diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index 78256e0ee..def3b77f1 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -734,6 +734,23 @@ if self.dp: !!! Warning "Warning about backtesting" This method will always return up-to-date values - so usage during backtesting / hyperopt will lead to wrong results. +### Send Notification + +The dataprovider `.send_msg()` function allows you to send custom notifications from your strategy. +Identical notifications will only be sent once per candle, unless the 2nd argument (`always_send`) is set to True. + +``` python + self.dp.send_msg(f"{metadata['pair']} just got hot!") + + # Force send this notification, avoid caching (Please read warning below!) + self.dp.send_msg(f"{metadata['pair']} just got hot!", always_send=True) +``` + +Notifications will only be sent in trading modes (Live/Dry-run) - so this method can be called without conditions for backtesting. + +!!! Warning "Spamming" + You can spam yourself pretty good by setting `always_send=True` in this method. Use this with great care and only in conditions you know will not happen throughout a candle to avoid a message every 5 seconds. + ### Complete Data-provider sample ```python diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 9853e15c6..a690e18b9 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -98,6 +98,7 @@ Example configuration showing the different settings: "exit_fill": "off", "protection_trigger": "off", "protection_trigger_global": "on", + "strategy_msg": "off", "show_candle": "off" }, "reload": true, @@ -109,7 +110,8 @@ Example configuration showing the different settings: `exit` notifications are sent when the order is placed, while `exit_fill` notifications are sent when the order is filled on the exchange. `*_fill` notifications are off by default and must be explicitly enabled. `protection_trigger` notifications are sent when a protection triggers and `protection_trigger_global` notifications trigger when global protections are triggered. -`show_candle` - show candle values as part of entry/exit messages. Only possible value is "ohlc". +`strategy_msg` - Receive notifications from the strategy, sent via `self.dp.send_msg()` from the strategy [more details](strategy-customization.md#send-notification). +`show_candle` - show candle values as part of entry/exit messages. Only possible values are `"ohlc"` or `"off"`. `balance_dust_level` will define what the `/balance` command takes as "dust" - Currencies with a balance below this will be shown. `reload` allows you to disable reload-buttons on selected messages. diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 6d74ceafd..1d83d21a0 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -317,6 +317,10 @@ CONF_SCHEMA = { 'type': 'string', 'enum': ['off', 'ohlc'], }, + 'strategy_msg': { + 'type': 'string', + 'enum': TELEGRAM_SETTING_OPTIONS, + }, } }, 'reload': {'type': 'boolean'}, diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index b9b118c00..e21f10193 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -5,12 +5,14 @@ including ticker and orderbook data, live and historical candle (OHLCV) data Common Interface for bot and strategy to access data. """ import logging +from collections import deque from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple from pandas import DataFrame from freqtrade.configuration import TimeRange +from freqtrade.configuration.PeriodicCache import PeriodicCache from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe from freqtrade.data.history import load_pair_history from freqtrade.enums import CandleType, RunMode @@ -33,6 +35,10 @@ class DataProvider: self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} self.__slice_index: Optional[int] = None self.__cached_pairs_backtesting: Dict[PairWithTimeframe, DataFrame] = {} + self._msg_queue: deque = deque() + + self.__msg_cache = PeriodicCache( + maxsize=1000, ttl=timeframe_to_seconds(self._config.get('timeframe', '1h'))) def _set_dataframe_max_index(self, limit_index: int): """ @@ -265,3 +271,20 @@ class DataProvider: if self._exchange is None: raise OperationalException(NO_EXCHANGE_EXCEPTION) return self._exchange.fetch_l2_order_book(pair, maximum) + + def send_msg(self, message: str, *, always_send: bool = False) -> None: + """ + Send custom RPC Notifications from your bot. + Will not send any bot in modes other than Dry-run or Live. + :param message: Message to be sent. Must be below 4096. + :param always_send: If False, will send the message only once per candle, and surpress + identical messages. + Careful as this can end up spaming your chat. + Defaults to False + """ + if self.runmode not in (RunMode.DRY_RUN, RunMode.LIVE): + return + + if always_send or message not in self.__msg_cache: + self._msg_queue.append(message) + self.__msg_cache[message] = True diff --git a/freqtrade/enums/rpcmessagetype.py b/freqtrade/enums/rpcmessagetype.py index 584a011c2..415d8f18c 100644 --- a/freqtrade/enums/rpcmessagetype.py +++ b/freqtrade/enums/rpcmessagetype.py @@ -17,6 +17,8 @@ class RPCMessageType(Enum): PROTECTION_TRIGGER = 'protection_trigger' PROTECTION_TRIGGER_GLOBAL = 'protection_trigger_global' + STRATEGY_MSG = 'strategy_msg' + def __repr__(self): return self.value diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 43608cae7..9ea195c45 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -214,6 +214,7 @@ class FreqtradeBot(LoggingMixin): if self.trading_mode == TradingMode.FUTURES: self._schedule.run_pending() Trade.commit() + self.rpc.process_msg_queue(self.dataprovider._msg_queue) self.last_process = datetime.now(timezone.utc) def process_stopped(self) -> None: diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 66e84029f..3ccf23228 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -2,6 +2,7 @@ This module contains class to manage RPC communications (Telegram, API, ...) """ import logging +from collections import deque from typing import Any, Dict, List from freqtrade.enums import RPCMessageType @@ -77,6 +78,17 @@ class RPCManager: except NotImplementedError: logger.error(f"Message type '{msg['type']}' not implemented by handler {mod.name}.") + def process_msg_queue(self, queue: deque) -> None: + """ + Process all messages in the queue. + """ + while queue: + msg = queue.popleft() + self.send_msg({ + 'type': RPCMessageType.STRATEGY_MSG, + 'msg': msg, + }) + def startup_messages(self, config: Dict[str, Any], pairlist, protections) -> None: if config['dry_run']: self.send_msg({ diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 2aff1d210..121324d90 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -376,7 +376,8 @@ class Telegram(RPCHandler): elif msg_type == RPCMessageType.STARTUP: message = f"{msg['status']}" - + elif msg_type == RPCMessageType.STRATEGY_MSG: + message = f"{msg['msg']}" else: raise NotImplementedError(f"Unknown message type: {msg_type}") return message diff --git a/tests/data/test_dataprovider.py b/tests/data/test_dataprovider.py index 93f82de5d..49603feac 100644 --- a/tests/data/test_dataprovider.py +++ b/tests/data/test_dataprovider.py @@ -311,3 +311,27 @@ def test_no_exchange_mode(default_conf): with pytest.raises(OperationalException, match=message): dp.available_pairs() + + +def test_dp_send_msg(default_conf): + + default_conf["runmode"] = RunMode.DRY_RUN + + default_conf["timeframe"] = '1h' + dp = DataProvider(default_conf, None) + msg = 'Test message' + dp.send_msg(msg) + + assert msg in dp._msg_queue + dp._msg_queue.pop() + assert msg not in dp._msg_queue + # Message is not resent due to caching + dp.send_msg(msg) + assert msg not in dp._msg_queue + dp.send_msg(msg, always_send=True) + assert msg in dp._msg_queue + + default_conf["runmode"] = RunMode.BACKTEST + dp = DataProvider(default_conf, None) + dp.send_msg(msg, always_send=True) + assert msg not in dp._msg_queue diff --git a/tests/rpc/test_rpc_manager.py b/tests/rpc/test_rpc_manager.py index 596b5ae20..b9ae16a20 100644 --- a/tests/rpc/test_rpc_manager.py +++ b/tests/rpc/test_rpc_manager.py @@ -1,6 +1,7 @@ # pragma pylint: disable=missing-docstring, C0103 import logging import time +from collections import deque from unittest.mock import MagicMock from freqtrade.enums import RPCMessageType @@ -81,9 +82,25 @@ def test_send_msg_telegram_disabled(mocker, default_conf, caplog) -> None: assert telegram_mock.call_count == 0 +def test_process_msg_queue(mocker, default_conf, caplog) -> None: + telegram_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg') + mocker.patch('freqtrade.rpc.telegram.Telegram._init') + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + rpc_manager = RPCManager(freqtradebot) + queue = deque() + queue.append('Test message') + queue.append('Test message 2') + rpc_manager.process_msg_queue(queue) + + assert log_has("Sending rpc message: {'type': strategy_msg, 'msg': 'Test message'}", caplog) + assert log_has("Sending rpc message: {'type': strategy_msg, 'msg': 'Test message 2'}", caplog) + assert telegram_mock.call_count == 2 + + def test_send_msg_telegram_enabled(mocker, default_conf, caplog) -> None: - telegram_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg', MagicMock()) - mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) + telegram_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg') + mocker.patch('freqtrade.rpc.telegram.Telegram._init') freqtradebot = get_patched_freqtradebot(mocker, default_conf) rpc_manager = RPCManager(freqtradebot) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index f69b7e878..8d244f3fd 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -1994,6 +1994,16 @@ def test_startup_notification(default_conf, mocker) -> None: assert msg_mock.call_args[0][0] == '*Custom:* `Hello World`' +def test_send_msg_strategy_msg_notification(default_conf, mocker) -> None: + + telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf) + telegram.send_msg({ + 'type': RPCMessageType.STRATEGY_MSG, + 'msg': 'hello world, Test msg' + }) + assert msg_mock.call_args[0][0] == 'hello world, Test msg' + + def test_send_msg_unknown_type(default_conf, mocker) -> None: telegram, _, _ = get_telegram_testobject(mocker, default_conf) with pytest.raises(NotImplementedError, match=r'Unknown message type: None'): diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 66cbd7d9b..438a2704c 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -68,6 +68,12 @@ def test_process_stopped(mocker, default_conf_usdt) -> None: assert coo_mock.call_count == 1 +def test_process_calls_sendmsg(mocker, default_conf_usdt) -> None: + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + freqtrade.process() + assert freqtrade.rpc.process_msg_queue.call_count == 1 + + def test_bot_cleanup(mocker, default_conf_usdt, caplog) -> None: mock_cleanup = mocker.patch('freqtrade.freqtradebot.cleanup_db') coo_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.cancel_all_open_orders')