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..e004ed51c 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_1_candle') 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_1_candle') 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 03133107a..b2f8f4274 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -64,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) @@ -655,3 +658,76 @@ 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__ = 'pair_lock' + + id = Column(Integer, primary_key=True) + + pair = Column(String, nullable=False) + 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) + + def __repr__(self): + lock_time = self.open_date.strftime(DATETIME_PRINT_FORMAT) + lock_end_time = self.open_date.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 get_pair_locks(pair: 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() + """ + if not now: + now = datetime.now(timezone.utc) + + 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), + ).all() + + @staticmethod + def unlock_pair(pair: str, now: Optional[datetime] = None) -> None: + """ + Release all locks for this pair. + """ + 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.utcnow()). defaults to datetime.utcnow() + """ + if not now: + now = datetime.now(timezone.utc) + + 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), + ).scalar() is not None diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index b6b36b1a4..d9485e27a 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. @@ -288,8 +287,15 @@ class IStrategy(ABC): :param until: datetime in UTC until the pair should be blocked from opening new trades. Needs to be timezone aware `datetime.now(timezone.utc)` """ - if pair not in self._pair_locked_until or self._pair_locked_until[pair] < until: - self._pair_locked_until[pair] = until + 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() def unlock_pair(self, pair: str) -> None: """ @@ -298,8 +304,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 +316,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/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index 729b14f7b..e9d9bcc75 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,10 @@ 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 ... + # Lock is in the past, so we must fake the lock + lock = PairLock.query.filter(PairLock.pair == pair).first() + lock.lock_time = lock_time - timedelta(hours=2) + 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.