diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index a5709059a..27f5f91b6 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -275,9 +275,12 @@ The relative profit of `1.2%` is the average profit per trade. The relative profit of `15.2 Σ%` is be based on the starting capital - so in this case, the starting capital was `0.00485701 * 1.152 = 0.00738 BTC`. Starting capital is either taken from the `available_capital` setting, or calculated by using current wallet size - profits. -### /forcesell +### /forceexit -> **BINANCE:** Selling BTC/LTC with limit `0.01650000 (profit: ~-4.07%, -0.00008168)` +> **BINANCE:** Exiting BTC/LTC with limit `0.01650000 (profit: ~-4.07%, -0.00008168)` + +!!! Tip + You can get a list of all open trades by calling `/forceexit` without parameter, which will show a list of buttons to simply exit a trade. ### /forcelong [rate] | /forceshort [rate] diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 5f6a8b147..ce43dfb4f 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -103,7 +103,6 @@ class Telegram(RPCHandler): ['/count', '/start', '/stop', '/help'] ] # do not allow commands with mandatory arguments and critical cmds - # like /forcesell and /forcebuy # TODO: DRY! - its not good to list all valid cmds here. But otherwise # this needs refactoring of the whole telegram module (same # problem in _help()). @@ -116,6 +115,7 @@ class Telegram(RPCHandler): r'/logs$', r'/whitelist$', r'/blacklist$', r'/bl_delete$', r'/weekly$', r'/weekly \d+$', r'/monthly$', r'/monthly \d+$', r'/forcebuy$', r'/forcelong$', r'/forceshort$', + r'/forcesell$', r'/forceexit$', r'/edge$', r'/health$', r'/help$', r'/version$'] # Create keys for generation valid_keys_print = [k.replace('$', '') for k in valid_keys] @@ -197,7 +197,8 @@ class Telegram(RPCHandler): pattern='update_exit_reason_performance'), CallbackQueryHandler(self._mix_tag_performance, pattern='update_mix_tag_performance'), CallbackQueryHandler(self._count, pattern='update_count'), - CallbackQueryHandler(self._force_enter_inline), + CallbackQueryHandler(self._force_exit_inline, pattern=r"force_exit__\S+"), + CallbackQueryHandler(self._force_enter_inline, pattern=r"\S+\/\S+"), ] for handle in handles: self._updater.dispatcher.add_handler(handle) @@ -928,23 +929,58 @@ class Telegram(RPCHandler): @authorized_only def _force_exit(self, update: Update, context: CallbackContext) -> None: """ - Handler for /forcesell . + Handler for /forceexit . Sells the given trade at current price :param bot: telegram bot :param update: message update :return: None """ - trade_id = context.args[0] if context.args and len(context.args) > 0 else None - if not trade_id: - self._send_msg("You must specify a trade-id or 'all'.") - return - try: - msg = self._rpc._rpc_force_exit(trade_id) - self._send_msg('Force_exit Result: `{result}`'.format(**msg)) + if context.args: + trade_id = context.args[0] + self._force_exit_action(trade_id) + else: + fiat_currency = self._config.get('fiat_display_currency', '') + try: + statlist, head, fiat_profit_sum = self._rpc._rpc_status_table( + self._config['stake_currency'], fiat_currency) + except RPCException: + self._send_msg(msg='No open trade found.') + return + trades = [] + for trade in statlist: + trades.append((trade[0], f"{trade[0]} {trade[1]} {trade[2]} {trade[3]}")) - except RPCException as e: - self._send_msg(str(e)) + trade_buttons = [ + InlineKeyboardButton(text=trade[1], callback_data=f"force_exit__{trade[0]}") + for trade in trades] + buttons_aligned = self._layout_inline_keyboard_onecol(trade_buttons) + + buttons_aligned.append([InlineKeyboardButton( + text='Cancel', callback_data='force_exit__cancel')]) + self._send_msg(msg="Which trade?", keyboard=buttons_aligned) + + def _force_exit_action(self, trade_id): + if trade_id != 'cancel': + try: + self._rpc._rpc_force_exit(trade_id) + except RPCException as e: + self._send_msg(str(e)) + + def _force_exit_inline(self, update: Update, _: CallbackContext) -> None: + if update.callback_query: + query = update.callback_query + if query.data and '__' in query.data: + # Input data is "force_exit__" + trade_id = query.data.split("__")[1].split(' ')[0] + if trade_id == 'cancel': + query.answer() + query.edit_message_text(text="Force exit canceled.") + return + trade: Trade = Trade.get_trades(trade_filter=Trade.id == trade_id).first() + query.answer() + query.edit_message_text(text=f"Manually exiting Trade #{trade_id}, {trade.pair}") + self._force_exit_action(trade_id) def _force_enter_action(self, pair, price: Optional[float], order_side: SignalDirection): if pair != 'cancel': @@ -964,8 +1000,13 @@ class Telegram(RPCHandler): self._force_enter_action(pair, None, order_side) @staticmethod - def _layout_inline_keyboard(buttons: List[InlineKeyboardButton], - cols=3) -> List[List[InlineKeyboardButton]]: + def _layout_inline_keyboard( + buttons: List[InlineKeyboardButton], cols=3) -> List[List[InlineKeyboardButton]]: + return [buttons[i:i + cols] for i in range(0, len(buttons), cols)] + + @staticmethod + def _layout_inline_keyboard_onecol( + buttons: List[InlineKeyboardButton], cols=1) -> List[List[InlineKeyboardButton]]: return [buttons[i:i + cols] for i in range(0, len(buttons), cols)] @authorized_only diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 9314158d7..3fd4d76fb 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -1068,8 +1068,8 @@ def test_telegram_forcesell_handle(default_conf, update, ticker, fee, } == last_msg -def test_telegram_forcesell_down_handle(default_conf, update, ticker, fee, - ticker_sell_down, mocker) -> None: +def test_telegram_force_exit_down_handle(default_conf, update, ticker, fee, + ticker_sell_down, mocker) -> None: mocker.patch('freqtrade.rpc.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) msg_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg', MagicMock()) @@ -1212,15 +1212,6 @@ def test_forcesell_handle_invalid(default_conf, update, mocker) -> None: assert msg_mock.call_count == 1 assert 'not running' in msg_mock.call_args_list[0][0][0] - # No argument - msg_mock.reset_mock() - freqtradebot.state = State.RUNNING - context = MagicMock() - context.args = [] - telegram._force_exit(update=update, context=context) - assert msg_mock.call_count == 1 - assert "You must specify a trade-id or 'all'." in msg_mock.call_args_list[0][0][0] - # Invalid argument msg_mock.reset_mock() freqtradebot.state = State.RUNNING @@ -1232,6 +1223,59 @@ def test_forcesell_handle_invalid(default_conf, update, mocker) -> None: assert 'invalid argument' in msg_mock.call_args_list[0][0][0] +def test_force_exit_no_pair(default_conf, update, ticker, fee, mocker) -> None: + default_conf['max_open_trades'] = 4 + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker, + get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=True), + ) + femock = mocker.patch('freqtrade.rpc.rpc.RPC._rpc_force_exit') + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + + patch_get_signal(freqtradebot) + + # /forceexit + context = MagicMock() + context.args = [] + telegram._force_exit(update=update, context=context) + # No pair + assert msg_mock.call_args_list[0][1]['msg'] == 'No open trade found.' + + # Create some test data + freqtradebot.enter_positions() + msg_mock.reset_mock() + + # /forceexit + telegram._force_exit(update=update, context=context) + keyboard = msg_mock.call_args_list[0][1]['keyboard'] + # 4 pairs + cancel + assert reduce(lambda acc, x: acc + len(x), keyboard, 0) == 5 + assert keyboard[-1][0].text == "Cancel" + + assert keyboard[1][0].callback_data == 'force_exit__2 ' + update = MagicMock() + update.callback_query = MagicMock() + update.callback_query.data = keyboard[1][0].callback_data + telegram._force_exit_inline(update, None) + assert update.callback_query.answer.call_count == 1 + assert update.callback_query.edit_message_text.call_count == 1 + assert femock.call_count == 1 + assert femock.call_args_list[0][0][0] == '2' + + # Retry exiting - but cancel instead + update.callback_query.reset_mock() + telegram._force_exit(update=update, context=context) + # Use cancel button + update.callback_query.data = keyboard[-1][0].callback_data + telegram._force_exit_inline(update, None) + query = update.callback_query + assert query.answer.call_count == 1 + assert query.edit_message_text.call_count == 1 + assert query.edit_message_text.call_args_list[-1][1]['text'] == "Force exit canceled." + + def test_force_enter_handle(default_conf, update, mocker) -> None: mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0)