diff --git a/.gitignore b/.gitignore index 34c751242..97f77f779 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Freqtrade rules config*.json *.sqlite +*.sqlite-shm +*.sqlite-wal logfile.txt user_data/* !user_data/strategy/sample_strategy.py diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index e7d2dbf1b..90f5dab78 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2022.2' +__version__ = '2022.2.1' if __version__ == 'develop': diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 99872ff0b..20fd833eb 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -979,10 +979,10 @@ class FreqtradeBot(LoggingMixin): or (order_obj and self.strategy.ft_check_timed_out( 'sell', trade, order_obj, datetime.now(timezone.utc)) ))): - self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT']) + canceled = self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT']) canceled_count = trade.get_exit_order_count() max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0) - if max_timeouts > 0 and canceled_count >= max_timeouts: + if canceled and max_timeouts > 0 and canceled_count >= max_timeouts: logger.warning(f'Emergencyselling trade {trade}, as the sell order ' f'timed out {max_timeouts} times.') try: @@ -1079,11 +1079,12 @@ class FreqtradeBot(LoggingMixin): reason=reason) return was_trade_fully_canceled - def handle_cancel_exit(self, trade: Trade, order: Dict, reason: str) -> str: + def handle_cancel_exit(self, trade: Trade, order: Dict, reason: str) -> bool: """ Sell cancel - cancel order and update trade - :return: Reason for cancel + :return: True if exit order was cancelled, false otherwise """ + cancelled = False # 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): @@ -1094,7 +1095,7 @@ class FreqtradeBot(LoggingMixin): trade.update_order(co) except InvalidOrderException: logger.exception(f"Could not cancel sell order {trade.open_order_id}") - return 'error cancelling order' + return False logger.info('Sell order %s for %s.', reason, trade) else: reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] @@ -1108,9 +1109,11 @@ class FreqtradeBot(LoggingMixin): trade.close_date = None trade.is_open = True trade.open_order_id = None + cancelled = True else: # TODO: figure out how to handle partially complete sell orders reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] + cancelled = False self.wallets.update() self._notify_exit_cancel( @@ -1118,7 +1121,7 @@ class FreqtradeBot(LoggingMixin): order_type=self.strategy.order_types['sell'], reason=reason ) - return reason + return cancelled def _safe_exit_amount(self, pair: str, amount: float) -> float: """ diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index e674890d3..559c7e94a 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -195,6 +195,7 @@ class Order(_DECL_BASE): return { 'amount': self.amount, 'average': round(self.average, 8) if self.average else 0, + 'safe_price': self.safe_price, 'cost': self.cost if self.cost else 0, 'filled': self.filled, 'ft_order_side': self.ft_order_side, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index da613fab8..69f7f2858 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -370,46 +370,50 @@ class Telegram(RPCHandler): else: return "\N{CROSS MARK}" - def _prepare_entry_details(self, filled_orders, base_currency, is_open): + def _prepare_entry_details(self, filled_orders: List, base_currency: str, is_open: bool): """ Prepare details of trade with entry adjustment enabled """ - lines = [] + lines: List[str] = [] + if len(filled_orders) > 0: + first_avg = filled_orders[0]["safe_price"] + for x, order in enumerate(filled_orders): cur_entry_datetime = arrow.get(order["order_filled_date"]) cur_entry_amount = order["amount"] - cur_entry_average = order["average"] + cur_entry_average = order["safe_price"] lines.append(" ") if x == 0: - lines.append("*Entry #{}:*".format(x+1)) - lines.append("*Entry Amount:* {} ({:.8f} {})" - .format(cur_entry_amount, order["cost"], base_currency)) - lines.append("*Average Entry Price:* {}".format(cur_entry_average)) + lines.append(f"*Entry #{x+1}:*") + lines.append( + f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {base_currency})") + lines.append(f"*Average Entry Price:* {cur_entry_average}") else: sumA = 0 sumB = 0 for y in range(x): - sumA += (filled_orders[y]["amount"] * filled_orders[y]["average"]) + sumA += (filled_orders[y]["amount"] * filled_orders[y]["safe_price"]) sumB += filled_orders[y]["amount"] - prev_avg_price = sumA/sumB - price_to_1st_entry = ((cur_entry_average - filled_orders[0]["average"]) - / filled_orders[0]["average"]) - minus_on_entry = (cur_entry_average - prev_avg_price)/prev_avg_price + prev_avg_price = sumA / sumB + price_to_1st_entry = ((cur_entry_average - first_avg) / first_avg) + minus_on_entry = 0 + if prev_avg_price: + minus_on_entry = (cur_entry_average - prev_avg_price) / prev_avg_price + dur_entry = cur_entry_datetime - arrow.get(filled_orders[x-1]["order_filled_date"]) days = dur_entry.days hours, remainder = divmod(dur_entry.seconds, 3600) minutes, seconds = divmod(remainder, 60) - lines.append("*Entry #{}:* at {:.2%} avg profit".format(x+1, minus_on_entry)) + lines.append(f"*Entry #{x+1}:* at {minus_on_entry:.2%} avg profit") if is_open: lines.append("({})".format(cur_entry_datetime .humanize(granularity=["day", "hour", "minute"]))) - lines.append("*Entry Amount:* {} ({:.8f} {})" - .format(cur_entry_amount, order["cost"], base_currency)) - lines.append("*Average Entry Price:* {} ({:.2%} from 1st entry rate)" - .format(cur_entry_average, price_to_1st_entry)) - lines.append("*Order filled at:* {}".format(order["order_filled_date"])) - lines.append("({}d {}h {}m {}s from previous entry)" - .format(days, hours, minutes, seconds)) + lines.append( + f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {base_currency})") + lines.append(f"*Average Entry Price:* {cur_entry_average} " + f"({price_to_1st_entry:.2%} from 1st entry rate)") + lines.append(f"*Order filled at:* {order['order_filled_date']}") + lines.append(f"({days}d {hours}h {minutes}m {seconds}s from previous entry)") return lines @authorized_only diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index e7b09ab74..dd6c969ed 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -110,7 +110,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'open_order': None, 'exchange': 'binance', 'filled_entry_orders': [{ - 'amount': 91.07468123, 'average': 1.098e-05, + 'amount': 91.07468123, 'average': 1.098e-05, 'safe_price': 1.098e-05, 'cost': 0.0009999999999054, 'filled': 91.07468123, 'ft_order_side': 'buy', 'order_date': ANY, 'order_timestamp': ANY, 'order_filled_date': ANY, 'order_filled_timestamp': ANY, 'order_type': 'limit', 'price': 1.098e-05, @@ -185,7 +185,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'open_order': None, 'exchange': 'binance', 'filled_entry_orders': [{ - 'amount': 91.07468123, 'average': 1.098e-05, + 'amount': 91.07468123, 'average': 1.098e-05, 'safe_price': 1.098e-05, 'cost': 0.0009999999999054, 'filled': 91.07468123, 'ft_order_side': 'buy', 'order_date': ANY, 'order_timestamp': ANY, 'order_filled_date': ANY, 'order_filled_timestamp': ANY, 'order_type': 'limit', 'price': 1.098e-05, diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 5894b9a0f..ccf61f91b 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -236,6 +236,8 @@ def test_telegram_status_multi_entry(default_conf, update, mocker, fee) -> None: create_mock_trades(fee) trades = Trade.get_open_trades() trade = trades[0] + # Average may be empty on some exchanges + trade.orders[0].average = 0 trade.orders.append(Order( order_id='5412vbb', ft_order_side='buy', @@ -246,7 +248,7 @@ def test_telegram_status_multi_entry(default_conf, update, mocker, fee) -> None: order_type="market", side="buy", price=trade.open_rate * 0.95, - average=trade.open_rate * 0.95, + average=0, filled=trade.amount, remaining=0, cost=trade.amount, diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index d7b47174b..d433998a1 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -6,7 +6,7 @@ import time from copy import deepcopy from math import isclose from typing import List -from unittest.mock import ANY, MagicMock, PropertyMock +from unittest.mock import ANY, MagicMock, PropertyMock, patch import arrow import pytest @@ -2220,9 +2220,14 @@ def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, l et_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_trade_exit') caplog.clear() - # 2nd canceled trade ... open_trade.open_order_id = limit_sell_order_old['id'] + + # If cancelling fails - no emergency sell! + with patch('freqtrade.freqtradebot.FreqtradeBot.handle_cancel_exit', return_value=False): + freqtrade.check_handle_timedout() + assert et_mock.call_count == 0 + freqtrade.check_handle_timedout() assert log_has_re('Emergencyselling trade.*', caplog) assert et_mock.call_count == 1 @@ -2564,13 +2569,17 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: send_msg_mock.reset_mock() order['amount'] = 2 - assert freqtrade.handle_cancel_exit(trade, order, reason - ) == CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] + assert not freqtrade.handle_cancel_exit(trade, order, reason) # Assert cancel_order was not called (callcount remains unchanged) assert cancel_order_mock.call_count == 1 assert send_msg_mock.call_count == 1 - assert freqtrade.handle_cancel_exit(trade, order, reason - ) == CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] + assert (send_msg_mock.call_args_list[0][0][0]['reason'] + == CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN']) + + assert not freqtrade.handle_cancel_exit(trade, order, reason) + + send_msg_mock.call_args_list[0][0][0]['reason'] = CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] + # Message should not be iterated again assert trade.sell_order_status == CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] assert send_msg_mock.call_count == 1 @@ -2589,7 +2598,7 @@ def test_handle_cancel_exit_cancel_exception(mocker, default_conf_usdt) -> None: order = {'remaining': 1, 'amount': 1, 'status': "open"} - assert freqtrade.handle_cancel_exit(trade, order, reason) == 'error cancelling order' + assert not freqtrade.handle_cancel_exit(trade, order, reason) def test_execute_trade_exit_up(default_conf_usdt, ticker_usdt, fee, ticker_usdt_sell_up, mocker