diff --git a/docs/rest-api.md b/docs/rest-api.md index 44f0b07cf..7726ab875 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -109,24 +109,25 @@ python3 scripts/rest_client.py --config rest_config.json [optional par | Command | Description | |----------|-------------| | `ping` | Simple command testing the API Readiness - requires no authentication. -| `start` | Starts the trader -| `stop` | Stops the trader +| `start` | Starts the trader. +| `stop` | Stops the trader. | `stopbuy` | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. -| `reload_config` | Reloads the configuration file +| `reload_config` | Reloads the configuration file. | `trades` | List last trades. | `delete_trade ` | Remove trade from the database. Tries to close open orders. Requires manual handling of this trade on the exchange. -| `show_config` | Shows part of the current configuration with relevant settings to operation -| `logs` | Shows last log messages -| `status` | Lists all open trades -| `count` | Displays number of trades used and available -| `profit` | Display a summary of your profit/loss from close trades and some stats about your performance +| `show_config` | Shows part of the current configuration with relevant settings to operation. +| `logs` | Shows last log messages. +| `status` | Lists all open trades. +| `count` | Displays number of trades used and available. +| `locks` | Displays currently locked pairs. +| `profit` | Display a summary of your profit/loss from close trades and some stats about your performance. | `forcesell ` | Instantly sells the given trade (Ignoring `minimum_roi`). | `forcesell all` | Instantly sells all open trades (Ignoring `minimum_roi`). | `forcebuy [rate]` | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True) -| `performance` | Show performance of each finished trade grouped by pair -| `balance` | Show account balance per currency -| `daily ` | Shows profit or loss per day, over the last n days (n defaults to 7) -| `whitelist` | Show the current whitelist +| `performance` | Show performance of each finished trade grouped by pair. +| `balance` | Show account balance per currency. +| `daily ` | Shows profit or loss per day, over the last n days (n defaults to 7). +| `whitelist` | Show the current whitelist. | `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. | `edge` | Show validated pairs by Edge if it is enabled. | `pair_candles` | Returns dataframe for a pair / timeframe combination while the bot is running. **Alpha** @@ -135,7 +136,7 @@ python3 scripts/rest_client.py --config rest_config.json [optional par | `strategies` | List strategies in strategy directory. **Alpha** | `strategy ` | Get specific Strategy content. **Alpha** | `available_pairs` | List available backtest data. **Alpha** -| `version` | Show version +| `version` | Show version. !!! Warning "Alpha status" Endpoints labeled with *Alpha status* above may change at any time without notice. diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index a6cdef864..c0506203f 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -693,15 +693,15 @@ Locked pairs will show the message `Pair is currently locked.`. Sometimes it may be desired to lock a pair after certain events happen (e.g. multiple losing trades in a row). -Freqtrade has an easy method to do this from within the strategy, by calling `self.lock_pair(pair, until)`. -`until` must be a datetime object in the future, after which trading will be reenabled for that pair. +Freqtrade has an easy method to do this from within the strategy, by calling `self.lock_pair(pair, until, [reason])`. +`until` must be a datetime object in the future, after which trading will be re-enabled for that pair, while `reason` is an optional string detailing why the pair was locked. Locks can also be lifted manually, by calling `self.unlock_pair(pair)`. To verify if a pair is currently locked, use `self.is_pair_locked(pair)`. !!! Note - Locked pairs are not persisted, so a restart of the bot, or calling `/reload_config` will reset locked pairs. + Locked pairs will always be rounded up to the next candle. So assuming a `5m` timeframe, a lock with `until` set to 10:18 will lock the pair until the candle from 10:15-10:20 will be finished. !!! Warning Locking pairs is not functioning during backtesting. diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index cfc68a3ec..6112a599e 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -937,8 +937,8 @@ class FreqtradeBot: self.update_trade_state(trade, trade.stoploss_order_id, stoploss_order, stoploss_order=True) # Lock pair for one candle to prevent immediate rebuys - self.strategy.lock_pair(trade.pair, - timeframe_to_next_date(self.config['timeframe'])) + self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['timeframe']), + reason='Auto lock') self._notify_sell(trade, "stoploss") return True @@ -1264,7 +1264,8 @@ class FreqtradeBot: Trade.session.flush() # Lock pair for one candle to prevent immediate rebuys - self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['timeframe'])) + self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['timeframe']), + reason='Auto lock') self._notify_sell(trade, order_type) diff --git a/freqtrade/persistence/__init__.py b/freqtrade/persistence/__init__.py index a3ec13e98..e184e7d9a 100644 --- a/freqtrade/persistence/__init__.py +++ b/freqtrade/persistence/__init__.py @@ -1,3 +1,4 @@ # flake8: noqa: F401 -from freqtrade.persistence.models import Order, Trade, clean_dry_run_db, cleanup_db, init_db +from freqtrade.persistence.models import (Order, PairLock, Trade, clean_dry_run_db, cleanup_db, + init_db) diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 93b39860a..477a94bad 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -17,6 +17,7 @@ from sqlalchemy.orm.session import sessionmaker from sqlalchemy.pool import StaticPool from sqlalchemy.sql.schema import UniqueConstraint +from freqtrade.constants import DATETIME_PRINT_FORMAT from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.misc import safe_value_fallback from freqtrade.persistence.migrations import check_migrate @@ -63,6 +64,9 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None: # Copy session attributes to order object too Order.session = Trade.session Order.query = Order.session.query_property() + PairLock.session = Trade.session + PairLock.query = PairLock.session.query_property() + previous_tables = inspect(engine).get_table_names() _DECL_BASE.metadata.create_all(engine) check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables) @@ -251,7 +255,7 @@ class Trade(_DECL_BASE): self.recalc_open_trade_price() def __repr__(self): - open_since = self.open_date.strftime('%Y-%m-%d %H:%M:%S') if self.is_open else 'closed' + open_since = self.open_date.strftime(DATETIME_PRINT_FORMAT) if self.is_open else 'closed' return (f'Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, ' f'open_rate={self.open_rate:.8f}, open_since={open_since})') @@ -277,7 +281,7 @@ class Trade(_DECL_BASE): 'fee_close_currency': self.fee_close_currency, 'open_date_hum': arrow.get(self.open_date).humanize(), - 'open_date': self.open_date.strftime("%Y-%m-%d %H:%M:%S"), + 'open_date': self.open_date.strftime(DATETIME_PRINT_FORMAT), 'open_timestamp': int(self.open_date.replace(tzinfo=timezone.utc).timestamp() * 1000), 'open_rate': self.open_rate, 'open_rate_requested': self.open_rate_requested, @@ -285,7 +289,7 @@ class Trade(_DECL_BASE): 'close_date_hum': (arrow.get(self.close_date).humanize() if self.close_date else None), - 'close_date': (self.close_date.strftime("%Y-%m-%d %H:%M:%S") + 'close_date': (self.close_date.strftime(DATETIME_PRINT_FORMAT) if self.close_date else None), 'close_timestamp': int(self.close_date.replace( tzinfo=timezone.utc).timestamp() * 1000) if self.close_date else None, @@ -301,7 +305,7 @@ class Trade(_DECL_BASE): 'stop_loss_ratio': self.stop_loss_pct if self.stop_loss_pct else None, 'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None, 'stoploss_order_id': self.stoploss_order_id, - 'stoploss_last_update': (self.stoploss_last_update.strftime("%Y-%m-%d %H:%M:%S") + 'stoploss_last_update': (self.stoploss_last_update.strftime(DATETIME_PRINT_FORMAT) if self.stoploss_last_update else None), 'stoploss_last_update_timestamp': int(self.stoploss_last_update.replace( tzinfo=timezone.utc).timestamp() * 1000) if self.stoploss_last_update else None, @@ -654,3 +658,105 @@ class Trade(_DECL_BASE): trade.stop_loss = None trade.adjust_stop_loss(trade.open_rate, desired_stoploss) logger.info(f"New stoploss: {trade.stop_loss}.") + + +class PairLock(_DECL_BASE): + """ + Pair Locks database model. + """ + __tablename__ = 'pairlocks' + + id = Column(Integer, primary_key=True) + + 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, index=True) + + active = Column(Boolean, nullable=False, default=True, index=True) + + def __repr__(self): + lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT) + lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT) + return (f'PairLock(id={self.id}, pair={self.pair}, lock_time={lock_time}, ' + f'lock_end_time={lock_end_time})') + + @staticmethod + def lock_pair(pair: str, until: datetime, reason: str = None) -> None: + lock = PairLock( + pair=pair, + lock_time=datetime.now(timezone.utc), + lock_end_time=until, + reason=reason, + active=True + ) + PairLock.session.add(lock) + PairLock.session.flush() + + @staticmethod + 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. 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_end_time) >= now, + # Only active locks + PairLock.active.is_(True), ] + if pair: + filters.append(PairLock.pair == pair) + return PairLock.query.filter( + *filters + ).all() + + @staticmethod + def unlock_pair(pair: str, now: Optional[datetime] = None) -> None: + """ + Release all locks for this pair. + :param pair: Pair to unlock + :param now: Datetime object (generated via datetime.now(timezone.utc)). + defaults to datetime.utcnow() + """ + if not now: + now = datetime.now(timezone.utc) + + logger.info(f"Releasing all locks for {pair}.") + locks = PairLock.get_pair_locks(pair, now) + for lock in locks: + lock.active = False + PairLock.session.flush() + + @staticmethod + def is_pair_locked(pair: str, now: Optional[datetime] = None) -> bool: + """ + :param pair: Pair to check for + :param now: Datetime object (generated via datetime.now(timezone.utc)). + defaults to datetime.utcnow() + """ + if not now: + now = datetime.now(timezone.utc) + + return PairLock.query.filter( + PairLock.pair == pair, + func.datetime(PairLock.lock_end_time) >= now, + # 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.strftime(DATETIME_PRINT_FORMAT), + 'lock_timestamp': int(self.lock_time.replace(tzinfo=timezone.utc).timestamp() * 1000), + 'lock_end_time': self.lock_end_time.strftime(DATETIME_PRINT_FORMAT), + 'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc + ).timestamp() * 1000), + 'reason': self.reason, + 'active': self.active, + } diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index f31d7b0b5..be21179ad 100644 --- a/freqtrade/rpc/api_server.py +++ b/freqtrade/rpc/api_server.py @@ -192,6 +192,7 @@ class ApiServer(RPC): self.app.add_url_rule(f'{BASE_URI}/balance', 'balance', view_func=self._balance, methods=['GET']) self.app.add_url_rule(f'{BASE_URI}/count', 'count', view_func=self._count, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/locks', 'locks', view_func=self._locks, methods=['GET']) self.app.add_url_rule(f'{BASE_URI}/daily', 'daily', view_func=self._daily, methods=['GET']) self.app.add_url_rule(f'{BASE_URI}/edge', 'edge', view_func=self._edge, methods=['GET']) self.app.add_url_rule(f'{BASE_URI}/logs', 'log', view_func=self._get_logs, methods=['GET']) @@ -350,6 +351,15 @@ class ApiServer(RPC): msg = self._rpc_count() return jsonify(msg) + @require_login + @rpc_catch_errors + def _locks(self): + """ + Handler for /locks. + Returns the currently active locks. + """ + return jsonify(self._rpc_locks()) + @require_login @rpc_catch_errors def _daily(self): diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 911b2d731..de8bcaefb 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -13,13 +13,13 @@ from numpy import NAN, int64, mean from pandas import DataFrame from freqtrade.configuration.timerange import TimeRange -from freqtrade.constants import CANCEL_REASON +from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT from freqtrade.data.history import load_data 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, @@ -638,7 +649,7 @@ class RPC: buffer = bufferHandler.buffer[-limit:] else: buffer = bufferHandler.buffer - records = [[datetime.fromtimestamp(r.created).strftime("%Y-%m-%d %H:%M:%S"), + records = [[datetime.fromtimestamp(r.created).strftime(DATETIME_PRINT_FORMAT), r.created * 1000, r.name, r.levelname, r.message + ('\n' + r.exc_text if r.exc_text else '')] for r in buffer] diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 7a6607632..3dcb7ab72 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -100,6 +100,7 @@ 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 +609,26 @@ 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 currently active locks + """ + try: + locks = self._rpc_locks() + message = tabulate([[ + lock['pair'], + lock['lock_end_time'], + 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: """ @@ -719,8 +740,8 @@ class Telegram(RPC): "*/delete :* `Instantly delete the given trade in the database`\n" "*/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`" - "\n" + "*/count:* `Show number of active trades compared to allowed number of trades`\n" + "*/locks:* `Show currently locked pairs`\n" "*/balance:* `Show account balance per currency`\n" "*/stopbuy:* `Stops buying, but handles open trades gracefully` \n" "*/reload_config:* `Reload configuration file` \n" diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index b6b36b1a4..e6256cafb 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -17,7 +17,7 @@ from freqtrade.data.dataprovider import DataProvider from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes from freqtrade.exchange.exchange import timeframe_to_next_date -from freqtrade.persistence import Trade +from freqtrade.persistence import PairLock, Trade from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.wallets import Wallets @@ -133,7 +133,6 @@ class IStrategy(ABC): self.config = config # Dict to determine if analysis is necessary self._last_candle_seen_per_pair: Dict[str, datetime] = {} - self._pair_locked_until: Dict[str, datetime] = {} @abstractmethod def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: @@ -278,7 +277,7 @@ class IStrategy(ABC): """ return self.__class__.__name__ - def lock_pair(self, pair: str, until: datetime) -> None: + def lock_pair(self, pair: str, until: datetime, reason: str = None) -> None: """ Locks pair until a given timestamp happens. Locked pairs are not analyzed, and are prevented from opening new trades. @@ -287,9 +286,9 @@ class IStrategy(ABC): :param pair: Pair to lock :param until: datetime in UTC until the pair should be blocked from opening new trades. Needs to be timezone aware `datetime.now(timezone.utc)` + :param reason: Optional string explaining why the pair was locked. """ - if pair not in self._pair_locked_until or self._pair_locked_until[pair] < until: - self._pair_locked_until[pair] = until + PairLock.lock_pair(pair, until, reason) def unlock_pair(self, pair: str) -> None: """ @@ -298,8 +297,7 @@ class IStrategy(ABC): manually from within the strategy, to allow an easy way to unlock pairs. :param pair: Unlock pair to allow trading again """ - if pair in self._pair_locked_until: - del self._pair_locked_until[pair] + PairLock.unlock_pair(pair, datetime.now(timezone.utc)) def is_pair_locked(self, pair: str, candle_date: datetime = None) -> bool: """ @@ -311,15 +309,13 @@ class IStrategy(ABC): :param candle_date: Date of the last candle. Optional, defaults to current date :returns: locking state of the pair in question. """ - if pair not in self._pair_locked_until: - return False + if not candle_date: - return self._pair_locked_until[pair] >= datetime.now(timezone.utc) + # Simple call ... + return PairLock.is_pair_locked(pair, candle_date) else: - # Locking should happen until a new candle arrives lock_time = timeframe_to_next_date(self.timeframe, candle_date) - # lock_time = candle_date + timedelta(minutes=timeframe_to_minutes(self.timeframe)) - return self._pair_locked_until[pair] > lock_time + return PairLock.is_pair_locked(pair, lock_time) def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ diff --git a/scripts/rest_client.py b/scripts/rest_client.py index 46966d447..268e81397 100755 --- a/scripts/rest_client.py +++ b/scripts/rest_client.py @@ -111,6 +111,13 @@ class FtRestClient(): """ return self._get("count") + def locks(self): + """Return current locks + + :return: json object + """ + return self._get("locks") + def daily(self, days=None): """Return the amount of open trades. diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index d0e5d3c37..34e959875 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -2,7 +2,7 @@ Unit test file for rpc/api_server.py """ -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from unittest.mock import ANY, MagicMock, PropertyMock @@ -12,7 +12,7 @@ from requests.auth import _basic_auth_str from freqtrade.__init__ import __version__ from freqtrade.loggers import setup_logging, setup_logging_pre -from freqtrade.persistence import Trade +from freqtrade.persistence import PairLock, Trade from freqtrade.rpc.api_server import BASE_URI, ApiServer from freqtrade.state import State from tests.conftest import create_mock_trades, get_patched_freqtradebot, log_has, patch_get_signal @@ -328,6 +328,30 @@ def test_api_count(botclient, mocker, ticker, fee, markets): assert rc.json["max"] == 1.0 +def test_api_locks(botclient): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/locks") + assert_response(rc) + + assert 'locks' in rc.json + + assert rc.json['lock_count'] == 0 + assert rc.json['lock_count'] == len(rc.json['locks']) + + PairLock.lock_pair('ETH/BTC', datetime.utcnow() + timedelta(minutes=4), 'randreason') + PairLock.lock_pair('XRP/BTC', datetime.utcnow() + timedelta(minutes=20), 'deadbeef') + + rc = client_get(client, f"{BASE_URI}/locks") + assert_response(rc) + + assert rc.json['lock_count'] == 2 + assert rc.json['lock_count'] == len(rc.json['locks']) + assert 'ETH/BTC' in (rc.json['locks'][0]['pair'], rc.json['locks'][1]['pair']) + assert 'randreason' in (rc.json['locks'][0]['reason'], rc.json['locks'][1]['reason']) + assert 'deadbeef' in (rc.json['locks'][0]['reason'], rc.json['locks'][1]['reason']) + + def test_api_show_config(botclient, mocker): ftbot, client = botclient patch_get_signal(ftbot, (True, False)) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 230df0df9..c412313ad 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -18,7 +18,7 @@ from freqtrade.constants import CANCEL_REASON from freqtrade.edge import PairInfo from freqtrade.freqtradebot import FreqtradeBot from freqtrade.loggers import setup_logging -from freqtrade.persistence import Trade +from freqtrade.persistence import PairLock, Trade from freqtrade.rpc import RPCMessageType from freqtrade.rpc.telegram import Telegram, authorized_only from freqtrade.state import State @@ -75,8 +75,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 +1024,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/strategy/test_interface.py b/tests/strategy/test_interface.py index 729b14f7b..dc5cd47e7 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -1,5 +1,4 @@ # pragma pylint: disable=missing-docstring, C0103 - import logging from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock @@ -12,7 +11,7 @@ from freqtrade.configuration import TimeRange from freqtrade.data.dataprovider import DataProvider from freqtrade.data.history import load_data from freqtrade.exceptions import StrategyError -from freqtrade.persistence import Trade +from freqtrade.persistence import PairLock, Trade from freqtrade.resolvers import StrategyResolver from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from tests.conftest import log_has, log_has_re @@ -360,11 +359,12 @@ def test__analyze_ticker_internal_skip_analyze(ohlcv_history, mocker, caplog) -> assert log_has('Skipping TA Analysis for already analyzed candle', caplog) +@pytest.mark.usefixtures("init_persistence") def test_is_pair_locked(default_conf): default_conf.update({'strategy': 'DefaultStrategy'}) strategy = StrategyResolver.load_strategy(default_conf) - # dict should be empty - assert not strategy._pair_locked_until + # No lock should be present + assert len(PairLock.query.all()) == 0 pair = 'ETH/BTC' assert not strategy.is_pair_locked(pair) @@ -372,11 +372,6 @@ def test_is_pair_locked(default_conf): # ETH/BTC locked for 4 minutes assert strategy.is_pair_locked(pair) - # Test lock does not change - lock = strategy._pair_locked_until[pair] - strategy.lock_pair(pair, arrow.utcnow().shift(minutes=2).datetime) - assert lock == strategy._pair_locked_until[pair] - # XRP/BTC should not be locked now pair = 'XRP/BTC' assert not strategy.is_pair_locked(pair) @@ -393,7 +388,7 @@ def test_is_pair_locked(default_conf): # Lock until 14:30 lock_time = datetime(2020, 5, 1, 14, 30, 0, tzinfo=timezone.utc) strategy.lock_pair(pair, lock_time) - # Lock is in the past ... + assert not strategy.is_pair_locked(pair) # latest candle is from 14:20, lock goes to 14:30 assert strategy.is_pair_locked(pair, lock_time + timedelta(minutes=-10)) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index bb7ff26e7..2a1b0c3cc 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -15,7 +15,7 @@ from freqtrade.exceptions import (DependencyException, ExchangeError, Insufficie InvalidOrderException, OperationalException, PricingError, TemporaryError) from freqtrade.freqtradebot import FreqtradeBot -from freqtrade.persistence import Order, Trade +from freqtrade.persistence import Order, PairLock, Trade from freqtrade.rpc import RPCMessageType from freqtrade.state import RunMode, State from freqtrade.strategy.interface import SellCheckTuple, SellType @@ -2799,6 +2799,7 @@ def test_execute_sell_sloe_cancel_exception(mocker, default_conf, ticker, fee, c trade = Trade.query.first() Trade.session = MagicMock() + PairLock.session = MagicMock() freqtrade.config['dry_run'] = False trade.stoploss_order_id = "abcd" @@ -3249,7 +3250,6 @@ def test_locked_pairs(default_conf, ticker, fee, ticker_sell_down, mocker, caplo freqtrade.execute_sell(trade=trade, limit=ticker_sell_down()['bid'], sell_reason=SellType.STOP_LOSS) trade.close(ticker_sell_down()['bid']) - assert trade.pair in freqtrade.strategy._pair_locked_until assert freqtrade.strategy.is_pair_locked(trade.pair) # reinit - should buy other pair. diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 4216565ac..243da3396 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -1,5 +1,6 @@ # pragma pylint: disable=missing-docstring, C0103 import logging +from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock import arrow @@ -8,7 +9,7 @@ from sqlalchemy import create_engine from freqtrade import constants from freqtrade.exceptions import DependencyException, OperationalException -from freqtrade.persistence import Order, Trade, clean_dry_run_db, init_db +from freqtrade.persistence import Order, PairLock, Trade, clean_dry_run_db, init_db from tests.conftest import create_mock_trades, log_has, log_has_re @@ -1158,3 +1159,49 @@ def test_select_order(fee): assert order.ft_order_side == 'stoploss' order = trades[4].select_order('sell', False) assert order is None + + +@pytest.mark.usefixtures("init_persistence") +def test_PairLock(default_conf): + # No lock should be present + assert len(PairLock.query.all()) == 0 + + pair = 'ETH/BTC' + assert not PairLock.is_pair_locked(pair) + PairLock.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime) + # ETH/BTC locked for 4 minutes + assert PairLock.is_pair_locked(pair) + + # 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) + assert not PairLock.is_pair_locked(pair) + + pair = 'BTC/USDT' + # Lock until 14:30 + lock_time = datetime(2020, 5, 1, 14, 30, 0, tzinfo=timezone.utc) + PairLock.lock_pair(pair, lock_time) + + assert not PairLock.is_pair_locked(pair) + assert PairLock.is_pair_locked(pair, lock_time + timedelta(minutes=-10)) + assert PairLock.is_pair_locked(pair, lock_time + timedelta(minutes=-50)) + + # Should not be locked after time expired + assert not PairLock.is_pair_locked(pair, lock_time + timedelta(minutes=10)) + + locks = PairLock.get_pair_locks(pair, lock_time + timedelta(minutes=-2)) + assert len(locks) == 1 + assert 'PairLock' in str(locks[0])