diff --git a/docs/assets/telegram_forcebuy.png b/docs/assets/telegram_forcebuy.png new file mode 100644 index 000000000..b0592bff3 Binary files /dev/null and b/docs/assets/telegram_forcebuy.png differ diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 07f5fe7dd..6174bf0fe 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -250,10 +250,14 @@ Return a summary of your profit/loss and performance. > **BITTREX:** Selling BTC/LTC with limit `0.01650000 (profit: ~-4.07%, -0.00008168)` -### /forcebuy +### /forcebuy [rate] > **BITTREX:** Buying ETH/BTC with limit `0.03400000` (`1.000000 ETH`, `225.290 USD`) +Omitting the pair will open a query asking for the pair to buy (based on the current whitelist). + +![Telegram force-buy screenshot](assets/telegram_forcebuy.png) + Note that for this to work, `forcebuy_enable` needs to be set to true. [More details](configuration.md#understand-forcebuy_enable) @@ -261,12 +265,12 @@ Note that for this to work, `forcebuy_enable` needs to be set to true. ### /performance Return the performance of each crypto-currency the bot has sold. -> Performance: -> 1. `RCN/BTC 0.003 BTC (57.77%) (1)` -> 2. `PAY/BTC 0.0012 BTC (56.91%) (1)` -> 3. `VIB/BTC 0.0011 BTC (47.07%) (1)` -> 4. `SALT/BTC 0.0010 BTC (30.24%) (1)` -> 5. `STORJ/BTC 0.0009 BTC (27.24%) (1)` +> Performance: +> 1. `RCN/BTC 0.003 BTC (57.77%) (1)` +> 2. `PAY/BTC 0.0012 BTC (56.91%) (1)` +> 3. `VIB/BTC 0.0011 BTC (47.07%) (1)` +> 4. `SALT/BTC 0.0010 BTC (30.24%) (1)` +> 5. `STORJ/BTC 0.0009 BTC (27.24%) (1)` > ... ### /balance diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index bdb1590a5..b9e90dc8d 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -9,13 +9,14 @@ from datetime import timedelta from html import escape from itertools import chain from math import isnan -from typing import Any, Callable, Dict, List, Union +from typing import Any, Callable, Dict, List, Optional, Union, cast import arrow from tabulate import tabulate -from telegram import KeyboardButton, ParseMode, ReplyKeyboardMarkup, Update +from telegram import (InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ParseMode, + ReplyKeyboardMarkup, Update) from telegram.error import NetworkError, TelegramError -from telegram.ext import CallbackContext, CommandHandler, Updater +from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, Updater from telegram.utils.helpers import escape_markdown from freqtrade.__init__ import __version__ @@ -88,7 +89,7 @@ class Telegram(RPCHandler): Validates the keyboard configuration from telegram config section. """ - self._keyboard: List[List[Union[str, KeyboardButton]]] = [ + self._keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = [ ['/daily', '/profit', '/balance'], ['/status', '/status table', '/performance'], ['/count', '/start', '/stop', '/help'] @@ -170,6 +171,11 @@ class Telegram(RPCHandler): [h.command for h in handles] ) + self._current_callback_query_handler: Optional[CallbackQueryHandler] = None + self._callback_query_handlers = { + 'forcebuy': CallbackQueryHandler(self._forcebuy_inline) + } + def cleanup(self) -> None: """ Stops all running telegram threads. @@ -637,6 +643,25 @@ class Telegram(RPCHandler): except RPCException as e: self._send_msg(str(e)) + def _forcebuy_action(self, pair, price=None): + try: + self._rpc._rpc_forcebuy(pair, price) + except RPCException as e: + self._send_msg(str(e)) + + def _forcebuy_inline(self, update: Update, _: CallbackContext) -> None: + if update.callback_query: + query = update.callback_query + pair = query.data + query.answer() + query.edit_message_text(text=f"Force Buying: {pair}") + self._forcebuy_action(pair) + + @staticmethod + 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)] + @authorized_only def _forcebuy(self, update: Update, context: CallbackContext) -> None: """ @@ -649,10 +674,13 @@ class Telegram(RPCHandler): if context.args: pair = context.args[0] price = float(context.args[1]) if len(context.args) > 1 else None - try: - self._rpc._rpc_forcebuy(pair, price) - except RPCException as e: - self._send_msg(str(e)) + self._forcebuy_action(pair, price) + else: + whitelist = self._rpc._rpc_whitelist()['whitelist'] + pairs = [InlineKeyboardButton(pair, callback_data=pair) for pair in whitelist] + self._send_inline_msg("Which pair?", + keyboard=self._layout_inline_keyboard(pairs), + callback_query_handler='forcebuy') @authorized_only def _trades(self, update: Update, context: CallbackContext) -> None: @@ -987,8 +1015,9 @@ class Telegram(RPCHandler): f"*Current state:* `{val['state']}`" ) - def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN, - disable_notification: bool = False) -> None: + def _send_inline_msg(self, msg: str, callback_query_handler, + parse_mode: str = ParseMode.MARKDOWN, disable_notification: bool = False, + keyboard: List[List[InlineKeyboardButton]] = None, ) -> None: """ Send given markdown message :param msg: message @@ -996,7 +1025,29 @@ class Telegram(RPCHandler): :param parse_mode: telegram parse mode :return: None """ - reply_markup = ReplyKeyboardMarkup(self._keyboard, resize_keyboard=True) + if self._current_callback_query_handler: + self._updater.dispatcher.remove_handler(self._current_callback_query_handler) + self._current_callback_query_handler = self._callback_query_handlers[callback_query_handler] + self._updater.dispatcher.add_handler(self._current_callback_query_handler) + + self._send_msg(msg, parse_mode, disable_notification, + cast(List[List[Union[str, KeyboardButton, InlineKeyboardButton]]], keyboard), + reply_markup=InlineKeyboardMarkup) + + def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN, + disable_notification: bool = False, + keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = None, + reply_markup=ReplyKeyboardMarkup) -> None: + """ + Send given markdown message + :param msg: message + :param bot: alternative bot + :param parse_mode: telegram parse mode + :return: None + """ + if keyboard is None: + keyboard = self._keyboard + reply_markup = reply_markup(keyboard, resize_keyboard=True) try: try: self._updater.bot.send_message( diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 6008ede66..e640f2dff 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -4,6 +4,7 @@ import re from datetime import datetime +from functools import reduce from random import choice, randint from string import ascii_uppercase from unittest.mock import ANY, MagicMock @@ -54,6 +55,14 @@ class DummyCls(Telegram): raise Exception('test') +def get_telegram_testobject_with_inline(mocker, default_conf, mock=True, ftbot=None): + inline_msg_mock = MagicMock() + telegram, ftbot, msg_mock = get_telegram_testobject(mocker, default_conf) + mocker.patch('freqtrade.rpc.telegram.Telegram._send_inline_msg', inline_msg_mock) + + return telegram, ftbot, msg_mock, inline_msg_mock + + def get_telegram_testobject(mocker, default_conf, mock=True, ftbot=None): msg_mock = MagicMock() if mock: @@ -902,6 +911,33 @@ def test_forcebuy_handle_exception(default_conf, update, mocker) -> None: assert msg_mock.call_args_list[0][0][0] == 'Forcebuy not enabled.' +def test_forcebuy_no_pair(default_conf, update, mocker) -> None: + mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) + + fbuy_mock = MagicMock(return_value=None) + mocker.patch('freqtrade.rpc.RPC._rpc_forcebuy', fbuy_mock) + + telegram, freqtradebot, _, inline_msg_mock = get_telegram_testobject_with_inline(mocker, + default_conf) + patch_get_signal(freqtradebot, (True, False)) + + context = MagicMock() + context.args = [] + telegram._forcebuy(update=update, context=context) + + assert fbuy_mock.call_count == 0 + assert inline_msg_mock.call_count == 1 + assert inline_msg_mock.call_args_list[0][0][0] == 'Which pair?' + assert inline_msg_mock.call_args_list[0][1]['callback_query_handler'] == 'forcebuy' + keyboard = inline_msg_mock.call_args_list[0][1]['keyboard'] + assert reduce(lambda acc, x: acc + len(x), keyboard, 0) == 4 + update = MagicMock() + update.callback_query = MagicMock() + update.callback_query.data = 'XRP/USDT' + telegram._forcebuy_inline(update, None) + assert fbuy_mock.call_count == 1 + + def test_performance_handle(default_conf, update, ticker, fee, limit_buy_order, limit_sell_order, mocker) -> None: