diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index f9448da42..6919128ba 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -16,7 +16,7 @@ from freqtrade.configuration import validate_config_consistency from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge -from freqtrade.enums import RPCMessageType, SellType, State +from freqtrade.enums import RPCMessageType, SellType, SignalDirection, State from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError, InvalidOrderException, PricingError) from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds @@ -272,21 +272,26 @@ class FreqtradeBot(LoggingMixin): trades: List[Trade] = Trade.get_closed_trades_without_assigned_fees() for trade in trades: - - if not trade.is_open and not trade.fee_updated('sell'): + if not trade.is_open and not trade.fee_updated(trade.exit_side): # Get sell fee - order = trade.select_order('sell', False) + order = trade.select_order(trade.exit_side, False) if order: - logger.info(f"Updating sell-fee on trade {trade} for order {order.order_id}.") + logger.info( + f"Updating {trade.exit_side}-fee on trade {trade}" + f"for order {order.order_id}." + ) self.update_trade_state(trade, order.order_id, stoploss_order=order.ft_order_side == 'stoploss') trades: List[Trade] = Trade.get_open_trades_without_assigned_fees() for trade in trades: - if trade.is_open and not trade.fee_updated('buy'): - order = trade.select_order('buy', False) + if trade.is_open and not trade.fee_updated(trade.enter_side): + order = trade.select_order(trade.enter_side, False) if order: - logger.info(f"Updating buy-fee on trade {trade} for order {order.order_id}.") + logger.info( + f"Updating {trade.enter_side}-fee on trade {trade}" + f"for order {order.order_id}." + ) self.update_trade_state(trade, order.order_id) def handle_insufficient_funds(self, trade: Trade): @@ -294,8 +299,8 @@ class FreqtradeBot(LoggingMixin): Determine if we ever opened a exiting order for this trade. If not, try update entering fees - otherwise "refind" the open order we obviously lost. """ - sell_order = trade.select_order('sell', None) - if sell_order: + exit_order = trade.select_order(trade.exit_side, None) + if exit_order: self.refind_lost_order(trade) else: self.reupdate_enter_order_fees(trade) @@ -305,10 +310,11 @@ class FreqtradeBot(LoggingMixin): Get buy order from database, and try to reupdate. Handles trades where the initial fee-update did not work. """ - logger.info(f"Trying to reupdate buy fees for {trade}") - order = trade.select_order('buy', False) + logger.info(f"Trying to reupdate {trade.enter_side} fees for {trade}") + order = trade.select_order(trade.enter_side, False) if order: - logger.info(f"Updating buy-fee on trade {trade} for order {order.order_id}.") + logger.info( + f"Updating {trade.enter_side}-fee on trade {trade} for order {order.order_id}.") self.update_trade_state(trade, order.order_id) def refind_lost_order(self, trade): @@ -324,7 +330,7 @@ class FreqtradeBot(LoggingMixin): if not order.ft_is_open: logger.debug(f"Order {order} is no longer open.") continue - if order.ft_order_side == 'buy': + if order.ft_order_side == trade.enter_side: # Skip buy side - this is handled by reupdate_enter_order_fees continue try: @@ -334,7 +340,7 @@ class FreqtradeBot(LoggingMixin): if fo and fo['status'] == 'open': # Assume this as the open stoploss order trade.stoploss_order_id = order.order_id - elif order.ft_order_side == 'sell': + elif order.ft_order_side == trade.exit_side: if fo and fo['status'] == 'open': # Assume this as the open order trade.open_order_id = order.order_id @@ -433,8 +439,11 @@ class FreqtradeBot(LoggingMixin): if ((bid_check_dom.get('enabled', False)) and (bid_check_dom.get('bids_to_ask_delta', 0) > 0)): # TODO-lev: Does the below need to be adjusted for shorts? - if self._check_depth_of_market_buy(pair, bid_check_dom): - # TODO-lev: pass in "enter" as side. + if self._check_depth_of_market( + pair, + bid_check_dom, + side=side + ): return self.execute_entry(pair, stake_amount, enter_tag=enter_tag) else: @@ -444,7 +453,12 @@ class FreqtradeBot(LoggingMixin): else: return False - def _check_depth_of_market_buy(self, pair: str, conf: Dict) -> bool: + def _check_depth_of_market( + self, + pair: str, + conf: Dict, + side: SignalDirection + ) -> bool: """ Checks depth of market before executing a buy """ @@ -454,9 +468,17 @@ class FreqtradeBot(LoggingMixin): order_book_data_frame = order_book_to_dataframe(order_book['bids'], order_book['asks']) order_book_bids = order_book_data_frame['b_size'].sum() order_book_asks = order_book_data_frame['a_size'].sum() - bids_ask_delta = order_book_bids / order_book_asks + + enter_side = order_book_bids if side == SignalDirection.LONG else order_book_asks + exit_side = order_book_asks if side == SignalDirection.LONG else order_book_bids + bids_ask_delta = enter_side / exit_side + + bids = f"Bids: {order_book_bids}" + asks = f"Asks: {order_book_asks}" + delta = f"Delta: {bids_ask_delta}" + logger.info( - f"Bids: {order_book_bids}, Asks: {order_book_asks}, Delta: {bids_ask_delta}, " + f"{bids}, {asks}, {delta}, Direction: {side.value}" f"Bid Price: {order_book['bids'][0][0]}, Ask Price: {order_book['asks'][0][0]}, " f"Immediate Bid Quantity: {order_book['bids'][0][1]}, " f"Immediate Ask Quantity: {order_book['asks'][0][1]}." @@ -468,21 +490,32 @@ class FreqtradeBot(LoggingMixin): logger.info(f"Bids to asks delta for {pair} does not satisfy condition.") return False - def execute_entry(self, pair: str, stake_amount: float, price: Optional[float] = None, - forcebuy: bool = False, enter_tag: Optional[str] = None) -> bool: + def execute_entry( + self, + pair: str, + stake_amount: float, + price: Optional[float] = None, + forcebuy: bool = False, + leverage: float = 1.0, + is_short: bool = False, + enter_tag: Optional[str] = None + ) -> bool: """ Executes a limit buy for the given pair :param pair: pair for which we want to create a LIMIT_BUY :param stake_amount: amount of stake-currency for the pair + :param leverage: amount of leverage applied to this trade :return: True if a buy order is created, false if it fails. """ time_in_force = self.strategy.order_time_in_force['buy'] + [side, name] = ['sell', 'Short'] if is_short else ['buy', 'Long'] + if price: enter_limit_requested = price else: # Calculate price - proposed_enter_rate = self.exchange.get_rate(pair, refresh=True, side="buy") + proposed_enter_rate = self.exchange.get_rate(pair, refresh=True, side=side) custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price, default_retval=proposed_enter_rate)( pair=pair, current_time=datetime.now(timezone.utc), @@ -491,10 +524,14 @@ class FreqtradeBot(LoggingMixin): enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate) if not enter_limit_requested: - raise PricingError('Could not determine buy price.') + raise PricingError(f'Could not determine {side} price.') - min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, enter_limit_requested, - self.strategy.stoploss) + min_stake_amount = self.exchange.get_min_pair_stake_amount( + pair, + enter_limit_requested, + self.strategy.stoploss, + leverage=leverage + ) if not self.edge: max_stake_amount = self.wallets.get_available_stake_amount() @@ -508,10 +545,11 @@ class FreqtradeBot(LoggingMixin): if not stake_amount: return False - logger.info(f"Buy signal found: about create a new trade for {pair} with stake_amount: " + log_type = f"{name} signal found" + logger.info(f"{log_type}: about create a new trade for {pair} with stake_amount: " f"{stake_amount} ...") - amount = stake_amount / enter_limit_requested + amount = (stake_amount / enter_limit_requested) * leverage order_type = self.strategy.order_types['buy'] if forcebuy: # Forcebuy can define a different ordertype @@ -522,13 +560,13 @@ class FreqtradeBot(LoggingMixin): if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, time_in_force=time_in_force, current_time=datetime.now(timezone.utc)): - logger.info(f"User requested abortion of buying {pair}") + logger.info(f"User requested abortion of {name.lower()}ing {pair}") return False amount = self.exchange.amount_to_precision(pair, amount) - order = self.exchange.create_order(pair=pair, ordertype=order_type, side="buy", + order = self.exchange.create_order(pair=pair, ordertype=order_type, side=side, amount=amount, rate=enter_limit_requested, time_in_force=time_in_force) - order_obj = Order.parse_from_ccxt_object(order, pair, 'buy') + order_obj = Order.parse_from_ccxt_object(order, pair, side) order_id = order['id'] order_status = order.get('status', None) @@ -541,17 +579,17 @@ class FreqtradeBot(LoggingMixin): # return false if the order is not filled if float(order['filled']) == 0: - logger.warning('Buy %s order with time in force %s for %s is %s by %s.' + logger.warning('%s %s order with time in force %s for %s is %s by %s.' ' zero amount is fulfilled.', - order_tif, order_type, pair, order_status, self.exchange.name) + name, order_tif, order_type, pair, order_status, self.exchange.name) return False else: # the order is partially fulfilled # in case of IOC orders we can check immediately # if the order is fulfilled fully or partially - logger.warning('Buy %s order with time in force %s for %s is %s by %s.' + logger.warning('%s %s order with time in force %s for %s is %s by %s.' ' %s amount fulfilled out of %s (%s remaining which is canceled).', - order_tif, order_type, pair, order_status, self.exchange.name, + name, order_tif, order_type, pair, order_status, self.exchange.name, order['filled'], order['amount'], order['remaining'] ) stake_amount = order['cost'] @@ -582,7 +620,9 @@ class FreqtradeBot(LoggingMixin): strategy=self.strategy.get_strategy_name(), # TODO-lev: compatibility layer for buy_tag (!) buy_tag=enter_tag, - timeframe=timeframe_to_minutes(self.config['timeframe']) + timeframe=timeframe_to_minutes(self.config['timeframe']), + leverage=leverage, + is_short=is_short, ) trade.orders.append(order_obj) @@ -606,7 +646,7 @@ class FreqtradeBot(LoggingMixin): """ msg = { 'trade_id': trade.id, - 'type': RPCMessageType.BUY, + 'type': RPCMessageType.SHORT if trade.is_short else RPCMessageType.BUY, 'buy_tag': trade.buy_tag, 'exchange': self.exchange.name.capitalize(), 'pair': trade.pair, @@ -627,11 +667,11 @@ class FreqtradeBot(LoggingMixin): """ Sends rpc notification when a buy/short cancel occurred. """ - current_rate = self.exchange.get_rate(trade.pair, refresh=False, side="buy") - + current_rate = self.exchange.get_rate(trade.pair, refresh=False, side=trade.enter_side) + msg_type = RPCMessageType.SHORT_CANCEL if trade.is_short else RPCMessageType.BUY_CANCEL msg = { 'trade_id': trade.id, - 'type': RPCMessageType.BUY_CANCEL, + 'type': msg_type, 'buy_tag': trade.buy_tag, 'exchange': self.exchange.name.capitalize(), 'pair': trade.pair, @@ -650,9 +690,10 @@ class FreqtradeBot(LoggingMixin): self.rpc.send_msg(msg) def _notify_enter_fill(self, trade: Trade) -> None: + msg_type = RPCMessageType.SHORT_FILL if trade.is_short else RPCMessageType.BUY_FILL msg = { 'trade_id': trade.id, - 'type': RPCMessageType.BUY_FILL, + 'type': msg_type, 'buy_tag': trade.buy_tag, 'exchange': self.exchange.name.capitalize(), 'pair': trade.pair, @@ -706,6 +747,8 @@ class FreqtradeBot(LoggingMixin): logger.debug('Handling %s ...', trade) (enter, exit_) = (False, False) + exit_signal_type = "exit_short" if trade.is_short else "exit_long" + # TODO-lev: change to use_exit_signal, ignore_roi_if_enter_signal if (self.config.get('use_sell_signal', True) or self.config.get('ignore_roi_if_buy_signal', False)): @@ -715,15 +758,16 @@ class FreqtradeBot(LoggingMixin): (enter, exit_) = self.strategy.get_exit_signal( trade.pair, self.strategy.timeframe, - analyzed_df, is_short=trade.is_short + analyzed_df, + is_short=trade.is_short ) - logger.debug('checking sell') - exit_rate = self.exchange.get_rate(trade.pair, refresh=True, side="sell") + logger.debug('checking exit') + exit_rate = self.exchange.get_rate(trade.pair, refresh=True, side=trade.exit_side) if self._check_and_execute_exit(trade, exit_rate, enter, exit_): return True - logger.debug('Found no sell signal for %s.', trade) + logger.debug(f'Found no {exit_signal_type} signal for %s.', trade) return False def create_stoploss_order(self, trade: Trade, stop_price: float) -> bool: @@ -807,7 +851,10 @@ class FreqtradeBot(LoggingMixin): # If enter order is fulfilled but there is no stoploss, we add a stoploss on exchange if not stoploss_order: stoploss = self.edge.stoploss(pair=trade.pair) if self.edge else self.strategy.stoploss - stop_price = trade.open_rate * (1 + stoploss) + if trade.is_short: + stop_price = trade.open_rate * (1 - stoploss) + else: + stop_price = trade.open_rate * (1 + stoploss) if self.create_stoploss_order(trade=trade, stop_price=stop_price): trade.stoploss_last_update = datetime.utcnow() @@ -844,7 +891,7 @@ class FreqtradeBot(LoggingMixin): :param order: Current on exchange stoploss order :return: None """ - if self.exchange.stoploss_adjust(trade.stop_loss, order, side): + if self.exchange.stoploss_adjust(trade.stop_loss, order, side=trade.exit_side): # we check if the update is necessary update_beat = self.strategy.order_types.get('stoploss_on_exchange_interval', 60) if (datetime.utcnow() - trade.stoploss_last_update).total_seconds() >= update_beat: @@ -912,22 +959,38 @@ class FreqtradeBot(LoggingMixin): fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order) - if (order['side'] == 'buy' and (order['status'] == 'open' or fully_cancelled) and ( - fully_cancelled - or self._check_timed_out('buy', order) - or strategy_safe_wrapper(self.strategy.check_buy_timeout, - default_retval=False)(pair=trade.pair, - trade=trade, - order=order))): + if ( + order['side'] == trade.enter_side and + (order['status'] == 'open' or fully_cancelled) and + (fully_cancelled or + self._check_timed_out(trade.enter_side, order) or + strategy_safe_wrapper( + self.strategy.check_buy_timeout, + default_retval=False + )( + pair=trade.pair, + trade=trade, + order=order + ) + ) + ): self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT']) - elif (order['side'] == 'sell' and (order['status'] == 'open' or fully_cancelled) and ( - fully_cancelled - or self._check_timed_out('sell', order) - or strategy_safe_wrapper(self.strategy.check_sell_timeout, - default_retval=False)(pair=trade.pair, - trade=trade, - order=order))): + elif ( + order['side'] == trade.exit_side and + (order['status'] == 'open' or fully_cancelled) and + (fully_cancelled or + self._check_timed_out(trade.exit_side, order) or + strategy_safe_wrapper( + self.strategy.check_sell_timeout, + default_retval=False + )( + pair=trade.pair, + trade=trade, + order=order + ) + ) + ): self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT']) def cancel_all_open_orders(self) -> None: @@ -943,10 +1006,10 @@ class FreqtradeBot(LoggingMixin): logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc()) continue - if order['side'] == 'buy': + if order['side'] == trade.enter_side: self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['ALL_CANCELLED']) - elif order['side'] == 'sell': + elif order['side'] == trade.exit_side: self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['ALL_CANCELLED']) Trade.commit() @@ -968,7 +1031,7 @@ class FreqtradeBot(LoggingMixin): if filled_val > 0 and filled_stake < minstake: logger.warning( f"Order {trade.open_order_id} for {trade.pair} not cancelled, " - f"as the filled amount of {filled_val} would result in an unsellable trade.") + f"as the filled amount of {filled_val} would result in an unexitable trade.") return False corder = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair, trade.amount) @@ -983,12 +1046,16 @@ class FreqtradeBot(LoggingMixin): corder = order reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] - logger.info('Buy order %s for %s.', reason, trade) + side = trade.enter_side.capitalize() + logger.info('%s order %s for %s.', side, reason, trade) # Using filled to determine the filled amount filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled') if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC): - logger.info('Buy order fully cancelled. Removing %s from database.', trade) + logger.info( + '%s order fully cancelled. Removing %s from database.', + side, trade + ) # if trade is not partially completed, just delete the trade trade.delete() was_trade_fully_canceled = True @@ -1006,11 +1073,11 @@ class FreqtradeBot(LoggingMixin): self.update_trade_state(trade, trade.open_order_id, corder) trade.open_order_id = None - logger.info('Partial buy order timeout for %s.', trade) + logger.info('Partial %s order timeout for %s.', trade.enter_side, trade) reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}" self.wallets.update() - self._notify_enter_cancel(trade, order_type=self.strategy.order_types['buy'], + self._notify_enter_cancel(trade, order_type=self.strategy.order_types[trade.enter_side], reason=reason) return was_trade_fully_canceled @@ -1028,12 +1095,13 @@ class FreqtradeBot(LoggingMixin): trade.amount) trade.update_order(co) except InvalidOrderException: - logger.exception(f"Could not cancel sell order {trade.open_order_id}") + logger.exception( + f"Could not cancel {trade.exit_side} order {trade.open_order_id}") return 'error cancelling order' - logger.info('Sell order %s for %s.', reason, trade) + logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade) else: reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] - logger.info('Sell order %s for %s.', reason, trade) + logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade) trade.update_order(order) trade.close_rate = None @@ -1050,7 +1118,7 @@ class FreqtradeBot(LoggingMixin): self.wallets.update() self._notify_exit_cancel( trade, - order_type=self.strategy.order_types['sell'], + order_type=self.strategy.order_types[trade.exit_side], reason=reason ) return reason @@ -1189,7 +1257,7 @@ class FreqtradeBot(LoggingMixin): profit_trade = trade.calc_profit(rate=profit_rate) # Use cached rates here - it was updated seconds ago. current_rate = self.exchange.get_rate( - trade.pair, refresh=False, side="sell") if not fill else None + trade.pair, refresh=False, side=trade.exit_side) if not fill else None profit_ratio = trade.calc_profit_ratio(profit_rate) gain = "profit" if profit_ratio > 0 else "loss" @@ -1234,7 +1302,7 @@ class FreqtradeBot(LoggingMixin): profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested profit_trade = trade.calc_profit(rate=profit_rate) - current_rate = self.exchange.get_rate(trade.pair, refresh=False, side="sell") + current_rate = self.exchange.get_rate(trade.pair, refresh=False, side=trade.exit_side) profit_ratio = trade.calc_profit_ratio(profit_rate) gain = "profit" if profit_ratio > 0 else "loss" diff --git a/tests/freqtradebot.py b/tests/freqtradebot.py new file mode 100644 index 000000000..8aa60f887 --- /dev/null +++ b/tests/freqtradebot.py @@ -0,0 +1,1516 @@ +""" +Freqtrade is the main module of this bot. It contains the class Freqtrade() +""" +import copy +import logging +import traceback +from datetime import datetime, timezone +from math import isclose +from threading import Lock +from typing import Any, Dict, List, Optional + +import arrow + +from freqtrade import __version__, constants +from freqtrade.configuration import validate_config_consistency +from freqtrade.data.converter import order_book_to_dataframe +from freqtrade.data.dataprovider import DataProvider +from freqtrade.edge import Edge +from freqtrade.enums import RPCMessageType, SellType, SignalDirection, State +from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError, + InvalidOrderException, PricingError) +from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds +from freqtrade.misc import safe_value_fallback, safe_value_fallback2 +from freqtrade.mixins import LoggingMixin +from freqtrade.persistence import Order, PairLocks, Trade, cleanup_db, init_db +from freqtrade.plugins.pairlistmanager import PairListManager +from freqtrade.plugins.protectionmanager import ProtectionManager +from freqtrade.resolvers import ExchangeResolver, StrategyResolver +from freqtrade.rpc import RPCManager +from freqtrade.strategy.interface import IStrategy, SellCheckTuple +from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper +from freqtrade.wallets import Wallets + + +logger = logging.getLogger(__name__) + + +class FreqtradeBot(LoggingMixin): + """ + Freqtrade is the main class of the bot. + This is from here the bot start its logic. + """ + + def __init__(self, config: Dict[str, Any]) -> None: + """ + Init all variables and objects the bot needs to work + :param config: configuration dict, you can use Configuration.get_config() + to get the config dict. + """ + self.active_pair_whitelist: List[str] = [] + + logger.info('Starting freqtrade %s', __version__) + + # Init bot state + self.state = State.STOPPED + + # Init objects + self.config = config + + self.strategy: IStrategy = StrategyResolver.load_strategy(self.config) + + # Check config consistency here since strategies can set certain options + validate_config_consistency(config) + + self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) + + init_db(self.config.get('db_url', None), clean_open_orders=self.config['dry_run']) + + # TODO-lev: Do anything with this? + self.wallets = Wallets(self.config, self.exchange) + + PairLocks.timeframe = self.config['timeframe'] + + self.protections = ProtectionManager(self.config, self.strategy.protections) + + # RPC runs in separate threads, can start handling external commands just after + # initialization, even before Freqtradebot has a chance to start its throttling, + # so anything in the Freqtradebot instance should be ready (initialized), including + # the initial state of the bot. + # Keep this at the end of this initialization method. + # TODO-lev: Do I need to consider the rpc, pairlists or dataprovider? + self.rpc: RPCManager = RPCManager(self) + + self.pairlists = PairListManager(self.exchange, self.config) + + self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists) + + # Attach Dataprovider to Strategy baseclass + IStrategy.dp = self.dataprovider + # Attach Wallets to Strategy baseclass + IStrategy.wallets = self.wallets + + # Initializing Edge only if enabled + self.edge = Edge(self.config, self.exchange, self.strategy) if \ + self.config.get('edge', {}).get('enabled', False) else None + + self.active_pair_whitelist = self._refresh_active_whitelist() + + # Set initial bot state from config + initial_state = self.config.get('initial_state') + self.state = State[initial_state.upper()] if initial_state else State.STOPPED + + # Protect exit-logic from forcesell and vice versa + self._exit_lock = Lock() + LoggingMixin.__init__(self, logger, timeframe_to_seconds(self.strategy.timeframe)) + + def notify_status(self, msg: str) -> None: + """ + Public method for users of this class (worker, etc.) to send notifications + via RPC about changes in the bot status. + """ + self.rpc.send_msg({ + 'type': RPCMessageType.STATUS, + 'status': msg + }) + + def cleanup(self) -> None: + """ + Cleanup pending resources on an already stopped bot + :return: None + """ + logger.info('Cleaning up modules ...') + + if self.config['cancel_open_orders_on_exit']: + self.cancel_all_open_orders() + + self.check_for_open_trades() + + self.rpc.cleanup() + cleanup_db() + + def startup(self) -> None: + """ + Called on startup and after reloading the bot - triggers notifications and + performs startup tasks + """ + self.rpc.startup_messages(self.config, self.pairlists, self.protections) + if not self.edge: + # Adjust stoploss if it was changed + Trade.stoploss_reinitialization(self.strategy.stoploss) + + # Only update open orders on startup + # This will update the database after the initial migration + self.update_open_orders() + + def process(self) -> None: + """ + Queries the persistence layer for open trades and handles them, + otherwise a new trade is created. + :return: True if one or more trades has been created or closed, False otherwise + """ + + # Check whether markets have to be reloaded and reload them when it's needed + self.exchange.reload_markets() + + self.update_closed_trades_without_assigned_fees() + + # Query trades from persistence layer + trades = Trade.get_open_trades() + + self.active_pair_whitelist = self._refresh_active_whitelist(trades) + + # Refreshing candles + self.dataprovider.refresh(self.pairlists.create_pair_list(self.active_pair_whitelist), + self.strategy.informative_pairs()) + + strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() + + self.strategy.analyze(self.active_pair_whitelist) + + with self._exit_lock: + # Check and handle any timed out open orders + self.check_handle_timedout() + + # Protect from collisions with forceexit. + # Without this, freqtrade my try to recreate stoploss_on_exchange orders + # while exiting is in process, since telegram messages arrive in an different thread. + with self._exit_lock: + trades = Trade.get_open_trades() + # First process current opened trades (positions) + self.exit_positions(trades) + + # Then looking for buy opportunities + if self.get_free_open_trades(): + self.enter_positions() + + Trade.commit() + + def process_stopped(self) -> None: + """ + Close all orders that were left open + """ + if self.config['cancel_open_orders_on_exit']: + self.cancel_all_open_orders() + + def check_for_open_trades(self): + """ + Notify the user when the bot is stopped + and there are still open trades active. + """ + open_trades = Trade.get_trades([Trade.is_open.is_(True)]).all() + + if len(open_trades) != 0: + msg = { + 'type': RPCMessageType.WARNING, + 'status': f"{len(open_trades)} open trades active.\n\n" + f"Handle these trades manually on {self.exchange.name}, " + f"or '/start' the bot again and use '/stopbuy' " + f"to handle open trades gracefully. \n" + f"{'Trades are simulated.' if self.config['dry_run'] else ''}", + } + self.rpc.send_msg(msg) + + def _refresh_active_whitelist(self, trades: List[Trade] = []) -> List[str]: + """ + Refresh active whitelist from pairlist or edge and extend it with + pairs that have open trades. + """ + # Refresh whitelist + self.pairlists.refresh_pairlist() + _whitelist = self.pairlists.whitelist + + # Calculating Edge positioning + if self.edge: + self.edge.calculate(_whitelist) + _whitelist = self.edge.adjust(_whitelist) + + if trades: + # Extend active-pair whitelist with pairs of open trades + # It ensures that candle (OHLCV) data are downloaded for open trades as well + _whitelist.extend([trade.pair for trade in trades if trade.pair not in _whitelist]) + return _whitelist + + def get_free_open_trades(self) -> int: + """ + Return the number of free open trades slots or 0 if + max number of open trades reached + """ + open_trades = len(Trade.get_open_trades()) + return max(0, self.config['max_open_trades'] - open_trades) + + def update_open_orders(self): + """ + Updates open orders based on order list kept in the database. + Mainly updates the state of orders - but may also close trades + """ + if self.config['dry_run'] or self.config['exchange'].get('skip_open_order_update', False): + # Updating open orders in dry-run does not make sense and will fail. + return + + orders = Order.get_open_orders() + logger.info(f"Updating {len(orders)} open orders.") + for order in orders: + try: + fo = self.exchange.fetch_order_or_stoploss_order(order.order_id, order.ft_pair, + order.ft_order_side == 'stoploss') + + self.update_trade_state(order.trade, order.order_id, fo) + + except ExchangeError as e: + + logger.warning(f"Error updating Order {order.order_id} due to {e}") + + def update_closed_trades_without_assigned_fees(self): + """ + Update closed trades without close fees assigned. + Only acts when Orders are in the database, otherwise the last order-id is unknown. + """ + if self.config['dry_run']: + # Updating open orders in dry-run does not make sense and will fail. + return + + trades: List[Trade] = Trade.get_closed_trades_without_assigned_fees() + for trade in trades: + if not trade.is_open and not trade.fee_updated(trade.exit_side): + # Get sell fee + order = trade.select_order(trade.exit_side, False) + if order: + logger.info( + f"Updating {trade.exit_side}-fee on trade {trade}" + f"for order {order.order_id}." + ) + self.update_trade_state(trade, order.order_id, + stoploss_order=order.ft_order_side == 'stoploss') + + trades: List[Trade] = Trade.get_open_trades_without_assigned_fees() + for trade in trades: + if trade.is_open and not trade.fee_updated(trade.enter_side): + order = trade.select_order(trade.enter_side, False) + if order: + logger.info( + f"Updating {trade.enter_side}-fee on trade {trade}" + f"for order {order.order_id}." + ) + self.update_trade_state(trade, order.order_id) + + def handle_insufficient_funds(self, trade: Trade): + """ + Determine if we ever opened a exiting order for this trade. + If not, try update entering fees - otherwise "refind" the open order we obviously lost. + """ + exit_order = trade.select_order(trade.exit_side, None) + if exit_order: + self.refind_lost_order(trade) + else: + self.reupdate_enter_order_fees(trade) + + def reupdate_enter_order_fees(self, trade: Trade): + """ + Get buy order from database, and try to reupdate. + Handles trades where the initial fee-update did not work. + """ + logger.info(f"Trying to reupdate {trade.enter_side} fees for {trade}") + order = trade.select_order(trade.enter_side, False) + if order: + logger.info( + f"Updating {trade.enter_side}-fee on trade {trade} for order {order.order_id}.") + self.update_trade_state(trade, order.order_id) + + def refind_lost_order(self, trade): + """ + Try refinding a lost trade. + Only used when InsufficientFunds appears on exit orders (stoploss or long sell/short buy). + Tries to walk the stored orders and sell them off eventually. + """ + logger.info(f"Trying to refind lost order for {trade}") + for order in trade.orders: + logger.info(f"Trying to refind {order}") + fo = None + if not order.ft_is_open: + logger.debug(f"Order {order} is no longer open.") + continue + if order.ft_order_side == trade.enter_side: + # Skip buy side - this is handled by reupdate_enter_order_fees + continue + try: + fo = self.exchange.fetch_order_or_stoploss_order(order.order_id, order.ft_pair, + order.ft_order_side == 'stoploss') + if order.ft_order_side == 'stoploss': + if fo and fo['status'] == 'open': + # Assume this as the open stoploss order + trade.stoploss_order_id = order.order_id + elif order.ft_order_side == trade.exit_side: + if fo and fo['status'] == 'open': + # Assume this as the open order + trade.open_order_id = order.order_id + if fo: + logger.info(f"Found {order} for trade {trade}.") + self.update_trade_state(trade, order.order_id, fo, + stoploss_order=order.ft_order_side == 'stoploss') + + except ExchangeError: + logger.warning(f"Error updating {order.order_id}.") + +# +# BUY / enter positions / open trades logic and methods +# + + def enter_positions(self) -> int: + """ + Tries to execute long buy/short sell orders for new trades (positions) + """ + trades_created = 0 + + whitelist = copy.deepcopy(self.active_pair_whitelist) + if not whitelist: + logger.info("Active pair whitelist is empty.") + return trades_created + # Remove pairs for currently opened trades from the whitelist + for trade in Trade.get_open_trades(): + if trade.pair in whitelist: + whitelist.remove(trade.pair) + logger.debug('Ignoring %s in pair whitelist', trade.pair) + + if not whitelist: + logger.info("No currency pair in active pair whitelist, " + "but checking to exit open trades.") + return trades_created + if PairLocks.is_global_lock(): + lock = PairLocks.get_pair_longest_lock('*') + if lock: + self.log_once(f"Global pairlock active until " + f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)}. " + f"Not creating new trades, reason: {lock.reason}.", logger.info) + else: + self.log_once("Global pairlock active. Not creating new trades.", logger.info) + return trades_created + # Create entity and execute trade for each pair from whitelist + for pair in whitelist: + try: + trades_created += self.create_trade(pair) + except DependencyException as exception: + logger.warning('Unable to create trade for %s: %s', pair, exception) + + if not trades_created: + logger.debug("Found no enter signals for whitelisted currencies. Trying again...") + + return trades_created + + def create_trade(self, pair: str) -> bool: + """ + Check the implemented trading strategy for buy signals. + + If the pair triggers the buy signal a new trade record gets created + and the buy-order opening the trade gets issued towards the exchange. + + :return: True if a trade has been created. + """ + logger.debug(f"create_trade for pair {pair}") + + analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe) + nowtime = analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None + if self.strategy.is_pair_locked(pair, nowtime): + lock = PairLocks.get_pair_longest_lock(pair, nowtime) + if lock: + self.log_once(f"Pair {pair} is still locked until " + f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)} " + f"due to {lock.reason}.", + logger.info) + else: + self.log_once(f"Pair {pair} is still locked.", logger.info) + return False + + # get_free_open_trades is checked before create_trade is called + # but it is still used here to prevent opening too many trades within one iteration + if not self.get_free_open_trades(): + logger.debug(f"Can't open a new trade for {pair}: max number of trades is reached.") + return False + + # running get_signal on historical data fetched + (side, enter_tag) = self.strategy.get_entry_signal( + pair, self.strategy.timeframe, analyzed_df + ) + + if side: + stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge) + + bid_check_dom = self.config.get('bid_strategy', {}).get('check_depth_of_market', {}) + if ((bid_check_dom.get('enabled', False)) and + (bid_check_dom.get('bids_to_ask_delta', 0) > 0)): + # TODO-lev: Does the below need to be adjusted for shorts? + if self._check_depth_of_market( + pair, + bid_check_dom, + side=side + ): + + return self.execute_entry(pair, stake_amount, enter_tag=enter_tag) + else: + return False + + return self.execute_entry(pair, stake_amount, enter_tag=enter_tag) + else: + return False + + def _check_depth_of_market( + self, + pair: str, + conf: Dict, + side: SignalDirection + ) -> bool: + """ + Checks depth of market before executing a buy + """ + conf_bids_to_ask_delta = conf.get('bids_to_ask_delta', 0) + logger.info(f"Checking depth of market for {pair} ...") + order_book = self.exchange.fetch_l2_order_book(pair, 1000) + order_book_data_frame = order_book_to_dataframe(order_book['bids'], order_book['asks']) + order_book_bids = order_book_data_frame['b_size'].sum() + order_book_asks = order_book_data_frame['a_size'].sum() + + enter_side = order_book_bids if side == SignalDirection.LONG else order_book_asks + exit_side = order_book_asks if side == SignalDirection.LONG else order_book_bids + bids_ask_delta = enter_side / exit_side + + bids = f"Bids: {order_book_bids}" + asks = f"Asks: {order_book_asks}" + delta = f"Delta: {bids_ask_delta}" + + logger.info( + f"{bids}, {asks}, {delta}, Direction: {side}" + f"Bid Price: {order_book['bids'][0][0]}, Ask Price: {order_book['asks'][0][0]}, " + f"Immediate Bid Quantity: {order_book['bids'][0][1]}, " + f"Immediate Ask Quantity: {order_book['asks'][0][1]}." + ) + if bids_ask_delta >= conf_bids_to_ask_delta: + logger.info(f"Bids to asks delta for {pair} DOES satisfy condition.") + return True + else: + logger.info(f"Bids to asks delta for {pair} does not satisfy condition.") + return False + + def execute_entry( + self, + pair: str, + stake_amount: float, + price: Optional[float] = None, + forcebuy: bool = False, + leverage: float = 1.0, + is_short: bool = False, + enter_tag: Optional[str] = None + ) -> bool: + """ + Executes a limit buy for the given pair + :param pair: pair for which we want to create a LIMIT_BUY + :param stake_amount: amount of stake-currency for the pair + :param leverage: amount of leverage applied to this trade + :return: True if a buy order is created, false if it fails. + """ + time_in_force = self.strategy.order_time_in_force['buy'] + + [side, name] = ['sell', 'Short'] if is_short else ['buy', 'Long'] + + if price: + enter_limit_requested = price + else: + # Calculate price + proposed_enter_rate = self.exchange.get_rate(pair, refresh=True, side=side) + custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price, + default_retval=proposed_enter_rate)( + pair=pair, current_time=datetime.now(timezone.utc), + proposed_rate=proposed_enter_rate) + + enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate) + + if not enter_limit_requested: + raise PricingError(f'Could not determine {side} price.') + + min_stake_amount = self.exchange.get_min_pair_stake_amount( + pair, + enter_limit_requested, + self.strategy.stoploss, + leverage=leverage + ) + + if not self.edge: + max_stake_amount = self.wallets.get_available_stake_amount() + stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, + default_retval=stake_amount)( + pair=pair, current_time=datetime.now(timezone.utc), + current_rate=enter_limit_requested, proposed_stake=stake_amount, + min_stake=min_stake_amount, max_stake=max_stake_amount) + stake_amount = self.wallets._validate_stake_amount(pair, stake_amount, min_stake_amount) + + if not stake_amount: + return False + + log_type = f"{name} signal found" + logger.info(f"{log_type}: about create a new trade for {pair} with stake_amount: " + f"{stake_amount} ...") + + amount = (stake_amount / enter_limit_requested) * leverage + order_type = self.strategy.order_types['buy'] + if forcebuy: + # Forcebuy can define a different ordertype + # TODO-lev: get a forceshort? What is this + order_type = self.strategy.order_types.get('forcebuy', order_type) + # TODO-lev: Will this work for shorting? + + if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( + pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, + time_in_force=time_in_force, current_time=datetime.now(timezone.utc)): + logger.info(f"User requested abortion of {name.lower()}ing {pair}") + return False + amount = self.exchange.amount_to_precision(pair, amount) + order = self.exchange.create_order(pair=pair, ordertype=order_type, side=side, + amount=amount, rate=enter_limit_requested, + time_in_force=time_in_force) + order_obj = Order.parse_from_ccxt_object(order, pair, side) + order_id = order['id'] + order_status = order.get('status', None) + + # we assume the order is executed at the price requested + enter_limit_filled_price = enter_limit_requested + amount_requested = amount + + if order_status == 'expired' or order_status == 'rejected': + order_tif = self.strategy.order_time_in_force['buy'] + + # return false if the order is not filled + if float(order['filled']) == 0: + logger.warning('%s %s order with time in force %s for %s is %s by %s.' + ' zero amount is fulfilled.', + name, order_tif, order_type, pair, order_status, self.exchange.name) + return False + else: + # the order is partially fulfilled + # in case of IOC orders we can check immediately + # if the order is fulfilled fully or partially + logger.warning('%s %s order with time in force %s for %s is %s by %s.' + ' %s amount fulfilled out of %s (%s remaining which is canceled).', + name, order_tif, order_type, pair, order_status, self.exchange.name, + order['filled'], order['amount'], order['remaining'] + ) + stake_amount = order['cost'] + amount = safe_value_fallback(order, 'filled', 'amount') + enter_limit_filled_price = safe_value_fallback(order, 'average', 'price') + + # in case of FOK the order may be filled immediately and fully + elif order_status == 'closed': + stake_amount = order['cost'] + amount = safe_value_fallback(order, 'filled', 'amount') + enter_limit_filled_price = safe_value_fallback(order, 'average', 'price') + + # Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL + fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker') + trade = Trade( + pair=pair, + stake_amount=stake_amount, + amount=amount, + is_open=True, + amount_requested=amount_requested, + fee_open=fee, + fee_close=fee, + open_rate=enter_limit_filled_price, + open_rate_requested=enter_limit_requested, + open_date=datetime.utcnow(), + exchange=self.exchange.id, + open_order_id=order_id, + strategy=self.strategy.get_strategy_name(), + # TODO-lev: compatibility layer for buy_tag (!) + buy_tag=enter_tag, + timeframe=timeframe_to_minutes(self.config['timeframe']), + leverage=leverage, + is_short=is_short, + ) + trade.orders.append(order_obj) + + # Update fees if order is closed + if order_status == 'closed': + self.update_trade_state(trade, order_id, order) + + Trade.query.session.add(trade) + Trade.commit() + + # Updating wallets + self.wallets.update() + + self._notify_enter(trade, order_type) + + return True + + def _notify_enter(self, trade: Trade, order_type: str) -> None: + """ + Sends rpc notification when a buy/short occurred. + """ + msg = { + 'trade_id': trade.id, + 'type': RPCMessageType.SHORT if trade.is_short else RPCMessageType.BUY, + 'buy_tag': trade.buy_tag, + 'exchange': self.exchange.name.capitalize(), + 'pair': trade.pair, + 'limit': trade.open_rate, + 'order_type': order_type, + 'stake_amount': trade.stake_amount, + 'stake_currency': self.config['stake_currency'], + 'fiat_currency': self.config.get('fiat_display_currency', None), + 'amount': trade.amount, + 'open_date': trade.open_date or datetime.utcnow(), + 'current_rate': trade.open_rate_requested, + } + + # Send the message + self.rpc.send_msg(msg) + + def _notify_enter_cancel(self, trade: Trade, order_type: str, reason: str) -> None: + """ + Sends rpc notification when a buy/short cancel occurred. + """ + current_rate = self.exchange.get_rate(trade.pair, refresh=False, side=trade.enter_side) + msg_type = RPCMessageType.SHORT_CANCEL if trade.is_short else RPCMessageType.BUY_CANCEL + msg = { + 'trade_id': trade.id, + 'type': msg_type, + 'buy_tag': trade.buy_tag, + 'exchange': self.exchange.name.capitalize(), + 'pair': trade.pair, + 'limit': trade.open_rate, + 'order_type': order_type, + 'stake_amount': trade.stake_amount, + 'stake_currency': self.config['stake_currency'], + 'fiat_currency': self.config.get('fiat_display_currency', None), + 'amount': trade.amount, + 'open_date': trade.open_date, + 'current_rate': current_rate, + 'reason': reason, + } + + # Send the message + self.rpc.send_msg(msg) + + def _notify_enter_fill(self, trade: Trade) -> None: + msg_type = RPCMessageType.SHORT_FILL if trade.is_short else RPCMessageType.BUY_FILL + msg = { + 'trade_id': trade.id, + 'type': msg_type, + 'buy_tag': trade.buy_tag, + 'exchange': self.exchange.name.capitalize(), + 'pair': trade.pair, + 'open_rate': trade.open_rate, + 'stake_amount': trade.stake_amount, + 'stake_currency': self.config['stake_currency'], + 'fiat_currency': self.config.get('fiat_display_currency', None), + 'amount': trade.amount, + 'open_date': trade.open_date, + } + self.rpc.send_msg(msg) + +# +# SELL / exit positions / close trades logic and methods +# + + def exit_positions(self, trades: List[Any]) -> int: + """ + Tries to execute sell/exit_short orders for open trades (positions) + """ + trades_closed = 0 + for trade in trades: + try: + + if (self.strategy.order_types.get('stoploss_on_exchange') and + self.handle_stoploss_on_exchange(trade)): + trades_closed += 1 + Trade.commit() + continue + # Check if we can sell our current pair + if trade.open_order_id is None and trade.is_open and self.handle_trade(trade): + trades_closed += 1 + + except DependencyException as exception: + logger.warning('Unable to exit trade %s: %s', trade.pair, exception) + + # Updating wallets if any trade occurred + if trades_closed: + self.wallets.update() + + return trades_closed + + def handle_trade(self, trade: Trade) -> bool: + """ + Sells/exits_short the current pair if the threshold is reached and updates the trade record. + :return: True if trade has been sold/exited_short, False otherwise + """ + if not trade.is_open: + raise DependencyException(f'Attempt to handle closed trade: {trade}') + + logger.debug('Handling %s ...', trade) + + (enter, exit_) = (False, False) + exit_signal_type = "exit_short" if trade.is_short else "exit_long" + + # TODO-lev: change to use_exit_signal, ignore_roi_if_enter_signal + if (self.config.get('use_sell_signal', True) or + self.config.get('ignore_roi_if_buy_signal', False)): + analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair, + self.strategy.timeframe) + + (enter, exit_) = self.strategy.get_exit_signal( + trade.pair, + self.strategy.timeframe, + analyzed_df, + is_short=trade.is_short + ) + + logger.debug('checking exit') + exit_rate = self.exchange.get_rate(trade.pair, refresh=True, side=trade.exit_side) + if self._check_and_execute_exit(trade, exit_rate, enter, exit_): + return True + + logger.debug(f'Found no {exit_signal_type} signal for %s.', trade) + return False + + def create_stoploss_order(self, trade: Trade, stop_price: float) -> bool: + """ + Abstracts creating stoploss orders from the logic. + Handles errors and updates the trade database object. + Force-sells the pair (using EmergencySell reason) in case of Problems creating the order. + :return: True if the order succeeded, and False in case of problems. + """ + try: + stoploss_order = self.exchange.stoploss( + pair=trade.pair, + amount=trade.amount, + stop_price=stop_price, + order_types=self.strategy.order_types, + side=trade.exit_side + ) + + order_obj = Order.parse_from_ccxt_object(stoploss_order, trade.pair, 'stoploss') + trade.orders.append(order_obj) + trade.stoploss_order_id = str(stoploss_order['id']) + return True + except InsufficientFundsError as e: + logger.warning(f"Unable to place stoploss order {e}.") + # Try to figure out what went wrong + self.handle_insufficient_funds(trade) + + except InvalidOrderException as e: + trade.stoploss_order_id = None + logger.error(f'Unable to place a stoploss order on exchange. {e}') + logger.warning('Exiting the trade forcefully') + self.execute_trade_exit(trade, trade.stop_loss, sell_reason=SellCheckTuple( + sell_type=SellType.EMERGENCY_SELL), side=trade.exit_side) + + except ExchangeError: + trade.stoploss_order_id = None + logger.exception('Unable to place a stoploss order on exchange.') + return False + + def handle_stoploss_on_exchange(self, trade: Trade) -> bool: + """ + Check if trade is fulfilled in which case the stoploss + on exchange should be added immediately if stoploss on exchange + is enabled. + # TODO-lev: liquidation price will always be on exchange, even though + # TODO-lev: stoploss_on_exchange might not be enabled + """ + + logger.debug('Handling stoploss on exchange %s ...', trade) + + stoploss_order = None + + try: + # First we check if there is already a stoploss on exchange + stoploss_order = self.exchange.fetch_stoploss_order( + trade.stoploss_order_id, trade.pair) if trade.stoploss_order_id else None + except InvalidOrderException as exception: + logger.warning('Unable to fetch stoploss order: %s', exception) + + if stoploss_order: + trade.update_order(stoploss_order) + + # We check if stoploss order is fulfilled + if stoploss_order and stoploss_order['status'] in ('closed', 'triggered'): + # TODO-lev: Update to exit reason + trade.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value + self.update_trade_state(trade, trade.stoploss_order_id, stoploss_order, + stoploss_order=True) + # Lock pair for one candle to prevent immediate rebuys + self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), + reason='Auto lock') + self._notify_exit(trade, "stoploss") + return True + + if trade.open_order_id or not trade.is_open: + # Trade has an open Buy or Sell order, Stoploss-handling can't happen in this case + # as the Amount on the exchange is tied up in another trade. + # The trade can be closed already (sell-order fill confirmation came in this iteration) + return False + + # If enter order is fulfilled but there is no stoploss, we add a stoploss on exchange + if not stoploss_order: + stoploss = self.edge.stoploss(pair=trade.pair) if self.edge else self.strategy.stoploss + if trade.is_short: + stop_price = trade.open_rate * (1 - stoploss) + else: + stop_price = trade.open_rate * (1 + stoploss) + + if self.create_stoploss_order(trade=trade, stop_price=stop_price): + trade.stoploss_last_update = datetime.utcnow() + return False + + # If stoploss order is canceled for some reason we add it + if stoploss_order and stoploss_order['status'] in ('canceled', 'cancelled'): + if self.create_stoploss_order(trade=trade, stop_price=trade.stop_loss): + return False + else: + trade.stoploss_order_id = None + logger.warning('Stoploss order was cancelled, but unable to recreate one.') + + # Finally we check if stoploss on exchange should be moved up because of trailing. + # Triggered Orders are now real orders - so don't replace stoploss anymore + if ( + stoploss_order + and stoploss_order.get('status_stop') != 'triggered' + and (self.config.get('trailing_stop', False) + or self.config.get('use_custom_stoploss', False)) + ): + # if trailing stoploss is enabled we check if stoploss value has changed + # in which case we cancel stoploss order and put another one with new + # value immediately + self.handle_trailing_stoploss_on_exchange(trade, stoploss_order, side=trade.exit_side) + + return False + + def handle_trailing_stoploss_on_exchange(self, trade: Trade, order: dict, side: str) -> None: + """ + Check to see if stoploss on exchange should be updated + in case of trailing stoploss on exchange + :param trade: Corresponding Trade + :param order: Current on exchange stoploss order + :return: None + """ + if self.exchange.stoploss_adjust(trade.stop_loss, order, side=trade.exit_side): + # we check if the update is necessary + update_beat = self.strategy.order_types.get('stoploss_on_exchange_interval', 60) + if (datetime.utcnow() - trade.stoploss_last_update).total_seconds() >= update_beat: + # cancelling the current stoploss on exchange first + logger.info(f"Cancelling current stoploss on exchange for pair {trade.pair} " + f"(orderid:{order['id']}) in order to add another one ...") + try: + co = self.exchange.cancel_stoploss_order_with_result(order['id'], trade.pair, + trade.amount) + trade.update_order(co) + except InvalidOrderException: + logger.exception(f"Could not cancel stoploss order {order['id']} " + f"for pair {trade.pair}") + + # Create new stoploss order + if not self.create_stoploss_order(trade=trade, stop_price=trade.stop_loss): + logger.warning(f"Could not create trailing stoploss order " + f"for pair {trade.pair}.") + + def _check_and_execute_exit(self, trade: Trade, exit_rate: float, + enter: bool, exit_: bool) -> bool: + """ + Check and execute trade exit + """ + should_exit: SellCheckTuple = self.strategy.should_exit( + trade, exit_rate, datetime.now(timezone.utc), enter=enter, exit_=exit_, + force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0 + ) + + if should_exit.sell_flag: + logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.sell_type}') + self.execute_trade_exit(trade, exit_rate, should_exit, side=trade.exit_side) + return True + return False + + def _check_timed_out(self, side: str, order: dict) -> bool: + """ + Check if timeout is active, and if the order is still open and timed out + """ + timeout = self.config.get('unfilledtimeout', {}).get(side) + ordertime = arrow.get(order['datetime']).datetime + if timeout is not None: + timeout_unit = self.config.get('unfilledtimeout', {}).get('unit', 'minutes') + timeout_kwargs = {timeout_unit: -timeout} + timeout_threshold = arrow.utcnow().shift(**timeout_kwargs).datetime + return (order['status'] == 'open' and order['side'] == side + and ordertime < timeout_threshold) + return False + + def check_handle_timedout(self) -> None: + """ + Check if any orders are timed out and cancel if necessary + :param timeoutvalue: Number of minutes until order is considered timed out + :return: None + """ + + for trade in Trade.get_open_order_trades(): + try: + if not trade.open_order_id: + continue + order = self.exchange.fetch_order(trade.open_order_id, trade.pair) + except (ExchangeError): + logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc()) + continue + + fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order) + + if ( + order['side'] == trade.enter_side and + (order['status'] == 'open' or fully_cancelled) and + (fully_cancelled or + self._check_timed_out(trade.enter_side, order) or + strategy_safe_wrapper( + self.strategy.check_buy_timeout, + default_retval=False + )( + pair=trade.pair, + trade=trade, + order=order + ) + ) + ): + self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT']) + + elif ( + order['side'] == trade.exit_side and + (order['status'] == 'open' or fully_cancelled) and + (fully_cancelled or + self._check_timed_out(trade.exit_side, order) or + strategy_safe_wrapper( + self.strategy.check_sell_timeout, + default_retval=False + )( + pair=trade.pair, + trade=trade, + order=order + ) + ) + ): + self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT']) + + def cancel_all_open_orders(self) -> None: + """ + Cancel all orders that are currently open + :return: None + """ + + for trade in Trade.get_open_order_trades(): + try: + order = self.exchange.fetch_order(trade.open_order_id, trade.pair) + except (ExchangeError): + logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc()) + continue + + if order['side'] == trade.enter_side: + self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['ALL_CANCELLED']) + + elif order['side'] == trade.exit_side: + self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['ALL_CANCELLED']) + Trade.commit() + + def handle_cancel_enter(self, trade: Trade, order: Dict, reason: str) -> bool: + """ + Buy cancel - cancel order + :return: True if order was fully cancelled + """ + # TODO-lev: Pay back borrowed/interest and transfer back on leveraged trades + was_trade_fully_canceled = False + + # Cancelled orders may have the status of 'canceled' or 'closed' + if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES: + filled_val = order.get('filled', 0.0) or 0.0 + filled_stake = filled_val * trade.open_rate + minstake = self.exchange.get_min_pair_stake_amount( + trade.pair, trade.open_rate, self.strategy.stoploss) + + if filled_val > 0 and filled_stake < minstake: + logger.warning( + f"Order {trade.open_order_id} for {trade.pair} not cancelled, " + f"as the filled amount of {filled_val} would result in an unexitable trade.") + return False + corder = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair, + trade.amount) + # Avoid race condition where the order could not be cancelled coz its already filled. + # Simply bailing here is the only safe way - as this order will then be + # handled in the next iteration. + if corder.get('status') not in constants.NON_OPEN_EXCHANGE_STATES: + logger.warning(f"Order {trade.open_order_id} for {trade.pair} not cancelled.") + return False + else: + # Order was cancelled already, so we can reuse the existing dict + corder = order + reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] + + side = trade.enter_side.capitalize() + logger.info('%s order %s for %s.', side, reason, trade) + + # Using filled to determine the filled amount + filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled') + if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC): + logger.info( + '%s order fully cancelled. Removing %s from database.', + side, trade + ) + # if trade is not partially completed, just delete the trade + trade.delete() + was_trade_fully_canceled = True + reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}" + else: + # if trade is partially complete, edit the stake details for the trade + # and close the order + # cancel_order may not contain the full order dict, so we need to fallback + # to the order dict acquired before cancelling. + # we need to fall back to the values from order if corder does not contain these keys. + trade.amount = filled_amount + # TODO-lev: Check edge cases, we don't want to make leverage > 1.0 if we don't have to + + trade.stake_amount = trade.amount * trade.open_rate + self.update_trade_state(trade, trade.open_order_id, corder) + + trade.open_order_id = None + logger.info('Partial %s order timeout for %s.', trade.enter_side, trade) + reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}" + + self.wallets.update() + self._notify_enter_cancel(trade, order_type=self.strategy.order_types[trade.enter_side], + reason=reason) + return was_trade_fully_canceled + + def handle_cancel_exit(self, trade: Trade, order: Dict, reason: str) -> str: + """ + Sell/exit_short cancel - cancel order and update trade + :return: Reason for cancel + """ + # if trade is not partially completed, just cancel the order + if order['remaining'] == order['amount'] or order.get('filled') == 0.0: + if not self.exchange.check_order_canceled_empty(order): + try: + # if trade is not partially completed, just delete the order + co = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair, + trade.amount) + trade.update_order(co) + except InvalidOrderException: + logger.exception( + f"Could not cancel {trade.exit_side} order {trade.open_order_id}") + return 'error cancelling order' + logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade) + else: + reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] + logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade) + trade.update_order(order) + + trade.close_rate = None + trade.close_rate_requested = None + trade.close_profit = None + trade.close_profit_abs = None + trade.close_date = None + trade.is_open = True + trade.open_order_id = None + else: + # TODO: figure out how to handle partially complete sell orders + reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] + + self.wallets.update() + self._notify_exit_cancel( + trade, + order_type=self.strategy.order_types[trade.exit_side], + reason=reason + ) + return reason + + def _safe_exit_amount(self, pair: str, amount: float) -> float: + """ + Get sellable amount. + Should be trade.amount - but will fall back to the available amount if necessary. + This should cover cases where get_real_amount() was not able to update the amount + for whatever reason. + :param pair: Pair we're trying to sell + :param amount: amount we expect to be available + :return: amount to sell + :raise: DependencyException: if available balance is not within 2% of the available amount. + """ + # TODO-lev Maybe update? + # Update wallets to ensure amounts tied up in a stoploss is now free! + self.wallets.update() + trade_base_currency = self.exchange.get_pair_base_currency(pair) + wallet_amount = self.wallets.get_free(trade_base_currency) + logger.debug(f"{pair} - Wallet: {wallet_amount} - Trade-amount: {amount}") + if wallet_amount >= amount: + return amount + elif wallet_amount > amount * 0.98: + logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.") + return wallet_amount + else: + raise DependencyException( + f"Not enough amount to exit trade. Trade-amount: {amount}, Wallet: {wallet_amount}") + + def execute_trade_exit( + self, + trade: Trade, + limit: float, + sell_reason: SellCheckTuple, # TODO-lev update to exit_reason + side: str + ) -> bool: + """ + Executes a trade exit for the given trade and limit + :param trade: Trade instance + :param limit: limit rate for the sell order + :param sell_reason: Reason the sell was triggered + :param side: "buy" or "sell" + :return: True if it succeeds (supported) False (not supported) + """ + exit_type = 'sell' # TODO-lev: Update to exit + if sell_reason.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS): + exit_type = 'stoploss' + + # if stoploss is on exchange and we are on dry_run mode, + # we consider the sell price stop price + if self.config['dry_run'] and exit_type == 'stoploss' \ + and self.strategy.order_types['stoploss_on_exchange']: + limit = trade.stop_loss + + # set custom_exit_price if available + proposed_limit_rate = limit + current_profit = trade.calc_profit_ratio(limit) + custom_exit_price = strategy_safe_wrapper(self.strategy.custom_exit_price, + default_retval=proposed_limit_rate)( + pair=trade.pair, trade=trade, + current_time=datetime.now(timezone.utc), + proposed_rate=proposed_limit_rate, current_profit=current_profit) + + limit = self.get_valid_price(custom_exit_price, proposed_limit_rate) + + # First cancelling stoploss on exchange ... + if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id: + try: + co = self.exchange.cancel_stoploss_order_with_result(trade.stoploss_order_id, + trade.pair, trade.amount) + trade.update_order(co) + except InvalidOrderException: + logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}") + + order_type = self.strategy.order_types[exit_type] + if sell_reason.sell_type == SellType.EMERGENCY_SELL: + # Emergency sells (default to market!) + order_type = self.strategy.order_types.get("emergencysell", "market") + if sell_reason.sell_type == SellType.FORCE_SELL: + # Force sells (default to the sell_type defined in the strategy, + # but we allow this value to be changed) + order_type = self.strategy.order_types.get("forcesell", order_type) + + amount = self._safe_exit_amount(trade.pair, trade.amount) + time_in_force = self.strategy.order_time_in_force['sell'] # TODO-lev update to exit + + if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( + pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit, + time_in_force=time_in_force, sell_reason=sell_reason.sell_reason, + current_time=datetime.now(timezone.utc)): + logger.info(f"User requested abortion of exiting {trade.pair}") + return False + + try: + # Execute sell and update trade record + order = self.exchange.create_order( + pair=trade.pair, + ordertype=order_type, + amount=amount, + rate=limit, + time_in_force=time_in_force, + side=trade.exit_side + ) + except InsufficientFundsError as e: + logger.warning(f"Unable to place order {e}.") + # Try to figure out what went wrong + self.handle_insufficient_funds(trade) + return False + + order_obj = Order.parse_from_ccxt_object(order, trade.pair, trade.exit_side) + trade.orders.append(order_obj) + + trade.open_order_id = order['id'] + trade.sell_order_status = '' + trade.close_rate_requested = limit + trade.sell_reason = sell_reason.sell_reason + # In case of market sell orders the order can be closed immediately + if order.get('status', 'unknown') in ('closed', 'expired'): + self.update_trade_state(trade, trade.open_order_id, order) + Trade.commit() + + # Lock pair for one candle to prevent immediate re-trading + self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), + reason='Auto lock') + + self._notify_exit(trade, order_type) + + return True + + def _notify_exit(self, trade: Trade, order_type: str, fill: bool = False) -> None: + """ + Sends rpc notification when a sell occurred. + """ + profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested + profit_trade = trade.calc_profit(rate=profit_rate) + # Use cached rates here - it was updated seconds ago. + current_rate = self.exchange.get_rate( + trade.pair, refresh=False, side=trade.exit_side) if not fill else None + profit_ratio = trade.calc_profit_ratio(profit_rate) + gain = "profit" if profit_ratio > 0 else "loss" + + msg = { + 'type': (RPCMessageType.SELL_FILL if fill + else RPCMessageType.SELL), + 'trade_id': trade.id, + 'exchange': trade.exchange.capitalize(), + 'pair': trade.pair, + 'gain': gain, + 'limit': profit_rate, + 'order_type': order_type, + 'amount': trade.amount, + 'open_rate': trade.open_rate, + 'close_rate': trade.close_rate, + 'current_rate': current_rate, + 'profit_amount': profit_trade, + 'profit_ratio': profit_ratio, + 'sell_reason': trade.sell_reason, + 'open_date': trade.open_date, + 'close_date': trade.close_date or datetime.utcnow(), + 'stake_currency': self.config['stake_currency'], + 'fiat_currency': self.config.get('fiat_display_currency', None), + } + + if 'fiat_display_currency' in self.config: + msg.update({ + 'fiat_currency': self.config['fiat_display_currency'], + }) + + # Send the message + self.rpc.send_msg(msg) + + def _notify_exit_cancel(self, trade: Trade, order_type: str, reason: str) -> None: + """ + Sends rpc notification when a sell cancel occurred. + """ + if trade.sell_order_status == reason: + return + else: + trade.sell_order_status = reason + + profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested + profit_trade = trade.calc_profit(rate=profit_rate) + current_rate = self.exchange.get_rate(trade.pair, refresh=False, side=trade.exit_side) + profit_ratio = trade.calc_profit_ratio(profit_rate) + gain = "profit" if profit_ratio > 0 else "loss" + + msg = { + 'type': RPCMessageType.SELL_CANCEL, + 'trade_id': trade.id, + 'exchange': trade.exchange.capitalize(), + 'pair': trade.pair, + 'gain': gain, + 'limit': profit_rate, + 'order_type': order_type, + 'amount': trade.amount, + 'open_rate': trade.open_rate, + 'current_rate': current_rate, + 'profit_amount': profit_trade, + 'profit_ratio': profit_ratio, + 'sell_reason': trade.sell_reason, + 'open_date': trade.open_date, + 'close_date': trade.close_date, + 'stake_currency': self.config['stake_currency'], + 'fiat_currency': self.config.get('fiat_display_currency', None), + 'reason': reason, + } + + if 'fiat_display_currency' in self.config: + msg.update({ + 'fiat_currency': self.config['fiat_display_currency'], + }) + + # Send the message + self.rpc.send_msg(msg) + +# +# Common update trade state methods +# + + def update_trade_state(self, trade: Trade, order_id: str, action_order: Dict[str, Any] = None, + stoploss_order: bool = False) -> bool: + """ + Checks trades with open orders and updates the amount if necessary + Handles closing both buy and sell orders. + :param trade: Trade object of the trade we're analyzing + :param order_id: Order-id of the order we're analyzing + :param action_order: Already acquired order object + :return: True if order has been cancelled without being filled partially, False otherwise + """ + if not order_id: + logger.warning(f'Orderid for trade {trade} is empty.') + return False + + # Update trade with order values + logger.info('Found open order for %s', trade) + try: + order = action_order or self.exchange.fetch_order_or_stoploss_order(order_id, + trade.pair, + stoploss_order) + except InvalidOrderException as exception: + logger.warning('Unable to fetch order %s: %s', order_id, exception) + return False + + trade.update_order(order) + + # Try update amount (binance-fix) + try: + new_amount = self.get_real_amount(trade, order) + if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount, + abs_tol=constants.MATH_CLOSE_PREC): + order['amount'] = new_amount + order.pop('filled', None) + trade.recalc_open_trade_value() + except DependencyException as exception: + logger.warning("Could not update trade amount: %s", exception) + + if self.exchange.check_order_canceled_empty(order): + # Trade has been cancelled on exchange + # Handling of this will happen in check_handle_timeout. + return True + trade.update(order) + Trade.commit() + + # Updating wallets when order is closed + if not trade.is_open: + if not stoploss_order and not trade.open_order_id: + self._notify_exit(trade, '', True) + self.protections.stop_per_pair(trade.pair) + self.protections.global_stop() + self.wallets.update() + elif not trade.open_order_id: + # Buy fill + self._notify_enter_fill(trade) + + return False + + def apply_fee_conditional(self, trade: Trade, trade_base_currency: str, + amount: float, fee_abs: float) -> float: + """ + Applies the fee to amount (either from Order or from Trades). + Can eat into dust if more than the required asset is available. + """ + self.wallets.update() + if fee_abs != 0 and self.wallets.get_free(trade_base_currency) >= amount: + # Eat into dust if we own more than base currency + # TODO-lev: won't be in (quote) currency for shorts + logger.info(f"Fee amount for {trade} was in base currency - " + f"Eating Fee {fee_abs} into dust.") + elif fee_abs != 0: + real_amount = self.exchange.amount_to_precision(trade.pair, amount - fee_abs) + logger.info(f"Applying fee on amount for {trade} " + f"(from {amount} to {real_amount}).") + return real_amount + return amount + + def get_real_amount(self, trade: Trade, order: Dict) -> float: + """ + Detect and update trade fee. + Calls trade.update_fee() upon correct detection. + Returns modified amount if the fee was taken from the destination currency. + Necessary for exchanges which charge fees in base currency (e.g. binance) + :return: identical (or new) amount for the trade + """ + # Init variables + order_amount = safe_value_fallback(order, 'filled', 'amount') + # Only run for closed orders + if trade.fee_updated(order.get('side', '')) or order['status'] == 'open': + return order_amount + + trade_base_currency = self.exchange.get_pair_base_currency(trade.pair) + # use fee from order-dict if possible + if self.exchange.order_has_fee(order): + fee_cost, fee_currency, fee_rate = self.exchange.extract_cost_curr_rate(order) + logger.info(f"Fee for Trade {trade} [{order.get('side')}]: " + f"{fee_cost:.8g} {fee_currency} - rate: {fee_rate}") + if fee_rate is None or fee_rate < 0.02: + # Reject all fees that report as > 2%. + # These are most likely caused by a parsing bug in ccxt + # due to multiple trades (https://github.com/ccxt/ccxt/issues/8025) + trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', '')) + if trade_base_currency == fee_currency: + # Apply fee to amount + return self.apply_fee_conditional(trade, trade_base_currency, + amount=order_amount, fee_abs=fee_cost) + return order_amount + return self.fee_detection_from_trades(trade, order, order_amount) + + def fee_detection_from_trades(self, trade: Trade, order: Dict, order_amount: float) -> float: + """ + fee-detection fallback to Trades. Parses result of fetch_my_trades to get correct fee. + """ + trades = self.exchange.get_trades_for_order(self.exchange.get_order_id_conditional(order), + trade.pair, trade.open_date) + + if len(trades) == 0: + logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade) + return order_amount + fee_currency = None + amount = 0 + fee_abs = 0.0 + fee_cost = 0.0 + trade_base_currency = self.exchange.get_pair_base_currency(trade.pair) + fee_rate_array: List[float] = [] + for exectrade in trades: + amount += exectrade['amount'] + if self.exchange.order_has_fee(exectrade): + fee_cost_, fee_currency, fee_rate_ = self.exchange.extract_cost_curr_rate(exectrade) + fee_cost += fee_cost_ + if fee_rate_ is not None: + fee_rate_array.append(fee_rate_) + # only applies if fee is in quote currency! + if trade_base_currency == fee_currency: + fee_abs += fee_cost_ + # Ensure at least one trade was found: + if fee_currency: + # fee_rate should use mean + fee_rate = sum(fee_rate_array) / float(len(fee_rate_array)) if fee_rate_array else None + if fee_rate is not None and fee_rate < 0.02: + # Only update if fee-rate is < 2% + trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', '')) + + if not isclose(amount, order_amount, abs_tol=constants.MATH_CLOSE_PREC): + # TODO-lev: leverage? + logger.warning(f"Amount {amount} does not match amount {trade.amount}") + raise DependencyException("Half bought? Amounts don't match") + + if fee_abs != 0: + return self.apply_fee_conditional(trade, trade_base_currency, + amount=amount, fee_abs=fee_abs) + else: + return amount + + def get_valid_price(self, custom_price: float, proposed_price: float) -> float: + """ + Return the valid price. + Check if the custom price is of the good type if not return proposed_price + :return: valid price for the order + """ + if custom_price: + try: + valid_custom_price = float(custom_price) + except ValueError: + valid_custom_price = proposed_price + else: + valid_custom_price = proposed_price + + cust_p_max_dist_r = self.config.get('custom_price_max_distance_ratio', 0.02) + min_custom_price_allowed = proposed_price - (proposed_price * cust_p_max_dist_r) + max_custom_price_allowed = proposed_price + (proposed_price * cust_p_max_dist_r) + + # Bracket between min_custom_price_allowed and max_custom_price_allowed + return max( + min(valid_custom_price, max_custom_price_allowed), + min_custom_price_allowed) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 37289888c..51e55dfe0 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -11,7 +11,7 @@ import arrow import pytest from freqtrade.constants import CANCEL_REASON, MATH_CLOSE_PREC, UNLIMITED_STAKE_AMOUNT -from freqtrade.enums import RPCMessageType, RunMode, SellType, State +from freqtrade.enums import RPCMessageType, RunMode, SellType, SignalDirection, State from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError, InvalidOrderException, OperationalException, PricingError, TemporaryError) @@ -631,7 +631,7 @@ def test_process_trade_creation(default_conf, ticker, limit_buy_order, limit_buy assert trade.amount == 91.07468123 assert log_has( - 'Buy signal found: about create a new trade for ETH/BTC with stake_amount: 0.001 ...', + 'Long signal found: about create a new trade for ETH/BTC with stake_amount: 0.001 ...', caplog ) @@ -2508,6 +2508,8 @@ def test_handle_cancel_enter(mocker, caplog, default_conf, limit_buy_order) -> N trade = MagicMock() trade.pair = 'LTC/USDT' trade.open_rate = 200 + trade.is_short = False + trade.enter_side = "buy" limit_buy_order['filled'] = 0.0 limit_buy_order['status'] = 'open' reason = CANCEL_REASON['TIMEOUT'] @@ -2519,7 +2521,7 @@ def test_handle_cancel_enter(mocker, caplog, default_conf, limit_buy_order) -> N limit_buy_order['filled'] = 0.01 assert not freqtrade.handle_cancel_enter(trade, limit_buy_order, reason) assert cancel_order_mock.call_count == 0 - assert log_has_re("Order .* for .* not cancelled, as the filled amount.* unsellable.*", caplog) + assert log_has_re("Order .* for .* not cancelled, as the filled amount.* unexitable.*", caplog) caplog.clear() cancel_order_mock.reset_mock() @@ -2550,6 +2552,7 @@ def test_handle_cancel_enter_exchanges(mocker, caplog, default_conf, reason = CANCEL_REASON['TIMEOUT'] trade = MagicMock() trade.pair = 'LTC/ETH' + trade.enter_side = "buy" assert freqtrade.handle_cancel_enter(trade, limit_buy_order_canceled_empty, reason) assert cancel_order_mock.call_count == 0 assert log_has_re(r'Buy order fully cancelled. Removing .* from database\.', caplog) @@ -2577,7 +2580,9 @@ def test_handle_cancel_enter_corder_empty(mocker, default_conf, limit_buy_order, trade = MagicMock() trade.pair = 'LTC/USDT' + trade.enter_side = "buy" trade.open_rate = 200 + trade.enter_side = "buy" limit_buy_order['filled'] = 0.0 limit_buy_order['status'] = 'open' reason = CANCEL_REASON['TIMEOUT'] @@ -3374,7 +3379,7 @@ def test__safe_exit_amount_error(default_conf, fee, caplog, mocker): ) freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) - with pytest.raises(DependencyException, match=r"Not enough amount to exit."): + with pytest.raises(DependencyException, match=r"Not enough amount to exit trade."): assert freqtrade._safe_exit_amount(trade.pair, trade.amount) @@ -4210,7 +4215,7 @@ def test_order_book_bid_strategy_exception(mocker, default_conf, caplog) -> None assert log_has_re(r'Buy Price at location 1 from orderbook could not be determined.', caplog) -def test_check_depth_of_market_buy(default_conf, mocker, order_book_l2) -> None: +def test_check_depth_of_market(default_conf, mocker, order_book_l2) -> None: """ test check depth of market """ @@ -4227,7 +4232,7 @@ def test_check_depth_of_market_buy(default_conf, mocker, order_book_l2) -> None: freqtrade = FreqtradeBot(default_conf) conf = default_conf['bid_strategy']['check_depth_of_market'] - assert freqtrade._check_depth_of_market_buy('ETH/BTC', conf) is False + assert freqtrade._check_depth_of_market('ETH/BTC', conf, side=SignalDirection.LONG) is False def test_order_book_ask_strategy(default_conf, limit_buy_order_open, limit_buy_order, fee,