diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 4394b783a..1f9a9a5b0 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -668,14 +668,14 @@ class PairLock(_DECL_BASE): id = Column(Integer, primary_key=True) - pair = Column(String, nullable=False) + pair = Column(String, nullable=False, index=True) reason = Column(String, nullable=True) # Time the pair was locked (start time) lock_time = Column(DateTime, nullable=False) # Time until the pair is locked (end time) lock_end_time = Column(DateTime, nullable=False) - active = Column(Boolean, nullable=False, default=True) + active = Column(Boolean, nullable=False, default=True, index=True) def __repr__(self): lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT) @@ -696,21 +696,24 @@ class PairLock(_DECL_BASE): PairLock.session.flush() @staticmethod - def get_pair_locks(pair: str, now: Optional[datetime] = None) -> List['PairLock']: + def get_pair_locks(pair: Optional[str], now: Optional[datetime] = None) -> List['PairLock']: """ Get all locks for this pair - :param pair: Pair to check for - :param now: Datetime object (generated via datetime.utcnow()). defaults to datetime.utcnow() + :param pair: Pair to check for. Returns all current locks if pair is empty + :param now: Datetime object (generated via datetime.now(timezone.utc)). + defaults to datetime.utcnow() """ if not now: now = datetime.now(timezone.utc) + filters = [func.datetime(PairLock.lock_time) <= now, + func.datetime(PairLock.lock_end_time) >= now, + # Only active locks + PairLock.active.is_(True), ] + if pair: + filters.append(PairLock.pair == pair) return PairLock.query.filter( - PairLock.pair == pair, - func.datetime(PairLock.lock_time) <= now, - func.datetime(PairLock.lock_end_time) >= now, - # Only active locks - PairLock.active.is_(True), + *filters ).all() @staticmethod @@ -731,7 +734,8 @@ class PairLock(_DECL_BASE): def is_pair_locked(pair: str, now: Optional[datetime] = None) -> bool: """ :param pair: Pair to check for - :param now: Datetime object (generated via datetime.utcnow()). defaults to datetime.utcnow() + :param now: Datetime object (generated via datetime.now(timezone.utc)). + defaults to datetime.utcnow() """ if not now: now = datetime.now(timezone.utc) @@ -743,3 +747,12 @@ class PairLock(_DECL_BASE): # Only active locks PairLock.active.is_(True), ).scalar() is not None + + def to_json(self) -> Dict[str, Any]: + return { + 'pair': self.pair, + 'lock_time': self.lock_time, + 'lock_end_time': self.lock_end_time, + 'reason': self.reason, + 'active': self.active, + } diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 911b2d731..dbdb956b6 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -19,7 +19,7 @@ from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler from freqtrade.misc import shorten_date -from freqtrade.persistence import Trade +from freqtrade.persistence import PairLock, Trade from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.state import State from freqtrade.strategy.interface import SellType @@ -599,6 +599,17 @@ class RPC: 'total_stake': sum((trade.open_rate * trade.amount) for trade in trades) } + def _rpc_locks(self) -> Dict[str, Any]: + """ Returns the current locks""" + if self._freqtrade.state != State.RUNNING: + raise RPCException('trader is not running') + + locks = PairLock.get_pair_locks(None) + return { + 'lock_count': len(locks), + 'locks': [lock.to_json() for lock in locks] + } + def _rpc_whitelist(self) -> Dict: """ Returns the currently active whitelist""" res = {'method': self._freqtrade.pairlists.name_list, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 7a6607632..6a0fd5acd 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -15,6 +15,7 @@ from telegram.ext import CallbackContext, CommandHandler, Updater from telegram.utils.helpers import escape_markdown from freqtrade.__init__ import __version__ +from freqtrade.constants import DATETIME_PRINT_FORMAT from freqtrade.rpc import RPC, RPCException, RPCMessageType from freqtrade.rpc.fiat_convert import CryptoToFiatConverter @@ -100,6 +101,8 @@ class Telegram(RPC): CommandHandler('performance', self._performance), CommandHandler('daily', self._daily), CommandHandler('count', self._count), + CommandHandler('locks', self._locks), + CommandHandler(['reload_config', 'reload_conf'], self._reload_config), CommandHandler(['show_config', 'show_conf'], self._show_config), CommandHandler('stopbuy', self._stopbuy), @@ -608,6 +611,29 @@ class Telegram(RPC): except RPCException as e: self._send_msg(str(e)) + @authorized_only + def _locks(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /locks. + Returns the number of trades running + :param bot: telegram bot + :param update: message update + :return: None + """ + try: + locks = self._rpc_locks() + message = tabulate([[ + lock['pair'], + lock['lock_end_time'].strftime(DATETIME_PRINT_FORMAT), + lock['reason']] for lock in locks['locks']], + headers=['Pair', 'Until', 'Reason'], + tablefmt='simple') + message = "
{}
".format(message) + logger.debug(message) + self._send_msg(message, parse_mode=ParseMode.HTML) + except RPCException as e: + self._send_msg(str(e)) + @authorized_only def _whitelist(self, update: Update, context: CallbackContext) -> None: """ @@ -720,6 +746,7 @@ class Telegram(RPC): "*/performance:* `Show performance of each finished trade grouped by pair`\n" "*/daily :* `Shows profit or loss per day, over the last n days`\n" "*/count:* `Show number of trades running compared to allowed number of trades`" + "*/locks:* `Show currently locked pairs`" "\n" "*/balance:* `Show account balance per currency`\n" "*/stopbuy:* `Stops buying, but handles open trades gracefully` \n" diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 230df0df9..47d0a90c9 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -2,6 +2,7 @@ # pragma pylint: disable=protected-access, unused-argument, invalid-name # pragma pylint: disable=too-many-lines, too-many-arguments +from freqtrade.persistence.models import PairLock import re from datetime import datetime from random import choice, randint @@ -75,8 +76,8 @@ def test_telegram_init(default_conf, mocker, caplog) -> None: message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], " "['balance'], ['start'], ['stop'], ['forcesell'], ['forcebuy'], ['trades'], " - "['delete'], ['performance'], ['daily'], ['count'], ['reload_config', " - "'reload_conf'], ['show_config', 'show_conf'], ['stopbuy'], " + "['delete'], ['performance'], ['daily'], ['count'], ['locks'], " + "['reload_config', 'reload_conf'], ['show_config', 'show_conf'], ['stopbuy'], " "['whitelist'], ['blacklist'], ['logs'], ['edge'], ['help'], ['version']]") assert log_has(message_str, caplog) @@ -1024,6 +1025,43 @@ def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: assert msg in msg_mock.call_args_list[0][0][0] +def test_telegram_lock_handle(default_conf, update, ticker, fee, mocker) -> None: + msg_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.rpc.telegram.Telegram', + _init=MagicMock(), + _send_msg=msg_mock + ) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker, + get_fee=fee, + ) + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + patch_get_signal(freqtradebot, (True, False)) + telegram = Telegram(freqtradebot) + + freqtradebot.state = State.STOPPED + telegram._locks(update=update, context=MagicMock()) + assert msg_mock.call_count == 1 + assert 'not running' in msg_mock.call_args_list[0][0][0] + msg_mock.reset_mock() + freqtradebot.state = State.RUNNING + + PairLock.lock_pair('ETH/BTC', arrow.utcnow().shift(minutes=4).datetime, 'randreason') + PairLock.lock_pair('XRP/BTC', arrow.utcnow().shift(minutes=20).datetime, 'deadbeef') + + telegram._locks(update=update, context=MagicMock()) + + assert 'Pair' in msg_mock.call_args_list[0][0][0] + assert 'Until' in msg_mock.call_args_list[0][0][0] + assert 'Reason\n' in msg_mock.call_args_list[0][0][0] + assert 'ETH/BTC' in msg_mock.call_args_list[0][0][0] + assert 'XRP/BTC' in msg_mock.call_args_list[0][0][0] + assert 'deadbeef' in msg_mock.call_args_list[0][0][0] + assert 'randreason' in msg_mock.call_args_list[0][0][0] + + def test_whitelist_static(default_conf, update, mocker) -> None: msg_mock = MagicMock() mocker.patch.multiple( diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 6ac1e36a4..59b1fa31b 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -1175,10 +1175,16 @@ def test_PairLock(default_conf): # XRP/BTC should not be locked now pair = 'XRP/BTC' assert not PairLock.is_pair_locked(pair) - # Unlocking a pair that's not locked should not raise an error PairLock.unlock_pair(pair) + PairLock.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime) + assert PairLock.is_pair_locked(pair) + + # Get both locks from above + locks = PairLock.get_pair_locks(None) + assert len(locks) == 2 + # Unlock original pair pair = 'ETH/BTC' PairLock.unlock_pair(pair)