diff --git a/freqtrade/configuration.py b/freqtrade/configuration.py index 72925e8ce..39368886b 100644 --- a/freqtrade/configuration.py +++ b/freqtrade/configuration.py @@ -21,12 +21,14 @@ class Configuration(object): self.args = args self.logger = Logger(name=__name__).get_logger() self.config = self._load_config() + self.show_info() def _load_config(self) -> Dict[str, Any]: """ Extract information for sys.argv and load the bot configuration :return: Configuration dictionary """ + self.logger.info('Using config: %s ...', self.args.config) config = self._load_config_file(self.args.config) # Add the strategy file to use diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py new file mode 100644 index 000000000..35acb7649 --- /dev/null +++ b/freqtrade/freqtradebot.py @@ -0,0 +1,548 @@ +""" +Freqtrade is the main module of this bot. It contains the class Freqtrade() +""" + +import arrow +import copy +import json +import requests +import time +import traceback +from cachetools import cached, TTLCache +from datetime import datetime +from typing import Dict, List, Optional, Any, Callable +from freqtrade.analyze import Analyze +from freqtrade.constants import Constants +from freqtrade.fiat_convert import CryptoToFiatConverter +from freqtrade.logger import Logger +from freqtrade.persistence import Trade +from freqtrade.rpc.rpc_manager import RPCManager +from freqtrade.state import State +from freqtrade import (DependencyException, OperationalException, exchange, persistence) + + +class FreqtradeBot(object): + """ + Freqtrade is the main class of the bot. + This is from here the bot start its logic. + """ + + def __init__(self, config: Dict[str, Any], db_url: Optional[str] = None) -> bool: + """ + Init all variables and object the bot need to work + :param config: configuration dict, you can use the Configuration.get_config() + method to get the config dict. + :param db_url: database connector string for sqlalchemy (Optional) + """ + + # Init the logger + self.logger = Logger(name='freqtrade').get_logger() + + # Init bot states + self._state = State.STOPPED + + # Init objects + self.config = config + self.analyze = None + self.fiat_converter = None + self.rpc = None + self.persistence = None + self.exchange = None + + self._init_modules(db_url=db_url) + + def _init_modules(self, db_url: Optional[str] = None) -> None: + """ + Initializes all modules and updates the config + :param db_url: database connector string for sqlalchemy (Optional) + :return: None + """ + # Initialize all modules + self.analyze = Analyze(self.config) + self.fiat_converter = CryptoToFiatConverter() + self.rpc = RPCManager(self) + + persistence.init(self.config, db_url) + exchange.init(self.config) + + # Set initial application state + initial_state = self.config.get('initial_state') + + if initial_state: + self.update_state(State[initial_state.upper()]) + else: + self.update_state(State.STOPPED) + + def clean(self) -> bool: + """ + Cleanup the application state und finish all pending tasks + :return: None + """ + self.rpc.send_msg('*Status:* `Stopping trader...`') + self.logger.info('Stopping trader and cleaning up modules...') + self.update_state(State.STOPPED) + self.rpc.cleanup() + persistence.cleanup() + return True + + def update_state(self, state: State) -> None: + """ + Updates the application state + :param state: new state + :return: None + """ + self._state = state + + def get_state(self) -> State: + """ + Gets the current application state + :return: + """ + return self._state + + def worker(self, old_state: None) -> State: + """ + Trading routine that must be run at each loop + :param old_state: the previous service state from the previous call + :return: current service state + """ + new_state = self.get_state() + # Log state transition + if new_state != old_state: + self.rpc.send_msg('*Status:* `{}`'.format(new_state.name.lower())) + self.logger.info('Changing state to: %s', new_state.name) + + if new_state == State.STOPPED: + time.sleep(1) + elif new_state == State.RUNNING: + min_secs = self.config['internals'].get( + 'process_throttle_secs', + Constants.PROCESS_THROTTLE_SECS + ) + + nb_assets = self.config.get( + 'dynamic_whitelist', + Constants.DYNAMIC_WHITELIST + ) + + interval = int( + self.config.get( + 'ticker_interval', + Constants.TICKER_INTERVAL + ) + ) + + self._throttle(func=self._process, + min_secs=min_secs, + nb_assets=nb_assets, + interval=interval) + return new_state + + def _throttle(self, func: Callable[..., Any], min_secs: float, *args, **kwargs) -> Any: + """ + Throttles the given callable that it + takes at least `min_secs` to finish execution. + :param func: Any callable + :param min_secs: minimum execution time in seconds + :return: Any + """ + start = time.time() + result = func(*args, **kwargs) + end = time.time() + duration = max(min_secs - (end - start), 0.0) + self.logger.debug('Throttling %s for %.2f seconds', func.__name__, duration) + time.sleep(duration) + return result + + def _process(self, interval: int, nb_assets: Optional[int] = 0) -> bool: + """ + Queries the persistence layer for open trades and handles them, + otherwise a new trade is created. + :param: nb_assets: the maximum number of pairs to be traded at the same time + :return: True if one or more trades has been created or closed, False otherwise + """ + state_changed = False + try: + # Refresh whitelist based on wallet maintenance + sanitized_list = self._refresh_whitelist( + self._gen_pair_whitelist( + self.config['stake_currency'] + ) if nb_assets else self.config['exchange']['pair_whitelist'] + ) + + # Keep only the subsets of pairs wanted (up to nb_assets) + final_list = sanitized_list[:nb_assets] if nb_assets else sanitized_list + self.config['exchange']['pair_whitelist'] = final_list + + # Query trades from persistence layer + trades = Trade.query.filter(Trade.is_open.is_(True)).all() + + # First process current opened trades + for trade in trades: + state_changed |= self.process_maybe_execute_sell(trade, interval) + + # Then looking for buy opportunities + if len(trades) < self.config['max_open_trades']: + state_changed = self.process_maybe_execute_buy(interval) + + if 'unfilledtimeout' in self.config: + # Check and handle any timed out open orders + self.check_handle_timedout(self.config['unfilledtimeout']) + Trade.session.flush() + + except (requests.exceptions.RequestException, json.JSONDecodeError) as error: + self.logger.warning( + 'Got %s in _process(), retrying in 30 seconds...', + error + ) + time.sleep(Constants.RETRY_TIMEOUT) + except OperationalException: + self.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.' + ) + ) + self.logger.exception('Got OperationalException. Stopping trader ...') + self.update_state(State.STOPPED) + return state_changed + + @cached(TTLCache(maxsize=1, ttl=1800)) + def _gen_pair_whitelist(self, base_currency: str, key: str = 'BaseVolume') -> List[str]: + """ + Updates the whitelist with with a dynamically generated list + :param base_currency: base currency as str + :param key: sort key (defaults to 'BaseVolume') + :return: List of pairs + """ + summaries = sorted( + (s for s in exchange.get_market_summaries() if + s['MarketName'].startswith(base_currency)), + key=lambda s: s.get(key) or 0.0, + reverse=True + ) + + return [s['MarketName'].replace('-', '_') for s in summaries] + + def _refresh_whitelist(self, whitelist: List[str]) -> List[str]: + """ + Check wallet health and remove pair from whitelist if necessary + :param whitelist: the sorted list (based on BaseVolume) of pairs the user might want to + trade + :return: the list of pairs the user wants to trade without the one unavailable or + black_listed + """ + sanitized_whitelist = whitelist + health = exchange.get_wallet_health() + known_pairs = set() + for status in health: + pair = '{}_{}'.format(self.config['stake_currency'], status['Currency']) + # pair is not int the generated dynamic market, or in the blacklist ... ignore it + if pair not in whitelist or pair in self.config['exchange'].get('pair_blacklist', []): + continue + # else the pair is valid + known_pairs.add(pair) + # Market is not active + if not status['IsActive']: + sanitized_whitelist.remove(pair) + self.logger.info( + 'Ignoring %s from whitelist (reason: %s).', + pair, status.get('Notice') or 'wallet is not active' + ) + + # We need to remove pairs that are unknown + final_list = [x for x in sanitized_whitelist if x in known_pairs] + return final_list + + def get_target_bid(self, ticker: Dict[str, float]) -> float: + """ + Calculates bid target between current ask price and last price + :param ticker: Ticker to use for getting Ask and Last Price + :return: float: Price + """ + if ticker['ask'] < ticker['last']: + return ticker['ask'] + balance = self.config['bid_strategy']['ask_last_balance'] + return ticker['ask'] + balance * (ticker['last'] - ticker['ask']) + + # TODO: Remove the two parameters and use the value already in conf['stake_amount'] and + # int(conf['ticker_interval']) + def create_trade(self, stake_amount: float, interval: int) -> bool: + """ + Checks the implemented trading indicator(s) for a randomly picked pair, + if one pair triggers the buy_signal a new trade record gets created + :param stake_amount: amount of btc to spend + :param interval: Ticker interval used for Analyze + :return: True if a trade object has been created and persisted, False otherwise + """ + self.logger.info( + 'Checking buy signals to create a new trade with stake_amount: %f ...', + stake_amount + ) + whitelist = copy.deepcopy(self.config['exchange']['pair_whitelist']) + # Check if stake_amount is fulfilled + if exchange.get_balance(self.config['stake_currency']) < stake_amount: + raise DependencyException( + 'stake amount is not fulfilled (currency={})'.format(self.config['stake_currency']) + ) + + # Remove currently opened and latest pairs from whitelist + for trade in Trade.query.filter(Trade.is_open.is_(True)).all(): + if trade.pair in whitelist: + whitelist.remove(trade.pair) + self.logger.debug('Ignoring %s in pair whitelist', trade.pair) + + if not whitelist: + raise DependencyException('No pair in whitelist') + + # Pick pair based on StochRSI buy signals + for _pair in whitelist: + (buy, sell) = self.analyze.get_signal(_pair, interval) + if buy and not sell: + pair = _pair + break + else: + return False + + # Calculate amount + buy_limit = self.get_target_bid(exchange.get_ticker(pair)) + amount = stake_amount / buy_limit + + order_id = exchange.buy(pair, buy_limit, amount) + + stake_amount_fiat = self.fiat_converter.convert_amount( + stake_amount, + self.config['stake_currency'], + self.config['fiat_display_currency'] + ) + + # Create trade entity and return + self.rpc.send_msg( + '*{}:* Buying [{}]({}) with limit `{:.8f} ({:.6f} {}, {:.3f} {})` ' + .format( + exchange.get_name().upper(), + pair.replace('_', '/'), + exchange.get_pair_detail_url(pair), + buy_limit, + stake_amount, + self.config['stake_currency'], + stake_amount_fiat, + self.config['fiat_display_currency'] + ) + ) + # Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL + trade = Trade( + pair=pair, + stake_amount=stake_amount, + amount=amount, + fee=exchange.get_fee(), + open_rate=buy_limit, + open_date=datetime.utcnow(), + exchange=exchange.get_name().upper(), + open_order_id=order_id + ) + Trade.session.add(trade) + Trade.session.flush() + return True + + def process_maybe_execute_buy(self, interval: int) -> bool: + """ + Tries to execute a buy trade in a safe way + :return: True if executed + """ + try: + # Create entity and execute trade + if self.create_trade(float(self.config['stake_amount']), interval): + return True + + self.logger.info( + 'Checked all whitelisted currencies. ' + 'Found no suitable entry positions for buying. Will keep looking ...' + ) + return False + except DependencyException as exception: + self.logger.warning('Unable to create trade: %s', exception) + return False + + def process_maybe_execute_sell(self, trade: Trade, interval: int) -> bool: + """ + Tries to execute a sell trade + :return: True if executed + """ + # Get order details for actual price per unit + if trade.open_order_id: + # Update trade with order values + self.logger.info('Got open order for %s', trade) + trade.update(exchange.get_order(trade.open_order_id)) + + if trade.is_open and trade.open_order_id is None: + # Check if we can sell our current pair + return self.handle_trade(trade, interval) + return False + + def handle_trade(self, trade: Trade, interval: int) -> bool: + """ + Sells the current pair if the threshold is reached and updates the trade record. + :return: True if trade has been sold, False otherwise + """ + if not trade.is_open: + raise ValueError('attempt to handle closed trade: {}'.format(trade)) + + self.logger.debug('Handling %s ...', trade) + current_rate = exchange.get_ticker(trade.pair)['bid'] + + (buy, sell) = (False, False) + + if self.config.get('experimental', {}).get('use_sell_signal'): + (buy, sell) = self.analyze.get_signal(trade.pair, interval) + + if self.analyze.should_sell(trade, current_rate, datetime.utcnow(), buy, sell): + self.execute_sell(trade, current_rate) + return True + + return False + + def check_handle_timedout(self, timeoutvalue: int) -> None: + """ + Check if any orders are timed out and cancel if neccessary + :param timeoutvalue: Number of minutes until order is considered timed out + :return: None + """ + timeoutthreashold = arrow.utcnow().shift(minutes=-timeoutvalue).datetime + + for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all(): + try: + order = exchange.get_order(trade.open_order_id) + except requests.exceptions.RequestException: + self.logger.info( + 'Cannot query order for %s due to %s', + trade, + traceback.format_exc()) + continue + ordertime = arrow.get(order['opened']) + + # Check if trade is still actually open + if int(order['remaining']) == 0: + continue + + if order['type'] == "LIMIT_BUY" and ordertime < timeoutthreashold: + self.handle_timedout_limit_buy(trade, order) + elif order['type'] == "LIMIT_SELL" and ordertime < timeoutthreashold: + self.handle_timedout_limit_sell(trade, order) + + # FIX: 20180110, why is cancel.order unconditionally here, whereas + # it is conditionally called in the + # handle_timedout_limit_sell()? + def handle_timedout_limit_buy(self, trade: Trade, order: Dict) -> bool: + """Buy timeout - cancel order + :return: True if order was fully cancelled + """ + exchange.cancel_order(trade.open_order_id) + if order['remaining'] == order['amount']: + # if trade is not partially completed, just delete the trade + Trade.session.delete(trade) + # FIX? do we really need to flush, caller of + # check_handle_timedout will flush afterwards + Trade.session.flush() + self.logger.info('Buy order timeout for %s.', trade) + self.rpc.send_msg('*Timeout:* Unfilled buy order for {} cancelled'.format( + trade.pair.replace('_', '/'))) + return True + + # if trade is partially complete, edit the stake details for the trade + # and close the order + trade.amount = order['amount'] - order['remaining'] + trade.stake_amount = trade.amount * trade.open_rate + trade.open_order_id = None + self.logger.info('Partial buy order timeout for %s.', trade) + self.rpc.send_msg('*Timeout:* Remaining buy order for {} cancelled'.format( + trade.pair.replace('_', '/'))) + return False + + # FIX: 20180110, should cancel_order() be cond. or unconditionally called? + def handle_timedout_limit_sell(self, trade: Trade, order: Dict) -> bool: + """ + Sell timeout - cancel order and update trade + :return: True if order was fully cancelled + """ + if order['remaining'] == order['amount']: + # if trade is not partially completed, just cancel the trade + exchange.cancel_order(trade.open_order_id) + trade.close_rate = None + trade.close_profit = None + trade.close_date = None + trade.is_open = True + trade.open_order_id = None + self.rpc.send_msg('*Timeout:* Unfilled sell order for {} cancelled'.format( + trade.pair.replace('_', '/'))) + self.logger.info('Sell order timeout for %s.', trade) + return True + + # TODO: figure out how to handle partially complete sell orders + return False + + def execute_sell(self, trade: Trade, limit: float) -> None: + """ + Executes a limit sell for the given trade and limit + :param trade: Trade instance + :param limit: limit rate for the sell order + :return: None + """ + # Execute sell and update trade record + order_id = exchange.sell(str(trade.pair), limit, trade.amount) + trade.open_order_id = order_id + + fmt_exp_profit = round(trade.calc_profit_percent(rate=limit) * 100, 2) + profit_trade = trade.calc_profit(rate=limit) + current_rate = exchange.get_ticker(trade.pair, False)['bid'] + profit = trade.calc_profit_percent(current_rate) + + message = "*{exchange}:* Selling\n" \ + "*Current Pair:* [{pair}]({pair_url})\n" \ + "*Limit:* `{limit}`\n" \ + "*Amount:* `{amount}`\n" \ + "*Open Rate:* `{open_rate:.8f}`\n" \ + "*Current Rate:* `{current_rate:.8f}`\n" \ + "*Profit:* `{profit:.2f}%`" \ + "".format( + exchange=trade.exchange, + pair=trade.pair, + pair_url=exchange.get_pair_detail_url(trade.pair), + limit=limit, + open_rate=trade.open_rate, + current_rate=current_rate, + amount=round(trade.amount, 8), + profit=round(profit * 100, 2), + ) + + # For regular case, when the configuration exists + if 'stake_currency' in self.config and 'fiat_display_currency' in self.config: + fiat_converter = CryptoToFiatConverter() + profit_fiat = fiat_converter.convert_amount( + profit_trade, + self.config['stake_currency'], + self.config['fiat_display_currency'] + ) + message += '` ({gain}: {profit_percent:.2f}%, {profit_coin:.8f} {coin}`' \ + '` / {profit_fiat:.3f} {fiat})`' \ + ''.format( + gain="profit" if fmt_exp_profit > 0 else "loss", + profit_percent=fmt_exp_profit, + profit_coin=profit_trade, + coin=self.config['stake_currency'], + profit_fiat=profit_fiat, + fiat=self.config['fiat_display_currency'], + ) + # Because telegram._forcesell does not have the configuration + # Ignore the FIAT value and does not show the stake_currency as well + else: + message += '` ({gain}: {profit_percent:.2f}%, {profit_coin:.8f})`'.format( + gain="profit" if fmt_exp_profit > 0 else "loss", + profit_percent=fmt_exp_profit, + profit_coin=profit_trade + ) + + # Send the message + self.rpc.send_msg(message) + Trade.session.flush() diff --git a/freqtrade/main.py b/freqtrade/main.py index 163597eee..47e941c44 100755 --- a/freqtrade/main.py +++ b/freqtrade/main.py @@ -1,570 +1,73 @@ #!/usr/bin/env python3 -import copy -import json +""" +Main Freqtrade bot script. +Read the documentation to know what cli arguments you need. +""" + import logging import sys -import time -import traceback -from datetime import datetime -from typing import Dict, List, Optional, Any +from typing import Dict +from freqtrade.configuration import Configuration +from freqtrade.arguments import Arguments +from freqtrade.freqtradebot import FreqtradeBot +from freqtrade.logger import Logger +from freqtrade import (__version__) -import arrow -import requests -from cachetools import cached, TTLCache - -from freqtrade import (DependencyException, OperationalException, __version__, - exchange, persistence, rpc) -from freqtrade.analyze import get_signal -from freqtrade.fiat_convert import CryptoToFiatConverter -from freqtrade.misc import (State, get_state, load_config, parse_args, - throttle, update_state) -from freqtrade.persistence import Trade -from freqtrade.strategy.strategy import Strategy - -logger = logging.getLogger('freqtrade') - -_CONF: Dict[str, Any] = {} +logger = Logger(name='freqtrade').get_logger() -def refresh_whitelist(whitelist: List[str]) -> List[str]: +def main(sysargv: Dict) -> None: """ - Check wallet health and remove pair from whitelist if necessary - :param whitelist: the sorted list (based on BaseVolume) of pairs the user might want to trade - :return: the list of pairs the user wants to trade without the one unavailable or black_listed - """ - sanitized_whitelist = whitelist - health = exchange.get_wallet_health() - known_pairs = set() - for status in health: - pair = '{}_{}'.format(_CONF['stake_currency'], status['Currency']) - # pair is not int the generated dynamic market, or in the blacklist ... ignore it - if pair not in whitelist or pair in _CONF['exchange'].get('pair_blacklist', []): - continue - # else the pair is valid - known_pairs.add(pair) - # Market is not active - if not status['IsActive']: - sanitized_whitelist.remove(pair) - logger.info( - 'Ignoring %s from whitelist (reason: %s).', - pair, status.get('Notice') or 'wallet is not active' - ) - - # We need to remove pairs that are unknown - final_list = [x for x in sanitized_whitelist if x in known_pairs] - return final_list - - -def process_maybe_execute_buy(interval: int) -> bool: - """ - Tries to execute a buy trade in a safe way - :return: True if executed - """ - try: - # Create entity and execute trade - if create_trade(float(_CONF['stake_amount']), interval): - return True - - logger.info( - 'Checked all whitelisted currencies. ' - 'Found no suitable entry positions for buying. Will keep looking ...' - ) - return False - except DependencyException as exception: - logger.warning('Unable to create trade: %s', exception) - return False - - -def process_maybe_execute_sell(trade: Trade, interval: int) -> bool: - """ - Tries to execute a sell trade - :return: True if executed - """ - # Get order details for actual price per unit - 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)) - - if trade.is_open and trade.open_order_id is None: - # Check if we can sell our current pair - return handle_trade(trade, interval) - return False - - -def _process(interval: int, nb_assets: Optional[int] = 0) -> bool: - """ - Queries the persistence layer for open trades and handles them, - otherwise a new trade is created. - :param: nb_assets: the maximum number of pairs to be traded at the same time - :return: True if one or more trades has been created or closed, False otherwise - """ - state_changed = False - try: - # Refresh whitelist based on wallet maintenance - sanitized_list = refresh_whitelist( - gen_pair_whitelist( - _CONF['stake_currency'] - ) if nb_assets else _CONF['exchange']['pair_whitelist'] - ) - - # Keep only the subsets of pairs wanted (up to nb_assets) - final_list = sanitized_list[:nb_assets] if nb_assets else sanitized_list - _CONF['exchange']['pair_whitelist'] = final_list - - # Query trades from persistence layer - trades = Trade.query.filter(Trade.is_open.is_(True)).all() - - # First process current opened trades - for trade in trades: - state_changed |= process_maybe_execute_sell(trade, interval) - - # Then looking for buy opportunities - if len(trades) < _CONF['max_open_trades']: - state_changed = process_maybe_execute_buy(interval) - - if 'unfilledtimeout' in _CONF: - # Check and handle any timed out open orders - check_handle_timedout(_CONF['unfilledtimeout']) - Trade.session.flush() - - except (requests.exceptions.RequestException, json.JSONDecodeError) as error: - logger.warning( - 'Got %s in _process(), retrying in 30 seconds...', - error - ) - time.sleep(30) - 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 OperationalException. Stopping trader ...') - update_state(State.STOPPED) - return state_changed - - -# FIX: 20180110, why is cancel.order unconditionally here, whereas -# it is conditionally called in the -# handle_timedout_limit_sell()? -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) - if order['remaining'] == order['amount']: - # if trade is not partially completed, just delete the trade - Trade.session.delete(trade) - # FIX? do we really need to flush, caller of - # check_handle_timedout will flush afterwards - Trade.session.flush() - logger.info('Buy order timeout for %s.', trade) - rpc.send_msg('*Timeout:* Unfilled buy order for {} cancelled'.format( - trade.pair.replace('_', '/'))) - return True - - # if trade is partially complete, edit the stake details for the trade - # and close the order - trade.amount = order['amount'] - order['remaining'] - trade.stake_amount = trade.amount * trade.open_rate - trade.open_order_id = None - logger.info('Partial buy order timeout for %s.', trade) - rpc.send_msg('*Timeout:* Remaining buy order for {} cancelled'.format( - trade.pair.replace('_', '/'))) - return False - - -# FIX: 20180110, should cancel_order() be cond. or unconditionally called? -def handle_timedout_limit_sell(trade: Trade, order: Dict) -> bool: - """ - Sell timeout - cancel order and update trade - :return: True if order was fully cancelled - """ - if order['remaining'] == order['amount']: - # if trade is not partially completed, just cancel the trade - exchange.cancel_order(trade.open_order_id) - trade.close_rate = None - trade.close_profit = None - trade.close_date = None - trade.is_open = True - trade.open_order_id = None - rpc.send_msg('*Timeout:* Unfilled sell order for {} cancelled'.format( - trade.pair.replace('_', '/'))) - logger.info('Sell order timeout for %s.', trade) - return True - - # TODO: figure out how to handle partially complete sell orders - return False - - -def check_handle_timedout(timeoutvalue: int) -> None: - """ - Check if any orders are timed out and cancel if neccessary - :param timeoutvalue: Number of minutes until order is considered timed out + This function will initiate the bot and start the trading loop. :return: None """ - timeoutthreashold = arrow.utcnow().shift(minutes=-timeoutvalue).datetime - - for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all(): - try: - order = exchange.get_order(trade.open_order_id) - except requests.exceptions.RequestException: - logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc()) - continue - ordertime = arrow.get(order['opened']) - - # Check if trade is still actually open - if int(order['remaining']) == 0: - continue - - if order['type'] == "LIMIT_BUY" and ordertime < timeoutthreashold: - handle_timedout_limit_buy(trade, order) - elif order['type'] == "LIMIT_SELL" and ordertime < timeoutthreashold: - handle_timedout_limit_sell(trade, order) - - -def execute_sell(trade: Trade, limit: float) -> None: - """ - Executes a limit sell for the given trade and limit - :param trade: Trade instance - :param limit: limit rate for the sell order - :return: None - """ - # Execute sell and update trade record - order_id = exchange.sell(str(trade.pair), limit, trade.amount) - trade.open_order_id = order_id - - fmt_exp_profit = round(trade.calc_profit_percent(rate=limit) * 100, 2) - profit_trade = trade.calc_profit(rate=limit) - current_rate = exchange.get_ticker(trade.pair, False)['bid'] - profit = trade.calc_profit_percent(current_rate) - - message = """*{exchange}:* Selling -*Current Pair:* [{pair}]({pair_url}) -*Limit:* `{limit}` -*Amount:* `{amount}` -*Open Rate:* `{open_rate:.8f}` -*Current Rate:* `{current_rate:.8f}` -*Profit:* `{profit:.2f}%` - """.format( - exchange=trade.exchange, - pair=trade.pair, - pair_url=exchange.get_pair_detail_url(trade.pair), - limit=limit, - open_rate=trade.open_rate, - current_rate=current_rate, - amount=round(trade.amount, 8), - profit=round(profit * 100, 2), + arguments = Arguments( + sysargv, + 'Simple High Frequency Trading Bot for crypto currencies' ) + args = arguments.get_parsed_arg() - # For regular case, when the configuration exists - if 'stake_currency' in _CONF and 'fiat_display_currency' in _CONF: - fiat_converter = CryptoToFiatConverter() - profit_fiat = fiat_converter.convert_amount( - profit_trade, - _CONF['stake_currency'], - _CONF['fiat_display_currency'] - ) - message += '` ({gain}: {profit_percent:.2f}%, {profit_coin:.8f} {coin}`' \ - '` / {profit_fiat:.3f} {fiat})`'.format( - gain="profit" if fmt_exp_profit > 0 else "loss", - profit_percent=fmt_exp_profit, - profit_coin=profit_trade, - coin=_CONF['stake_currency'], - profit_fiat=profit_fiat, - fiat=_CONF['fiat_display_currency'], - ) - # Because telegram._forcesell does not have the configuration - # Ignore the FIAT value and does not show the stake_currency as well - else: - message += '` ({gain}: {profit_percent:.2f}%, {profit_coin:.8f})`'.format( - gain="profit" if fmt_exp_profit > 0 else "loss", - profit_percent=fmt_exp_profit, - profit_coin=profit_trade - ) - - # Send the message - rpc.send_msg(message) - Trade.session.flush() - - -def min_roi_reached(trade: Trade, current_rate: float, current_time: datetime) -> bool: - """ - Based an earlier trade and current price and ROI configuration, decides whether bot should sell - :return True if bot should sell at current rate - """ - strategy = Strategy() - - current_profit = trade.calc_profit_percent(current_rate) - if strategy.stoploss is not None and current_profit < float(strategy.stoploss): - logger.debug('Stop loss hit.') - return True - - # Check if time matches and current rate is above threshold - time_diff = (current_time - trade.open_date).total_seconds() / 60 - for duration, threshold in sorted(strategy.minimal_roi.items()): - if time_diff > float(duration) and current_profit > threshold: - return True - - logger.debug('Threshold not reached. (cur_profit: %1.2f%%)', float(current_profit) * 100.0) - return False - - -def should_sell(trade: Trade, rate: float, date: datetime, buy: bool, sell: bool) -> bool: - """ - This function evaluate if on the condition required to trigger a sell has been reached - if the threshold is reached and updates the trade record. - :return: True if trade should be sold, False otherwise - """ - # Check if minimal roi has been reached and no longer in buy conditions (avoiding a fee) - if min_roi_reached(trade, rate, date): - logger.debug('Executing sell due to ROI ...') - return True - - # Experimental: Check if the trade is profitable before selling it (avoid selling at loss) - if _CONF.get('experimental', {}).get('sell_profit_only', False): - logger.debug('Checking if trade is profitable ...') - if trade.calc_profit(rate=rate) <= 0: - return False - - if sell and not buy and _CONF.get('experimental', {}).get('use_sell_signal', False): - logger.debug('Executing sell due to sell signal ...') - return True - - return False - - -def handle_trade(trade: Trade, interval: int) -> bool: - """ - Sells the current pair if the threshold is reached and updates the trade record. - :return: True if trade has been sold, False otherwise - """ - if not trade.is_open: - raise ValueError('attempt to handle closed trade: {}'.format(trade)) - - logger.debug('Handling %s ...', trade) - current_rate = exchange.get_ticker(trade.pair)['bid'] - - (buy, sell) = (False, False) - - if _CONF.get('experimental', {}).get('use_sell_signal'): - (buy, sell) = get_signal(trade.pair, interval) - - if should_sell(trade, current_rate, datetime.utcnow(), buy, sell): - execute_sell(trade, current_rate) - return True - - return False - - -def get_target_bid(ticker: Dict[str, float]) -> float: - """ Calculates bid target between current ask price and last price """ - if ticker['ask'] < ticker['last']: - return ticker['ask'] - balance = _CONF['bid_strategy']['ask_last_balance'] - return ticker['ask'] + balance * (ticker['last'] - ticker['ask']) - - -def create_trade(stake_amount: float, interval: int) -> bool: - """ - Checks the implemented trading indicator(s) for a randomly picked pair, - if one pair triggers the buy_signal a new trade record gets created - :param stake_amount: amount of btc to spend - :return: True if a trade object has been created and persisted, False otherwise - """ - logger.info( - 'Checking buy signals to create a new trade with stake_amount: %f ...', - stake_amount - ) - whitelist = copy.deepcopy(_CONF['exchange']['pair_whitelist']) - # Check if stake_amount is fulfilled - if exchange.get_balance(_CONF['stake_currency']) < stake_amount: - raise DependencyException( - 'stake amount is not fulfilled (currency={})'.format(_CONF['stake_currency']) - ) - - # Remove currently opened and latest pairs from whitelist - for trade in Trade.query.filter(Trade.is_open.is_(True)).all(): - if trade.pair in whitelist: - whitelist.remove(trade.pair) - logger.debug('Ignoring %s in pair whitelist', trade.pair) - if not whitelist: - raise DependencyException('No pair in whitelist') - - # Pick pair based on StochRSI buy signals - for _pair in whitelist: - (buy, sell) = get_signal(_pair, interval) - if buy and not sell: - pair = _pair - break - else: - return False - - # Calculate amount - buy_limit = get_target_bid(exchange.get_ticker(pair)) - amount = stake_amount / buy_limit - - order_id = exchange.buy(pair, buy_limit, amount) - - fiat_converter = CryptoToFiatConverter() - stake_amount_fiat = fiat_converter.convert_amount( - stake_amount, - _CONF['stake_currency'], - _CONF['fiat_display_currency'] - ) - - # Create trade entity and return - rpc.send_msg('*{}:* Buying [{}]({}) with limit `{:.8f} ({:.6f} {}, {:.3f} {})` '.format( - exchange.get_name().upper(), - pair.replace('_', '/'), - exchange.get_pair_detail_url(pair), - buy_limit, 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 - trade = Trade( - pair=pair, - stake_amount=stake_amount, - amount=amount, - fee=exchange.get_fee(), - open_rate=buy_limit, - open_date=datetime.utcnow(), - exchange=exchange.get_name().upper(), - open_order_id=order_id - ) - Trade.session.add(trade) - Trade.session.flush() - return True - - -def init(config: dict, db_url: Optional[str] = None) -> None: - """ - Initializes all modules and updates the config - :param config: config as dict - :param db_url: database connector string for sqlalchemy (Optional) - :return: None - """ - # Initialize all modules - rpc.init(config) - persistence.init(config, db_url) - exchange.init(config) - - strategy = Strategy() - strategy.init(config) - - # Set initial application state - initial_state = config.get('initial_state') - if initial_state: - update_state(State[initial_state.upper()]) - else: - update_state(State.STOPPED) - - -@cached(TTLCache(maxsize=1, ttl=1800)) -def gen_pair_whitelist(base_currency: str, key: str = 'BaseVolume') -> List[str]: - """ - Updates the whitelist with with a dynamically generated list - :param base_currency: base currency as str - :param key: sort key (defaults to 'BaseVolume') - :return: List of pairs - """ - summaries = sorted( - (s for s in exchange.get_market_summaries() if s['MarketName'].startswith(base_currency)), - key=lambda s: s.get(key) or 0.0, - reverse=True - ) - - return [s['MarketName'].replace('-', '_') for s in summaries] - - -def cleanup() -> None: - """ - Cleanup the application state und finish all pending tasks - :return: None - """ - rpc.send_msg('*Status:* `Stopping trader...`') - logger.info('Stopping trader and cleaning up modules...') - update_state(State.STOPPED) - persistence.cleanup() - rpc.cleanup() - exit(0) - - -def main(sysargv=sys.argv[1:]) -> int: - """ - Loads and validates the config and handles the main loop - :return: None - """ - global _CONF - args = parse_args(sysargv, - 'Simple High Frequency Trading Bot for crypto currencies') - - # A subcommand has been issued + # A subcommand has been issued. + # Means if Backtesting or Hyperopt have been called we exit the bot if hasattr(args, 'func'): args.func(args) return 0 - # Initialize logger - logging.basicConfig( - level=args.loglevel, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - ) - logger.info( 'Starting freqtrade %s (loglevel=%s)', __version__, logging.getLevelName(args.loglevel) ) - # Load and validate configuration - _CONF = load_config(args.config) - - # Add the strategy file to use - _CONF.update({'strategy': args.strategy}) - - # Initialize all modules and start main loop - if args.dynamic_whitelist: - logger.info('Using dynamically generated whitelist. (--dynamic-whitelist detected)') - - # If the user ask for Dry run with a local DB instead of memory - if args.dry_run_db: - if _CONF.get('dry_run', False): - _CONF.update({'dry_run_db': True}) - logger.info( - 'Dry_run will use the DB file: "tradesv3.dry_run.sqlite". (--dry_run_db detected)' - ) - else: - logger.info('Dry run is disabled. (--dry_run_db ignored)') - try: - init(_CONF) - old_state = None + # Load and validate configuration + configuration = Configuration(args) - 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) + # Init the bot + freqtrade = FreqtradeBot(configuration.get_config()) + + state = None + while 1: + state = freqtrade.worker(old_state=state) - if new_state == State.STOPPED: - time.sleep(1) - elif new_state == State.RUNNING: - throttle( - _process, - min_secs=_CONF['internals'].get('process_throttle_secs', 10), - nb_assets=args.dynamic_whitelist, - interval=int(_CONF.get('ticker_interval', 5)) - ) - old_state = new_state except KeyboardInterrupt: logger.info('Got SIGINT, aborting ...') except BaseException: logger.exception('Got fatal exception!') finally: - cleanup() - return 0 + freqtrade.clean() + sys.exit(0) + + +def set_loggers() -> None: + """ + Set the logger level for Third party libs + :return: None + """ + logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO) + logging.getLogger('telegram').setLevel(logging.INFO) if __name__ == '__main__': + set_loggers() main(sys.argv[1:]) diff --git a/freqtrade/tests/conftest.py b/freqtrade/tests/conftest.py index e2967b845..26a61e923 100644 --- a/freqtrade/tests/conftest.py +++ b/freqtrade/tests/conftest.py @@ -281,3 +281,134 @@ def default_strategy(): # that inserts a trade of some type and open-status # return the open-order-id # See tests in rpc/main that could use this + + +@pytest.fixture +def get_market_summaries_data(): + """ + This fixture is a real result from exchange.get_market_summaries() but reduced to only + 8 entries. 4 BTC, 4 USTD + :return: JSON market summaries + """ + return [ + { + 'Ask': 1.316e-05, + 'BaseVolume': 5.72599471, + 'Bid': 1.3e-05, + 'Created': '2014-04-14T00:00:00', + 'High': 1.414e-05, + 'Last': 1.298e-05, + 'Low': 1.282e-05, + 'MarketName': 'BTC-XWC', + 'OpenBuyOrders': 2000, + 'OpenSellOrders': 1484, + 'PrevDay': 1.376e-05, + 'TimeStamp': '2018-02-05T01:32:40.493', + 'Volume': 424041.21418375 + }, + { + 'Ask': 0.00627051, + 'BaseVolume': 93.23302388, + 'Bid': 0.00618192, + 'Created': '2016-10-20T04:48:30.387', + 'High': 0.00669897, + 'Last': 0.00618192, + 'Low': 0.006, + 'MarketName': 'BTC-XZC', + 'OpenBuyOrders': 343, + 'OpenSellOrders': 2037, + 'PrevDay': 0.00668229, + 'TimeStamp': '2018-02-05T01:32:43.383', + 'Volume': 14863.60730702 + }, + { + 'Ask': 0.01137247, + 'BaseVolume': 383.55922657, + 'Bid': 0.01136006, + 'Created': '2016-11-15T20:29:59.73', + 'High': 0.012, + 'Last': 0.01137247, + 'Low': 0.01119883, + 'MarketName': 'BTC-ZCL', + 'OpenBuyOrders': 1332, + 'OpenSellOrders': 5317, + 'PrevDay': 0.01179603, + 'TimeStamp': '2018-02-05T01:32:42.773', + 'Volume': 33308.07358285 + }, + { + 'Ask': 0.04155821, + 'BaseVolume': 274.75369074, + 'Bid': 0.04130002, + 'Created': '2016-10-28T17:13:10.833', + 'High': 0.04354429, + 'Last': 0.041585, + 'Low': 0.0413, + 'MarketName': 'BTC-ZEC', + 'OpenBuyOrders': 863, + 'OpenSellOrders': 5579, + 'PrevDay': 0.0429, + 'TimeStamp': '2018-02-05T01:32:43.21', + 'Volume': 6479.84033259 + }, + { + 'Ask': 210.99999999, + 'BaseVolume': 615132.70989532, + 'Bid': 210.05503736, + 'Created': '2017-07-21T01:08:49.397', + 'High': 257.396, + 'Last': 211.0, + 'Low': 209.05333589, + 'MarketName': 'USDT-XMR', + 'OpenBuyOrders': 180, + 'OpenSellOrders': 1203, + 'PrevDay': 247.93528899, + 'TimeStamp': '2018-02-05T01:32:43.117', + 'Volume': 2688.17410793 + }, + { + 'Ask': 0.79589979, + 'BaseVolume': 9349557.01853031, + 'Bid': 0.789226, + 'Created': '2017-07-14T17:10:10.737', + 'High': 0.977, + 'Last': 0.79589979, + 'Low': 0.781, + 'MarketName': 'USDT-XRP', + 'OpenBuyOrders': 1075, + 'OpenSellOrders': 6508, + 'PrevDay': 0.93300218, + 'TimeStamp': '2018-02-05T01:32:42.383', + 'Volume': 10801663.00788851 + }, + { + 'Ask': 0.05154982, + 'BaseVolume': 2311087.71232136, + 'Bid': 0.05040107, + 'Created': '2017-12-29T19:29:18.357', + 'High': 0.06668561, + 'Last': 0.0508, + 'Low': 0.05006731, + 'MarketName': 'USDT-XVG', + 'OpenBuyOrders': 655, + 'OpenSellOrders': 5544, + 'PrevDay': 0.0627, + 'TimeStamp': '2018-02-05T01:32:41.507', + 'Volume': 40031424.2152716 + }, + { + 'Ask': 332.65500022, + 'BaseVolume': 562911.87455665, + 'Bid': 330.00000001, + 'Created': '2017-07-14T17:10:10.673', + 'High': 401.59999999, + 'Last': 332.65500019, + 'Low': 330.0, + 'MarketName': 'USDT-ZEC', + 'OpenBuyOrders': 161, + 'OpenSellOrders': 1731, + 'PrevDay': 391.42, + 'TimeStamp': '2018-02-05T01:32:42.947', + 'Volume': 1571.09647946 + } + ] diff --git a/freqtrade/tests/test_freqtradebot.py b/freqtrade/tests/test_freqtradebot.py new file mode 100644 index 000000000..5177bd3b7 --- /dev/null +++ b/freqtrade/tests/test_freqtradebot.py @@ -0,0 +1,1213 @@ +# pragma pylint: disable=protected-access, too-many-lines, invalid-name, too-many-arguments + +""" +Unit test file for freqtradebot.py +""" + +import logging +import time +from unittest.mock import MagicMock +from copy import deepcopy +from typing import Optional +import arrow +import pytest +import requests +from sqlalchemy import create_engine + +import freqtrade.tests.conftest as tt # test tools +from freqtrade import DependencyException, OperationalException +from freqtrade.exchange import Exchanges +from freqtrade.freqtradebot import FreqtradeBot +from freqtrade.state import State +from freqtrade.persistence import Trade + + +# Functions for recurrent object patching +def get_patched_freqtradebot(mocker, config) -> FreqtradeBot: + """ + This function patch _init_modules() to not call dependencies + :param mocker: a Mocker object to apply patches + :param config: Config to pass to the bot + :return: None + """ + mocker.patch('freqtrade.freqtradebot.Analyze', MagicMock()) + mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock()) + mocker.patch('freqtrade.freqtradebot.persistence.init', MagicMock()) + mocker.patch('freqtrade.freqtradebot.exchange.init', MagicMock()) + patch_pymarketcap(mocker) + + return FreqtradeBot(config) + + +def patch_get_signal(mocker, value=(True, False)) -> None: + """ + + :param mocker: mocker to patch Analyze class + :param value: which value Analyze.get_signal() must return + :return: None + """ + mocker.patch( + 'freqtrade.freqtradebot.Analyze.get_signal', + side_effect=lambda s, t: value + ) + + +def patch_RPCManager(mocker) -> MagicMock: + """ + This function mock RPC manager to avoid repeating this code in almost every tests + :param mocker: mocker to patch RPCManager class + :return: RPCManager.send_msg MagicMock to track if this method is called + """ + mocker.patch('freqtrade.freqtradebot.RPCManager._init', MagicMock()) + rpc_mock = mocker.patch('freqtrade.freqtradebot.RPCManager.send_msg', MagicMock()) + return rpc_mock + + +def patch_pymarketcap(mocker, value: Optional[str] = None) -> None: + """ + Mocker to Pymarketcap to speed up tests + :param mocker: mocker to patch Pymarketcap class + :return: None + """ + pymarketcap = MagicMock() + + if value: + pymarketcap.ticker = {'price_usd': 12345.0} + + mocker.patch('freqtrade.fiat_convert.Pymarketcap', pymarketcap) + + +# Unit tests +def test_freqtradebot_object() -> None: + """ + Test the FreqtradeBot object has the mandatory public methods + """ + assert hasattr(FreqtradeBot, 'worker') + assert hasattr(FreqtradeBot, 'get_state') + assert hasattr(FreqtradeBot, 'update_state') + assert hasattr(FreqtradeBot, 'clean') + assert hasattr(FreqtradeBot, 'create_trade') + assert hasattr(FreqtradeBot, 'get_target_bid') + assert hasattr(FreqtradeBot, 'process_maybe_execute_buy') + assert hasattr(FreqtradeBot, 'process_maybe_execute_sell') + assert hasattr(FreqtradeBot, 'handle_trade') + assert hasattr(FreqtradeBot, 'check_handle_timedout') + assert hasattr(FreqtradeBot, 'handle_timedout_limit_buy') + assert hasattr(FreqtradeBot, 'handle_timedout_limit_sell') + assert hasattr(FreqtradeBot, 'execute_sell') + + +def test_freqtradebot(mocker, default_conf) -> None: + """ + Test __init__, _init_modules, update_state, and get_state methods + """ + freqtrade = get_patched_freqtradebot(mocker, default_conf) + assert freqtrade.get_state() is State.RUNNING + + conf = deepcopy(default_conf) + conf.pop('initial_state') + freqtrade = FreqtradeBot(conf) + assert freqtrade.get_state() is State.STOPPED + + +@pytest.mark.skip(reason="Test not implemented") +def test_clean() -> None: + """ + Test clean() method + """ + pass + + +@pytest.mark.skip(reason="Test not implemented") +def test_worker() -> None: + """ + Test worker() method + """ + pass + + +def test_throttle(mocker, default_conf, caplog) -> None: + """ + Test _throttle() method + """ + def func(): + """ + Test function to throttle + """ + return 42 + + caplog.set_level(logging.DEBUG) + freqtrade = get_patched_freqtradebot(mocker, default_conf) + + start = time.time() + result = freqtrade._throttle(func, min_secs=0.1) + end = time.time() + + assert result == 42 + assert end - start > 0.1 + assert tt.log_has('Throttling func for 0.10 seconds', caplog.record_tuples) + + result = freqtrade._throttle(func, min_secs=-1) + assert result == 42 + + +def test_throttle_with_assets(mocker, default_conf) -> None: + """ + Test _throttle() method when the function passed can have parameters + """ + def func(nb_assets=-1): + """ + Test function to throttle + """ + return nb_assets + + freqtrade = get_patched_freqtradebot(mocker, default_conf) + + result = freqtrade._throttle(func, min_secs=0.1, nb_assets=666) + assert result == 666 + + result = freqtrade._throttle(func, min_secs=0.1) + assert result == -1 + + +@pytest.mark.skip(reason="Test not implemented") +def test_process() -> None: + """ + Test _process() method + """ + pass + + +def test_gen_pair_whitelist(mocker, default_conf, get_market_summaries_data) -> None: + """ + Test _gen_pair_whitelist() method + """ + freqtrade = get_patched_freqtradebot(mocker, default_conf) + mocker.patch( + 'freqtrade.freqtradebot.exchange.get_market_summaries', + return_value=get_market_summaries_data + ) + + # Test to retrieved BTC sorted on BaseVolume + whitelist = freqtrade._gen_pair_whitelist(base_currency='BTC') + assert whitelist == ['BTC_ZCL', 'BTC_ZEC', 'BTC_XZC', 'BTC_XWC'] + + # Test to retrieved BTC sorted on OpenBuyOrders + whitelist = freqtrade._gen_pair_whitelist(base_currency='BTC', key='OpenBuyOrders') + assert whitelist == ['BTC_XWC', 'BTC_ZCL', 'BTC_ZEC', 'BTC_XZC'] + + # Test with USDT sorted on BaseVolume + whitelist = freqtrade._gen_pair_whitelist(base_currency='USDT') + assert whitelist == ['USDT_XRP', 'USDT_XVG', 'USDT_XMR', 'USDT_ZEC'] + + # Test with ETH (our fixture does not have ETH, but Bittrex returns them) + whitelist = freqtrade._gen_pair_whitelist(base_currency='ETH') + assert whitelist == [] + + +@pytest.mark.skip(reason="Test not implemented") +def test_refresh_whitelist() -> None: + """ + Test _refresh_whitelist() method + """ + pass + + +def test_create_trade(default_conf, ticker, limit_buy_order, mocker) -> None: + """ + Test create_trade() method + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + buy=MagicMock(return_value='mocked_limit_buy') + ) + + # Save state of current whitelist + whitelist = deepcopy(default_conf['exchange']['pair_whitelist']) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + assert trade is not None + assert trade.stake_amount == 0.001 + assert trade.is_open + assert trade.open_date is not None + assert trade.exchange == Exchanges.BITTREX.name + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + assert trade.open_rate == 0.00001099 + assert trade.amount == 90.99181073 + + assert whitelist == default_conf['exchange']['pair_whitelist'] + + +def test_create_trade_minimal_amount(default_conf, ticker, mocker) -> None: + """ + Test create_trade() method + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + buy_mock = MagicMock(return_value='mocked_limit_buy') + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + buy=buy_mock + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + min_stake_amount = 0.0005 + freqtrade.create_trade(min_stake_amount, int(default_conf['ticker_interval'])) + rate, amount = buy_mock.call_args[0][1], buy_mock.call_args[0][2] + assert rate * amount >= min_stake_amount + + +def test_create_trade_no_stake_amount(default_conf, ticker, mocker) -> None: + """ + Test create_trade() method + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + buy=MagicMock(return_value='mocked_limit_buy'), + get_balance=MagicMock(return_value=default_conf['stake_amount'] * 0.5) + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + with pytest.raises(DependencyException, match=r'.*stake amount.*'): + freqtrade.create_trade(default_conf['stake_amount'], int(default_conf['ticker_interval'])) + + +def test_create_trade_no_pairs(default_conf, ticker, mocker) -> None: + """ + Test create_trade() method + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + buy=MagicMock(return_value='mocked_limit_buy') + ) + + conf = deepcopy(default_conf) + conf['exchange']['pair_whitelist'] = ["BTC_ETH"] + conf['exchange']['pair_blacklist'] = ["BTC_ETH"] + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + with pytest.raises(DependencyException, match=r'.*No pair in whitelist.*'): + freqtrade.create_trade(default_conf['stake_amount'], int(default_conf['ticker_interval'])) + + +def test_create_trade_no_pairs_after_blacklist(default_conf, ticker, mocker) -> None: + """ + Test create_trade() method + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + buy=MagicMock(return_value='mocked_limit_buy') + ) + + conf = deepcopy(default_conf) + conf['exchange']['pair_whitelist'] = ["BTC_ETH"] + conf['exchange']['pair_blacklist'] = ["BTC_ETH"] + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + with pytest.raises(DependencyException, match=r'.*No pair in whitelist.*'): + freqtrade.create_trade(conf['stake_amount'], int(conf['ticker_interval'])) + + +def test_create_trade_no_signal(default_conf, mocker) -> None: + """ + Test create_trade() method + """ + conf = deepcopy(default_conf) + conf['dry_run'] = True + + patch_get_signal(mocker, value=(False, False)) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker_history=MagicMock(return_value=20), + get_balance=MagicMock(return_value=20) + ) + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + + stake_amount = 10 + Trade.query = MagicMock() + Trade.query.filter = MagicMock() + assert not freqtrade.create_trade(stake_amount, int(conf['ticker_interval'])) + + +def test_process_trade_creation(default_conf, ticker, limit_buy_order, + health, mocker, caplog) -> None: + """ + Test the trade creation in _process() method + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker, value={'price_usd': 12345.0}) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + get_wallet_health=health, + buy=MagicMock(return_value='mocked_limit_buy'), + get_order=MagicMock(return_value=limit_buy_order) + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + trades = Trade.query.filter(Trade.is_open.is_(True)).all() + assert not trades + + result = freqtrade._process(interval=int(default_conf['ticker_interval'])) + assert result is True + + trades = Trade.query.filter(Trade.is_open.is_(True)).all() + assert len(trades) == 1 + trade = trades[0] + assert trade is not None + assert trade.stake_amount == default_conf['stake_amount'] + assert trade.is_open + assert trade.open_date is not None + assert trade.exchange == Exchanges.BITTREX.name + assert trade.open_rate == 0.00001099 + assert trade.amount == 90.99181073703367 + + assert tt.log_has( + 'Checking buy signals to create a new trade with stake_amount: 0.001000 ...', + caplog.record_tuples + ) + + +def test_process_exchange_failures(default_conf, ticker, health, mocker) -> None: + """ + Test _process() method when a RequestException happens + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker, value={'price_usd': 12345.0}) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + get_wallet_health=health, + buy=MagicMock(side_effect=requests.exceptions.RequestException) + ) + sleep_mock = mocker.patch('time.sleep', side_effect=lambda _: None) + + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + result = freqtrade._process(interval=int(default_conf['ticker_interval'])) + assert result is False + assert sleep_mock.has_calls() + + +def test_process_operational_exception(default_conf, ticker, health, mocker) -> None: + """ + Test _process() method when an OperationalException happens + """ + patch_get_signal(mocker) + msg_mock = patch_RPCManager(mocker) + patch_pymarketcap(mocker, value={'price_usd': 12345.0}) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + get_wallet_health=health, + buy=MagicMock(side_effect=OperationalException) + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + assert freqtrade.get_state() == State.RUNNING + + result = freqtrade._process(interval=int(default_conf['ticker_interval'])) + assert result is False + assert freqtrade.get_state() == State.STOPPED + assert 'OperationalException' in msg_mock.call_args_list[-1][0][0] + + +def test_process_trade_handling(default_conf, ticker, limit_buy_order, health, mocker) -> None: + """ + Test _process() + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker, value={'price_usd': 12345.0}) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + get_wallet_health=health, + buy=MagicMock(return_value='mocked_limit_buy'), + get_order=MagicMock(return_value=limit_buy_order) + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + trades = Trade.query.filter(Trade.is_open.is_(True)).all() + assert not trades + result = freqtrade._process(interval=int(default_conf['ticker_interval'])) + assert result is True + trades = Trade.query.filter(Trade.is_open.is_(True)).all() + assert len(trades) == 1 + + result = freqtrade._process(interval=int(default_conf['ticker_interval'])) + assert result is False + + +@pytest.mark.skip(reason="Test not implemented") +def test_get_target_bid(): + """ + Test get_target_bid() method + """ + pass + + +def test_balance_fully_ask_side(mocker) -> None: + """ + Test get_target_bid() method + """ + freqtrade = get_patched_freqtradebot(mocker, {'bid_strategy': {'ask_last_balance': 0.0}}) + + assert freqtrade.get_target_bid({'ask': 20, 'last': 10}) == 20 + + +def test_balance_fully_last_side(mocker) -> None: + """ + Test get_target_bid() method + """ + freqtrade = get_patched_freqtradebot(mocker, {'bid_strategy': {'ask_last_balance': 1.0}}) + + assert freqtrade.get_target_bid({'ask': 20, 'last': 10}) == 10 + + +def test_balance_bigger_last_ask(mocker) -> None: + """ + Test get_target_bid() method + """ + freqtrade = get_patched_freqtradebot(mocker, {'bid_strategy': {'ask_last_balance': 1.0}}) + + assert freqtrade.get_target_bid({'ask': 5, 'last': 10}) == 5 + + +def test_process_maybe_execute_buy(mocker, default_conf) -> None: + """ + Test process_maybe_execute_buy() method + """ + freqtrade = get_patched_freqtradebot(mocker, default_conf) + + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.create_trade', MagicMock(return_value=True)) + assert freqtrade.process_maybe_execute_buy(int(default_conf['ticker_interval'])) + + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.create_trade', MagicMock(return_value=False)) + assert not freqtrade.process_maybe_execute_buy(int(default_conf['ticker_interval'])) + + +def test_process_maybe_execute_buy_exception(mocker, default_conf, caplog) -> None: + """ + Test exception on process_maybe_execute_buy() method + """ + caplog.set_level(logging.INFO) + freqtrade = get_patched_freqtradebot(mocker, default_conf) + + mocker.patch( + 'freqtrade.freqtradebot.FreqtradeBot.create_trade', + MagicMock(side_effect=DependencyException) + ) + freqtrade.process_maybe_execute_buy(int(default_conf['ticker_interval'])) + tt.log_has('Unable to create trade:', caplog.record_tuples) + + +def test_process_maybe_execute_sell(mocker, default_conf) -> None: + """ + Test process_maybe_execute_sell() method + """ + freqtrade = get_patched_freqtradebot(mocker, default_conf) + + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_trade', MagicMock(return_value=True)) + mocker.patch('freqtrade.freqtradebot.exchange.get_order', return_value=1) + + trade = MagicMock() + trade.open_order_id = '123' + assert not freqtrade.process_maybe_execute_sell(trade, int(default_conf['ticker_interval'])) + trade.is_open = True + trade.open_order_id = None + # Assert we call handle_trade() if trade is feasible for execution + assert freqtrade.process_maybe_execute_sell(trade, int(default_conf['ticker_interval'])) + + +def test_handle_trade(default_conf, limit_buy_order, limit_sell_order, mocker) -> None: + """ + Test check_handle() method + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.00001172, + 'ask': 0.00001173, + 'last': 0.00001172 + }), + buy=MagicMock(return_value='mocked_limit_buy'), + sell=MagicMock(return_value='mocked_limit_sell') + ) + patch_pymarketcap(mocker, value={'price_usd': 15000.0}) + mocker.patch('freqtrade.fiat_convert.Pymarketcap._cache_symbols', return_value={'BTC': 1}) + + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + assert trade + + trade.update(limit_buy_order) + assert trade.is_open is True + + patch_get_signal(mocker, value=(False, True)) + assert freqtrade.handle_trade(trade, int(default_conf['ticker_interval'])) is True + assert trade.open_order_id == 'mocked_limit_sell' + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + assert trade.close_rate == 0.00001173 + assert trade.close_profit == 0.06201057 + assert trade.calc_profit() == 0.00006217 + assert trade.close_date is not None + + +def test_handle_overlpapping_signals(default_conf, ticker, mocker) -> None: + """ + Test check_handle() method + """ + conf = deepcopy(default_conf) + conf.update({'experimental': {'use_sell_signal': True}}) + + patch_get_signal(mocker, value=(True, True)) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch('freqtrade.freqtradebot.Analyze.min_roi_reached', return_value=False) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + buy=MagicMock(return_value='mocked_limit_buy') + ) + + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + # Buy and Sell triggering, so doing nothing ... + trades = Trade.query.all() + nb_trades = len(trades) + assert nb_trades == 0 + + # Buy is triggering, so buying ... + patch_get_signal(mocker, value=(True, False)) + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + trades = Trade.query.all() + nb_trades = len(trades) + assert nb_trades == 1 + assert trades[0].is_open is True + + # Buy and Sell are not triggering, so doing nothing ... + patch_get_signal(mocker, value=(False, False)) + assert freqtrade.handle_trade(trades[0], int(default_conf['ticker_interval'])) is False + trades = Trade.query.all() + nb_trades = len(trades) + assert nb_trades == 1 + assert trades[0].is_open is True + + # Buy and Sell are triggering, so doing nothing ... + patch_get_signal(mocker, value=(True, True)) + assert freqtrade.handle_trade(trades[0], int(default_conf['ticker_interval'])) is False + trades = Trade.query.all() + nb_trades = len(trades) + assert nb_trades == 1 + assert trades[0].is_open is True + + # Sell is triggering, guess what : we are Selling! + patch_get_signal(mocker, value=(False, True)) + trades = Trade.query.all() + assert freqtrade.handle_trade(trades[0], int(default_conf['ticker_interval'])) is True + + +def test_handle_trade_roi(default_conf, ticker, mocker, caplog) -> None: + """ + Test check_handle() method + """ + caplog.set_level(logging.DEBUG) + conf = deepcopy(default_conf) + conf.update({'experimental': {'use_sell_signal': True}}) + + patch_get_signal(mocker, value=(True, False)) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + buy=MagicMock(return_value='mocked_limit_buy') + ) + + mocker.patch('freqtrade.freqtradebot.Analyze.min_roi_reached', return_value=True) + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + trade.is_open = True + + # FIX: sniffing logs, suggest handle_trade should not execute_sell + # instead that responsibility should be moved out of handle_trade(), + # we might just want to check if we are in a sell condition without + # executing + # if ROI is reached we must sell + patch_get_signal(mocker, value=(False, True)) + assert freqtrade.handle_trade(trade, interval=int(default_conf['ticker_interval'])) + assert tt.log_has('Executing sell due to ROI ...', caplog.record_tuples) + + +def test_handle_trade_experimental(default_conf, ticker, mocker, caplog) -> None: + """ + Test check_handle() method + """ + caplog.set_level(logging.DEBUG) + conf = deepcopy(default_conf) + conf.update({'experimental': {'use_sell_signal': True}}) + + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + buy=MagicMock(return_value='mocked_limit_buy') + ) + mocker.patch('freqtrade.freqtradebot.Analyze.min_roi_reached', return_value=False) + + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + trade.is_open = True + + patch_get_signal(mocker, value=(False, False)) + assert not freqtrade.handle_trade(trade, int(default_conf['ticker_interval'])) + + patch_get_signal(mocker, value=(False, True)) + assert freqtrade.handle_trade(trade, int(default_conf['ticker_interval'])) + assert tt.log_has('Executing sell due to sell signal ...', caplog.record_tuples) + + +def test_close_trade(default_conf, ticker, limit_buy_order, limit_sell_order, mocker) -> None: + """ + Test check_handle() method + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + buy=MagicMock(return_value='mocked_limit_buy') + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + # Create trade and sell it + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + assert trade + + trade.update(limit_buy_order) + trade.update(limit_sell_order) + assert trade.is_open is False + + with pytest.raises(ValueError, match=r'.*closed trade.*'): + freqtrade.handle_trade(trade, int(default_conf['ticker_interval'])) + + +def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, mocker) -> None: + """ + Test check_handle_timedout() method + """ + rpc_mock = patch_RPCManager(mocker) + cancel_order_mock = MagicMock() + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + get_order=MagicMock(return_value=limit_buy_order_old), + cancel_order=cancel_order_mock + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + trade_buy = Trade( + pair='BTC_ETH', + open_rate=0.00001099, + exchange='BITTREX', + open_order_id='123456789', + amount=90.99181073, + fee=0.0, + stake_amount=1, + open_date=arrow.utcnow().shift(minutes=-601).datetime, + is_open=True + ) + + Trade.session.add(trade_buy) + + # check it does cancel buy orders over the time limit + freqtrade.check_handle_timedout(600) + assert cancel_order_mock.call_count == 1 + assert rpc_mock.call_count == 1 + trades = Trade.query.filter(Trade.open_order_id.is_(trade_buy.open_order_id)).all() + nb_trades = len(trades) + assert nb_trades == 0 + + +def test_check_handle_timedout_sell(default_conf, ticker, limit_sell_order_old, mocker) -> None: + """ + Test check_handle_timedout() method + """ + rpc_mock = patch_RPCManager(mocker) + patch_pymarketcap(mocker) + cancel_order_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + get_order=MagicMock(return_value=limit_sell_order_old), + cancel_order=cancel_order_mock + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + trade_sell = Trade( + pair='BTC_ETH', + open_rate=0.00001099, + exchange='BITTREX', + open_order_id='123456789', + amount=90.99181073, + fee=0.0, + stake_amount=1, + open_date=arrow.utcnow().shift(hours=-5).datetime, + close_date=arrow.utcnow().shift(minutes=-601).datetime, + is_open=False + ) + + Trade.session.add(trade_sell) + + # check it does cancel sell orders over the time limit + freqtrade.check_handle_timedout(600) + assert cancel_order_mock.call_count == 1 + assert rpc_mock.call_count == 1 + assert trade_sell.is_open is True + + +def test_check_handle_timedout_partial(default_conf, ticker, limit_buy_order_old_partial, + mocker) -> None: + """ + Test check_handle_timedout() method + """ + rpc_mock = patch_RPCManager(mocker) + patch_pymarketcap(mocker) + cancel_order_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + get_order=MagicMock(return_value=limit_buy_order_old_partial), + cancel_order=cancel_order_mock + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + trade_buy = Trade( + pair='BTC_ETH', + open_rate=0.00001099, + exchange='BITTREX', + open_order_id='123456789', + amount=90.99181073, + fee=0.0, + stake_amount=1, + open_date=arrow.utcnow().shift(minutes=-601).datetime, + is_open=True + ) + + Trade.session.add(trade_buy) + + # check it does cancel buy orders over the time limit + # note this is for a partially-complete buy order + freqtrade.check_handle_timedout(600) + assert cancel_order_mock.call_count == 1 + assert rpc_mock.call_count == 1 + trades = Trade.query.filter(Trade.open_order_id.is_(trade_buy.open_order_id)).all() + assert len(trades) == 1 + assert trades[0].amount == 23.0 + assert trades[0].stake_amount == trade_buy.open_rate * trades[0].amount + + +def test_handle_timedout_limit_buy(mocker, default_conf) -> None: + """ + Test handle_timedout_limit_buy() method + """ + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + cancel_order_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + cancel_order=cancel_order_mock + ) + + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + Trade.session = MagicMock() + trade = MagicMock() + order = {'remaining': 1, + 'amount': 1} + assert freqtrade.handle_timedout_limit_buy(trade, order) + assert cancel_order_mock.call_count == 1 + order['amount'] = 2 + assert not freqtrade.handle_timedout_limit_buy(trade, order) + assert cancel_order_mock.call_count == 2 + + +def test_handle_timedout_limit_sell(mocker, default_conf) -> None: + """ + Test handle_timedout_limit_sell() method + """ + patch_RPCManager(mocker) + cancel_order_mock = MagicMock() + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + cancel_order=cancel_order_mock + ) + + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + trade = MagicMock() + order = {'remaining': 1, + 'amount': 1} + assert freqtrade.handle_timedout_limit_sell(trade, order) + assert cancel_order_mock.call_count == 1 + order['amount'] = 2 + assert not freqtrade.handle_timedout_limit_sell(trade, order) + # Assert cancel_order was not called (callcount remains unchanged) + assert cancel_order_mock.call_count == 1 + + +def test_execute_sell_up(default_conf, ticker, ticker_sell_up, mocker) -> None: + """ + Test execute_sell() method with a ticker going UP + """ + patch_get_signal(mocker) + rpc_mock = patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker + ) + mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + # Create some test data + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + assert trade + + # Increase the price and sell it + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker_sell_up + ) + + freqtrade.execute_sell(trade=trade, limit=ticker_sell_up()['bid']) + + assert rpc_mock.call_count == 2 + assert 'Selling' in rpc_mock.call_args_list[-1][0][0] + assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0] + assert 'Amount' in rpc_mock.call_args_list[-1][0][0] + assert 'Profit' in rpc_mock.call_args_list[-1][0][0] + assert '0.00001172' in rpc_mock.call_args_list[-1][0][0] + assert 'profit: 6.11%, 0.00006126' in rpc_mock.call_args_list[-1][0][0] + assert '0.919 USD' in rpc_mock.call_args_list[-1][0][0] + + +def test_execute_sell_down(default_conf, ticker, ticker_sell_down, mocker) -> None: + """ + Test execute_sell() method with a ticker going DOWN + """ + patch_get_signal(mocker) + rpc_mock = patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + # Create some test data + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + assert trade + + # Decrease the price and sell it + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker_sell_down + ) + + freqtrade.execute_sell(trade=trade, limit=ticker_sell_down()['bid']) + + assert rpc_mock.call_count == 2 + assert 'Selling' in rpc_mock.call_args_list[-1][0][0] + assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0] + assert 'Amount' in rpc_mock.call_args_list[-1][0][0] + assert '0.00001044' in rpc_mock.call_args_list[-1][0][0] + assert 'loss: -5.48%, -0.00005492' in rpc_mock.call_args_list[-1][0][0] + assert '-0.824 USD' in rpc_mock.call_args_list[-1][0][0] + + +def test_execute_sell_without_conf_sell_up(default_conf, ticker, ticker_sell_up, mocker) -> None: + """ + Test execute_sell() method with a ticker going DOWN and with a bot config empty + """ + patch_get_signal(mocker) + rpc_mock = patch_RPCManager(mocker) + patch_pymarketcap(mocker, value={'price_usd': 12345.0}) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + # Create some test data + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + assert trade + + # Increase the price and sell it + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker_sell_up + ) + freqtrade.config = {} + + freqtrade.execute_sell(trade=trade, limit=ticker_sell_up()['bid']) + + assert rpc_mock.call_count == 2 + assert 'Selling' in rpc_mock.call_args_list[-1][0][0] + assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0] + assert 'Amount' in rpc_mock.call_args_list[-1][0][0] + assert '0.00001172' in rpc_mock.call_args_list[-1][0][0] + assert '(profit: 6.11%, 0.00006126)' in rpc_mock.call_args_list[-1][0][0] + assert 'USD' not in rpc_mock.call_args_list[-1][0][0] + + +def test_execute_sell_without_conf_sell_down(default_conf, ticker, + ticker_sell_down, mocker) -> None: + """ + Test execute_sell() method with a ticker going DOWN and with a bot config empty + """ + patch_get_signal(mocker) + rpc_mock = patch_RPCManager(mocker) + patch_pymarketcap(mocker, value={'price_usd': 12345.0}) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker + ) + freqtrade = FreqtradeBot(default_conf, create_engine('sqlite://')) + + # Create some test data + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + assert trade + + # Decrease the price and sell it + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker_sell_down + ) + + freqtrade.config = {} + freqtrade.execute_sell(trade=trade, limit=ticker_sell_down()['bid']) + + assert rpc_mock.call_count == 2 + assert 'Selling' in rpc_mock.call_args_list[-1][0][0] + assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0] + assert '0.00001044' in rpc_mock.call_args_list[-1][0][0] + assert 'loss: -5.48%, -0.00005492' in rpc_mock.call_args_list[-1][0][0] + + +def test_sell_profit_only_enable_profit(default_conf, limit_buy_order, mocker) -> None: + """ + Test sell_profit_only feature when enabled + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch('freqtrade.freqtradebot.Analyze.min_roi_reached', return_value=False) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.00002172, + 'ask': 0.00002173, + 'last': 0.00002172 + }), + buy=MagicMock(return_value='mocked_limit_buy') + ) + conf = deepcopy(default_conf) + conf['experimental'] = { + 'use_sell_signal': True, + 'sell_profit_only': True, + } + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + trade.update(limit_buy_order) + patch_get_signal(mocker, value=(False, True)) + assert freqtrade.handle_trade(trade, int(default_conf['ticker_interval'])) is True + + +def test_sell_profit_only_disable_profit(default_conf, limit_buy_order, mocker) -> None: + """ + Test sell_profit_only feature when disabled + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch('freqtrade.freqtradebot.Analyze.min_roi_reached', return_value=False) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.00002172, + 'ask': 0.00002173, + 'last': 0.00002172 + }), + buy=MagicMock(return_value='mocked_limit_buy') + ) + conf = deepcopy(default_conf) + conf['experimental'] = { + 'use_sell_signal': True, + 'sell_profit_only': False, + } + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + trade.update(limit_buy_order) + patch_get_signal(mocker, value=(False, True)) + assert freqtrade.handle_trade(trade, int(default_conf['ticker_interval'])) is True + + +def test_sell_profit_only_enable_loss(default_conf, limit_buy_order, mocker) -> None: + """ + Test sell_profit_only feature when enabled and we have a loss + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch('freqtrade.freqtradebot.Analyze.min_roi_reached', return_value=False) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.00000172, + 'ask': 0.00000173, + 'last': 0.00000172 + }), + buy=MagicMock(return_value='mocked_limit_buy') + ) + conf = deepcopy(default_conf) + conf['experimental'] = { + 'use_sell_signal': True, + 'sell_profit_only': True, + } + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + trade.update(limit_buy_order) + patch_get_signal(mocker, value=(False, True)) + assert freqtrade.handle_trade(trade, int(default_conf['ticker_interval'])) is False + + +def test_sell_profit_only_disable_loss(default_conf, limit_buy_order, mocker) -> None: + """ + Test sell_profit_only feature when enabled and we have a loss + """ + patch_get_signal(mocker) + patch_RPCManager(mocker) + patch_pymarketcap(mocker) + mocker.patch('freqtrade.freqtradebot.Analyze.min_roi_reached', return_value=False) + mocker.patch.multiple( + 'freqtrade.freqtradebot.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.00000172, + 'ask': 0.00000173, + 'last': 0.00000172 + }), + buy=MagicMock(return_value='mocked_limit_buy') + ) + + conf = deepcopy(default_conf) + conf['experimental'] = { + 'use_sell_signal': True, + 'sell_profit_only': False, + } + + freqtrade = FreqtradeBot(conf, create_engine('sqlite://')) + freqtrade.create_trade(0.001, int(default_conf['ticker_interval'])) + + trade = Trade.query.first() + trade.update(limit_buy_order) + patch_get_signal(mocker, value=(False, True)) + assert freqtrade.handle_trade(trade, int(default_conf['ticker_interval'])) is True diff --git a/freqtrade/tests/test_main.py b/freqtrade/tests/test_main.py index 1adfa8418..ae89912e3 100644 --- a/freqtrade/tests/test_main.py +++ b/freqtrade/tests/test_main.py @@ -1,30 +1,22 @@ -# pragma pylint: disable=missing-docstring, C0103 -import copy +""" +Unit test file for main.py +""" + import logging from unittest.mock import MagicMock - -import arrow import pytest -import requests -from sqlalchemy import create_engine -import freqtrade.main as main +from freqtrade.main import main, set_loggers import freqtrade.tests.conftest as tt # test tools -from freqtrade import DependencyException, OperationalException -from freqtrade.exchange import Exchanges -from freqtrade.main import (_process, check_handle_timedout, create_trade, - execute_sell, get_target_bid, handle_trade, init) -from freqtrade.misc import State, get_state -from freqtrade.persistence import Trade -def test_parse_args_backtesting(mocker): - """ Test that main() can start backtesting or hyperopt. - and also ensure we can pass some specific arguments - further argument parsing is done in test_misc.py """ - backtesting_mock = mocker.patch( - 'freqtrade.optimize.backtesting.start', MagicMock()) - main.main(['backtesting']) +def test_parse_args_backtesting(mocker) -> None: + """ + Test that main() can start backtesting and also ensure we can pass some specific arguments + further argument parsing is done in test_arguments.py + """ + backtesting_mock = mocker.patch('freqtrade.optimize.backtesting.start', MagicMock()) + main(['backtesting']) assert backtesting_mock.call_count == 1 call_args = backtesting_mock.call_args[0][0] assert call_args.config == 'config.json' @@ -35,10 +27,12 @@ def test_parse_args_backtesting(mocker): assert call_args.ticker_interval is None -def test_main_start_hyperopt(mocker): - hyperopt_mock = mocker.patch( - 'freqtrade.optimize.hyperopt.start', MagicMock()) - main.main(['hyperopt']) +def test_main_start_hyperopt(mocker) -> None: + """ + Test that main() can start hyperopt. + """ + hyperopt_mock = mocker.patch('freqtrade.optimize.hyperopt.start', MagicMock()) + main(['hyperopt']) assert hyperopt_mock.call_count == 1 call_args = hyperopt_mock.call_args[0][0] assert call_args.config == 'config.json' @@ -47,795 +41,51 @@ def test_main_start_hyperopt(mocker): assert call_args.func is not None -def test_process_maybe_execute_buy(default_conf, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.create_trade', return_value=True) - assert main.process_maybe_execute_buy(int(default_conf['ticker_interval'])) - mocker.patch('freqtrade.main.create_trade', return_value=False) - assert not main.process_maybe_execute_buy(int(default_conf['ticker_interval'])) - - -def test_process_maybe_execute_sell(default_conf, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.handle_trade', return_value=True) - mocker.patch('freqtrade.exchange.get_order', return_value=1) - trade = MagicMock() - trade.open_order_id = '123' - assert not main.process_maybe_execute_sell(trade, int(default_conf['ticker_interval'])) - trade.is_open = True - trade.open_order_id = None - # Assert we call handle_trade() if trade is feasible for execution - assert main.process_maybe_execute_sell(trade, int(default_conf['ticker_interval'])) - - -def test_process_maybe_execute_buy_exception(default_conf, mocker, caplog): - caplog.set_level(logging.INFO) - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.create_trade', MagicMock(side_effect=DependencyException)) - main.process_maybe_execute_buy(int(default_conf['ticker_interval'])) - tt.log_has('Unable to create trade:', caplog.record_tuples) - - -def test_process_trade_creation(default_conf, ticker, limit_buy_order, health, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - get_wallet_health=health, - buy=MagicMock(return_value='mocked_limit_buy'), - get_order=MagicMock(return_value=limit_buy_order)) - init(default_conf, create_engine('sqlite://')) - - trades = Trade.query.filter(Trade.is_open.is_(True)).all() - assert not trades - - result = _process(interval=int(default_conf['ticker_interval'])) - assert result is True - - trades = Trade.query.filter(Trade.is_open.is_(True)).all() - assert len(trades) == 1 - trade = trades[0] - assert trade is not None - assert trade.stake_amount == default_conf['stake_amount'] - assert trade.is_open - assert trade.open_date is not None - assert trade.exchange == Exchanges.BITTREX.name - assert trade.open_rate == 0.00001099 - assert trade.amount == 90.99181073703367 - - -def test_process_exchange_failures(default_conf, ticker, health, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - sleep_mock = mocker.patch('time.sleep', side_effect=lambda _: None) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - get_wallet_health=health, - buy=MagicMock(side_effect=requests.exceptions.RequestException)) - init(default_conf, create_engine('sqlite://')) - result = _process(interval=int(default_conf['ticker_interval'])) - assert result is False - assert sleep_mock.has_calls() - - -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) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - get_wallet_health=health, - buy=MagicMock(side_effect=OperationalException)) - init(default_conf, create_engine('sqlite://')) - assert get_state() == State.RUNNING - - result = _process(interval=int(default_conf['ticker_interval'])) - assert result is False - assert get_state() == State.STOPPED - assert 'OperationalException' in msg_mock.call_args_list[-1][0][0] - - -def test_process_trade_handling(default_conf, ticker, limit_buy_order, health, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - get_wallet_health=health, - buy=MagicMock(return_value='mocked_limit_buy'), - get_order=MagicMock(return_value=limit_buy_order)) - init(default_conf, create_engine('sqlite://')) - - trades = Trade.query.filter(Trade.is_open.is_(True)).all() - assert not trades - result = _process(interval=int(default_conf['ticker_interval'])) - assert result is True - trades = Trade.query.filter(Trade.is_open.is_(True)).all() - assert len(trades) == 1 - - result = _process(interval=int(default_conf['ticker_interval'])) - assert result is False - - -def test_create_trade(default_conf, ticker, limit_buy_order, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - buy=MagicMock(return_value='mocked_limit_buy')) - # Save state of current whitelist - whitelist = copy.deepcopy(default_conf['exchange']['pair_whitelist']) - - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - assert trade is not None - assert trade.stake_amount == 0.001 - assert trade.is_open - assert trade.open_date is not None - assert trade.exchange == Exchanges.BITTREX.name - - # Simulate fulfilled LIMIT_BUY order for trade - trade.update(limit_buy_order) - - assert trade.open_rate == 0.00001099 - assert trade.amount == 90.99181073 - - assert whitelist == default_conf['exchange']['pair_whitelist'] - - -def test_create_trade_minimal_amount(default_conf, ticker, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - buy_mock = mocker.patch( - 'freqtrade.main.exchange.buy', MagicMock(return_value='mocked_limit_buy') - ) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker) - init(default_conf, create_engine('sqlite://')) - min_stake_amount = 0.0005 - create_trade(min_stake_amount, int(default_conf['ticker_interval'])) - rate, amount = buy_mock.call_args[0][1], buy_mock.call_args[0][2] - assert rate * amount >= min_stake_amount - - -def test_create_trade_no_stake_amount(default_conf, ticker, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - buy=MagicMock(return_value='mocked_limit_buy'), - get_balance=MagicMock(return_value=default_conf['stake_amount'] * 0.5)) - with pytest.raises(DependencyException, match=r'.*stake amount.*'): - create_trade(default_conf['stake_amount'], int(default_conf['ticker_interval'])) - - -def test_create_trade_no_pairs(default_conf, ticker, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - buy=MagicMock(return_value='mocked_limit_buy')) - - 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) - create_trade(default_conf['stake_amount'], int(default_conf['ticker_interval'])) - - -def test_create_trade_no_pairs_after_blacklist(default_conf, ticker, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - buy=MagicMock(return_value='mocked_limit_buy')) - - with pytest.raises(DependencyException, match=r'.*No pair in whitelist.*'): - conf = copy.deepcopy(default_conf) - conf['exchange']['pair_whitelist'] = ["BTC_ETH"] - conf['exchange']['pair_blacklist'] = ["BTC_ETH"] - mocker.patch.dict('freqtrade.main._CONF', conf) - create_trade(default_conf['stake_amount'], int(default_conf['ticker_interval'])) - - -def test_create_trade_no_signal(default_conf, mocker): - default_conf['dry_run'] = True - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', MagicMock(return_value=(False, False))) - mocker.patch.multiple('freqtrade.exchange', - get_ticker_history=MagicMock(return_value=20)) - mocker.patch.multiple('freqtrade.main.exchange', - get_balance=MagicMock(return_value=20)) - stake_amount = 10 - Trade.query = MagicMock() - Trade.query.filter = MagicMock() - assert not create_trade(stake_amount, int(default_conf['ticker_interval'])) - - -def test_handle_trade(default_conf, limit_buy_order, limit_sell_order, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=MagicMock(return_value={ - 'bid': 0.00001172, - 'ask': 0.00001173, - 'last': 0.00001172 - }), - buy=MagicMock(return_value='mocked_limit_buy'), - sell=MagicMock(return_value='mocked_limit_sell')) - mocker.patch.multiple('freqtrade.fiat_convert.Pymarketcap', - ticker=MagicMock(return_value={'price_usd': 15000.0}), - _cache_symbols=MagicMock(return_value={'BTC': 1})) - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - assert trade - - trade.update(limit_buy_order) - assert trade.is_open is True - - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True)) - assert handle_trade(trade, int(default_conf['ticker_interval'])) is True - assert trade.open_order_id == 'mocked_limit_sell' - - # Simulate fulfilled LIMIT_SELL order for trade - trade.update(limit_sell_order) - - assert trade.close_rate == 0.00001173 - assert trade.close_profit == 0.06201057 - assert trade.calc_profit() == 0.00006217 - assert trade.close_date is not None - - -def test_handle_overlpapping_signals(default_conf, ticker, mocker): - default_conf.update({'experimental': {'use_sell_signal': True}}) - mocker.patch.dict('freqtrade.main._CONF', default_conf) - - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, True)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - buy=MagicMock(return_value='mocked_limit_buy')) - mocker.patch('freqtrade.main.min_roi_reached', return_value=False) - - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - # Buy and Sell triggering, so doing nothing ... - trades = Trade.query.all() - nb_trades = len(trades) - assert nb_trades == 0 - - # Buy is triggering, so buying ... - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - create_trade(0.001, int(default_conf['ticker_interval'])) - trades = Trade.query.all() - nb_trades = len(trades) - assert nb_trades == 1 - assert trades[0].is_open is True - - # Buy and Sell are not triggering, so doing nothing ... - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, False)) - assert handle_trade(trades[0], int(default_conf['ticker_interval'])) is False - trades = Trade.query.all() - nb_trades = len(trades) - assert nb_trades == 1 - assert trades[0].is_open is True - - # Buy and Sell are triggering, so doing nothing ... - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, True)) - assert handle_trade(trades[0], int(default_conf['ticker_interval'])) is False - trades = Trade.query.all() - nb_trades = len(trades) - assert nb_trades == 1 - assert trades[0].is_open is True - - # Sell is triggering, guess what : we are Selling! - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True)) - trades = Trade.query.all() - assert handle_trade(trades[0], int(default_conf['ticker_interval'])) is True - - -def test_handle_trade_roi(default_conf, ticker, mocker, caplog): - caplog.set_level(logging.DEBUG) - default_conf.update({'experimental': {'use_sell_signal': True}}) - mocker.patch.dict('freqtrade.main._CONF', default_conf) - - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - buy=MagicMock(return_value='mocked_limit_buy')) - mocker.patch('freqtrade.main.min_roi_reached', return_value=True) - - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - trade.is_open = True - - # FIX: sniffing logs, suggest handle_trade should not execute_sell - # instead that responsibility should be moved out of handle_trade(), - # we might just want to check if we are in a sell condition without - # executing - # if ROI is reached we must sell - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True)) - assert handle_trade(trade, interval=int(default_conf['ticker_interval'])) - assert ('freqtrade', logging.DEBUG, 'Executing sell due to ROI ...') in caplog.record_tuples - # if ROI is reached we must sell even if sell-signal is not signalled - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True)) - assert handle_trade(trade, interval=int(default_conf['ticker_interval'])) - assert ('freqtrade', logging.DEBUG, 'Executing sell due to ROI ...') in caplog.record_tuples - - -def test_handle_trade_experimental(default_conf, ticker, mocker, caplog): - caplog.set_level(logging.DEBUG) - default_conf.update({'experimental': {'use_sell_signal': True}}) - mocker.patch.dict('freqtrade.main._CONF', default_conf) - - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - buy=MagicMock(return_value='mocked_limit_buy')) - mocker.patch('freqtrade.main.min_roi_reached', return_value=False) - - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - trade.is_open = True - - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, False)) - value_returned = handle_trade(trade, int(default_conf['ticker_interval'])) - assert value_returned is False - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True)) - assert handle_trade(trade, int(default_conf['ticker_interval'])) - s = 'Executing sell due to sell signal ...' - assert ('freqtrade', logging.DEBUG, s) in caplog.record_tuples - - -def test_close_trade(default_conf, ticker, limit_buy_order, limit_sell_order, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - buy=MagicMock(return_value='mocked_limit_buy')) - - # Create trade and sell it - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - assert trade - - trade.update(limit_buy_order) - trade.update(limit_sell_order) - assert trade.is_open is False - - with pytest.raises(ValueError, match=r'.*closed trade.*'): - handle_trade(trade, int(default_conf['ticker_interval'])) - - -def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - cancel_order_mock = MagicMock() - mocker.patch('freqtrade.rpc.init', MagicMock()) - rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - get_order=MagicMock(return_value=limit_buy_order_old), - cancel_order=cancel_order_mock) - init(default_conf, create_engine('sqlite://')) - - trade_buy = Trade( - pair='BTC_ETH', - open_rate=0.00001099, - exchange='BITTREX', - open_order_id='123456789', - amount=90.99181073, - fee=0.0, - stake_amount=1, - open_date=arrow.utcnow().shift(minutes=-601).datetime, - is_open=True +def test_set_loggers() -> None: + """ + Test set_loggers() update the logger level for third-party libraries + """ + previous_value1 = logging.getLogger('requests.packages.urllib3').level + previous_value2 = logging.getLogger('telegram').level + + set_loggers() + + value1 = logging.getLogger('requests.packages.urllib3').level + assert previous_value1 is not value1 + assert value1 is logging.INFO + + value2 = logging.getLogger('telegram').level + assert previous_value2 is not value2 + assert value2 is logging.INFO + + +def test_main(mocker, caplog) -> None: + """ + Test main() function. + In this test we are skipping the while True loop by throwing an exception. + """ + mocker.patch.multiple( + 'freqtrade.freqtradebot.FreqtradeBot', + _init_modules=MagicMock(), + worker=MagicMock( + side_effect=KeyboardInterrupt + ), + clean=MagicMock(), ) - Trade.session.add(trade_buy) + # Test Main + the KeyboardInterrupt exception + with pytest.raises(SystemExit) as pytest_wrapped_e: + main([]) + tt.log_has('Starting freqtrade', caplog.record_tuples) + tt.log_has('Got SIGINT, aborting ...', caplog.record_tuples) + assert pytest_wrapped_e.type == SystemExit + assert pytest_wrapped_e.value.code == 42 - # check it does cancel buy orders over the time limit - check_handle_timedout(600) - assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 - trades = Trade.query.filter(Trade.open_order_id.is_(trade_buy.open_order_id)).all() - nb_trades = len(trades) - assert nb_trades == 0 - - -def test_handle_timedout_limit_buy(mocker): - cancel_order = MagicMock() - mocker.patch('freqtrade.exchange.cancel_order', cancel_order) - Trade.session = MagicMock() - trade = MagicMock() - order = {'remaining': 1, - 'amount': 1} - assert main.handle_timedout_limit_buy(trade, order) - assert cancel_order.call_count == 1 - order['amount'] = 2 - assert not main.handle_timedout_limit_buy(trade, order) - assert cancel_order.call_count == 2 - - -def test_check_handle_timedout_sell(default_conf, ticker, limit_sell_order_old, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - cancel_order_mock = MagicMock() - mocker.patch('freqtrade.rpc.init', MagicMock()) - rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - get_order=MagicMock(return_value=limit_sell_order_old), - cancel_order=cancel_order_mock) - init(default_conf, create_engine('sqlite://')) - - trade_sell = Trade( - pair='BTC_ETH', - open_rate=0.00001099, - exchange='BITTREX', - open_order_id='123456789', - amount=90.99181073, - fee=0.0, - stake_amount=1, - open_date=arrow.utcnow().shift(hours=-5).datetime, - close_date=arrow.utcnow().shift(minutes=-601).datetime, - is_open=False + # Test the BaseException case + mocker.patch( + 'freqtrade.freqtradebot.FreqtradeBot.worker', + MagicMock(side_effect=BaseException) ) - - Trade.session.add(trade_sell) - - # check it does cancel sell orders over the time limit - check_handle_timedout(600) - assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 - assert trade_sell.is_open is True - - -def test_handle_timedout_limit_sell(mocker): - cancel_order = MagicMock() - mocker.patch('freqtrade.exchange.cancel_order', cancel_order) - trade = MagicMock() - order = {'remaining': 1, - 'amount': 1} - assert main.handle_timedout_limit_sell(trade, order) - assert cancel_order.call_count == 1 - order['amount'] = 2 - assert not main.handle_timedout_limit_sell(trade, order) - # Assert cancel_order was not called (callcount remains unchanged) - assert cancel_order.call_count == 1 - - -def test_check_handle_timedout_partial(default_conf, ticker, limit_buy_order_old_partial, - mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - cancel_order_mock = MagicMock() - mocker.patch('freqtrade.rpc.init', MagicMock()) - rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker, - get_order=MagicMock(return_value=limit_buy_order_old_partial), - cancel_order=cancel_order_mock) - init(default_conf, create_engine('sqlite://')) - - trade_buy = Trade( - pair='BTC_ETH', - open_rate=0.00001099, - exchange='BITTREX', - open_order_id='123456789', - amount=90.99181073, - fee=0.0, - stake_amount=1, - open_date=arrow.utcnow().shift(minutes=-601).datetime, - is_open=True - ) - - Trade.session.add(trade_buy) - - # check it does cancel buy orders over the time limit - # note this is for a partially-complete buy order - check_handle_timedout(600) - assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 - trades = Trade.query.filter(Trade.open_order_id.is_(trade_buy.open_order_id)).all() - assert len(trades) == 1 - assert trades[0].amount == 23.0 - assert trades[0].stake_amount == trade_buy.open_rate * trades[0].amount - - -def test_balance_fully_ask_side(mocker): - mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 0.0}}) - assert get_target_bid({'ask': 20, 'last': 10}) == 20 - - -def test_balance_fully_last_side(mocker): - mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}}) - assert get_target_bid({'ask': 20, 'last': 10}) == 10 - - -def test_balance_bigger_last_ask(mocker): - mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}}) - assert get_target_bid({'ask': 5, 'last': 10}) == 5 - - -def test_execute_sell_up(default_conf, ticker, ticker_sell_up, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch('freqtrade.rpc.init', MagicMock()) - rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker) - mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) - init(default_conf, create_engine('sqlite://')) - - # Create some test data - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - assert trade - - # Increase the price and sell it - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker_sell_up) - - execute_sell(trade=trade, limit=ticker_sell_up()['bid']) - - assert rpc_mock.call_count == 2 - assert 'Selling' in rpc_mock.call_args_list[-1][0][0] - assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0] - assert 'Amount' in rpc_mock.call_args_list[-1][0][0] - assert 'Profit' in rpc_mock.call_args_list[-1][0][0] - assert '0.00001172' in rpc_mock.call_args_list[-1][0][0] - assert 'profit: 6.11%, 0.00006126' in rpc_mock.call_args_list[-1][0][0] - assert '0.919 USD' in rpc_mock.call_args_list[-1][0][0] - - -def test_execute_sell_down(default_conf, ticker, ticker_sell_down, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch('freqtrade.rpc.init', MagicMock()) - rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) - mocker.patch.multiple('freqtrade.rpc.telegram', - _CONF=default_conf, - init=MagicMock(), - send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker) - mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) - init(default_conf, create_engine('sqlite://')) - - # Create some test data - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - assert trade - - # Decrease the price and sell it - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker_sell_down) - - execute_sell(trade=trade, limit=ticker_sell_down()['bid']) - - assert rpc_mock.call_count == 2 - assert 'Selling' in rpc_mock.call_args_list[-1][0][0] - assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0] - assert 'Amount' in rpc_mock.call_args_list[-1][0][0] - assert '0.00001044' in rpc_mock.call_args_list[-1][0][0] - assert 'loss: -5.48%, -0.00005492' in rpc_mock.call_args_list[-1][0][0] - assert '-0.824 USD' in rpc_mock.call_args_list[-1][0][0] - - -def test_execute_sell_without_conf_sell_down(default_conf, ticker, ticker_sell_down, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch('freqtrade.rpc.init', MagicMock()) - rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker) - init(default_conf, create_engine('sqlite://')) - - # Create some test data - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - assert trade - - # Decrease the price and sell it - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker_sell_down) - mocker.patch('freqtrade.main._CONF', {}) - - execute_sell(trade=trade, limit=ticker_sell_down()['bid']) - - assert rpc_mock.call_count == 2 - assert 'Selling' in rpc_mock.call_args_list[-1][0][0] - assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0] - assert '0.00001044' in rpc_mock.call_args_list[-1][0][0] - assert 'loss: -5.48%, -0.00005492' in rpc_mock.call_args_list[-1][0][0] - - -def test_execute_sell_without_conf_sell_up(default_conf, ticker, ticker_sell_up, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch('freqtrade.rpc.init', MagicMock()) - rpc_mock = mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker) - init(default_conf, create_engine('sqlite://')) - - # Create some test data - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - assert trade - - # Increase the price and sell it - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker_sell_up) - mocker.patch('freqtrade.main._CONF', {}) - - execute_sell(trade=trade, limit=ticker_sell_up()['bid']) - - assert rpc_mock.call_count == 2 - assert 'Selling' in rpc_mock.call_args_list[-1][0][0] - assert '[BTC_ETH]' in rpc_mock.call_args_list[-1][0][0] - assert 'Amount' in rpc_mock.call_args_list[-1][0][0] - assert '0.00001172' in rpc_mock.call_args_list[-1][0][0] - assert '(profit: 6.11%, 0.00006126)' in rpc_mock.call_args_list[-1][0][0] - assert 'USD' not in rpc_mock.call_args_list[-1][0][0] - - -def test_sell_profit_only_enable_profit(default_conf, limit_buy_order, mocker): - default_conf['experimental'] = { - 'use_sell_signal': True, - 'sell_profit_only': True, - } - - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.min_roi_reached', return_value=False) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=MagicMock(return_value={ - 'bid': 0.00002172, - 'ask': 0.00002173, - 'last': 0.00002172 - }), - buy=MagicMock(return_value='mocked_limit_buy')) - - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - trade.update(limit_buy_order) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True)) - assert handle_trade(trade, int(default_conf['ticker_interval'])) is True - - -def test_sell_profit_only_disable_profit(default_conf, limit_buy_order, mocker): - default_conf['experimental'] = { - 'use_sell_signal': True, - 'sell_profit_only': False, - } - - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.min_roi_reached', return_value=False) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=MagicMock(return_value={ - 'bid': 0.00002172, - 'ask': 0.00002173, - 'last': 0.00002172 - }), - buy=MagicMock(return_value='mocked_limit_buy')) - - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - trade.update(limit_buy_order) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True)) - assert handle_trade(trade, int(default_conf['ticker_interval'])) is True - - -def test_sell_profit_only_enable_loss(default_conf, limit_buy_order, mocker): - default_conf['experimental'] = { - 'use_sell_signal': True, - 'sell_profit_only': True, - } - - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.min_roi_reached', return_value=False) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=MagicMock(return_value={ - 'bid': 0.00000172, - 'ask': 0.00000173, - 'last': 0.00000172 - }), - buy=MagicMock(return_value='mocked_limit_buy')) - - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - trade.update(limit_buy_order) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True)) - assert handle_trade(trade, int(default_conf['ticker_interval'])) is False - - -def test_sell_profit_only_disable_loss(default_conf, limit_buy_order, mocker): - default_conf['experimental'] = { - 'use_sell_signal': True, - 'sell_profit_only': False, - } - - mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.min_roi_reached', return_value=False) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) - mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=MagicMock()) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=MagicMock(return_value={ - 'bid': 0.00000172, - 'ask': 0.00000173, - 'last': 0.00000172 - }), - buy=MagicMock(return_value='mocked_limit_buy')) - - init(default_conf, create_engine('sqlite://')) - create_trade(0.001, int(default_conf['ticker_interval'])) - - trade = Trade.query.first() - trade.update(limit_buy_order) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (False, True)) - assert handle_trade(trade, int(default_conf['ticker_interval'])) is True + with pytest.raises(SystemExit): + main([]) + tt.log_has('Got fatal exception!', caplog.record_tuples) diff --git a/freqtrade/tests/test_misc.py b/freqtrade/tests/test_misc.py index 95b9bf338..e51144496 100644 --- a/freqtrade/tests/test_misc.py +++ b/freqtrade/tests/test_misc.py @@ -39,7 +39,7 @@ def test_datesarray_to_datetimearray(ticker_history): assert date_len == 3 -def test_file_dump_json(mocker): +def test_file_dump_json(mocker) -> None: """ Test file_dump_json() :return: None