Persist pairlocks

closes #3034
This commit is contained in:
Matthias 2020-10-17 11:28:34 +02:00
parent 6eab20e337
commit e513871fd5
7 changed files with 111 additions and 32 deletions

View File

@ -693,15 +693,15 @@ Locked pairs will show the message `Pair <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). 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)`. 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 reenabled for that pair. `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)`. 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)`. To verify if a pair is currently locked, use `self.is_pair_locked(pair)`.
!!! Note !!! 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 !!! Warning
Locking pairs is not functioning during backtesting. Locking pairs is not functioning during backtesting.

View File

@ -937,8 +937,8 @@ class FreqtradeBot:
self.update_trade_state(trade, trade.stoploss_order_id, stoploss_order, self.update_trade_state(trade, trade.stoploss_order_id, stoploss_order,
stoploss_order=True) stoploss_order=True)
# Lock pair for one candle to prevent immediate rebuys # Lock pair for one candle to prevent immediate rebuys
self.strategy.lock_pair(trade.pair, self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['timeframe']),
timeframe_to_next_date(self.config['timeframe'])) reason='auto_lock_1_candle')
self._notify_sell(trade, "stoploss") self._notify_sell(trade, "stoploss")
return True return True
@ -1264,7 +1264,8 @@ class FreqtradeBot:
Trade.session.flush() Trade.session.flush()
# Lock pair for one candle to prevent immediate rebuys # 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) self._notify_sell(trade, order_type)

View File

@ -1,3 +1,4 @@
# flake8: noqa: F401 # 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)

View File

@ -64,6 +64,9 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None:
# Copy session attributes to order object too # Copy session attributes to order object too
Order.session = Trade.session Order.session = Trade.session
Order.query = Order.session.query_property() Order.query = Order.session.query_property()
PairLock.session = Trade.session
PairLock.query = PairLock.session.query_property()
previous_tables = inspect(engine).get_table_names() previous_tables = inspect(engine).get_table_names()
_DECL_BASE.metadata.create_all(engine) _DECL_BASE.metadata.create_all(engine)
check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables) check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables)
@ -655,3 +658,76 @@ class Trade(_DECL_BASE):
trade.stop_loss = None trade.stop_loss = None
trade.adjust_stop_loss(trade.open_rate, desired_stoploss) trade.adjust_stop_loss(trade.open_rate, desired_stoploss)
logger.info(f"New stoploss: {trade.stop_loss}.") 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

View File

@ -17,7 +17,7 @@ from freqtrade.data.dataprovider import DataProvider
from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exceptions import OperationalException, StrategyError
from freqtrade.exchange import timeframe_to_minutes from freqtrade.exchange import timeframe_to_minutes
from freqtrade.exchange.exchange import timeframe_to_next_date 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.strategy.strategy_wrapper import strategy_safe_wrapper
from freqtrade.wallets import Wallets from freqtrade.wallets import Wallets
@ -133,7 +133,6 @@ class IStrategy(ABC):
self.config = config self.config = config
# Dict to determine if analysis is necessary # Dict to determine if analysis is necessary
self._last_candle_seen_per_pair: Dict[str, datetime] = {} self._last_candle_seen_per_pair: Dict[str, datetime] = {}
self._pair_locked_until: Dict[str, datetime] = {}
@abstractmethod @abstractmethod
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
@ -278,7 +277,7 @@ class IStrategy(ABC):
""" """
return self.__class__.__name__ 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. Locks pair until a given timestamp happens.
Locked pairs are not analyzed, and are prevented from opening new trades. 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. :param until: datetime in UTC until the pair should be blocked from opening new trades.
Needs to be timezone aware `datetime.now(timezone.utc)` Needs to be timezone aware `datetime.now(timezone.utc)`
""" """
if pair not in self._pair_locked_until or self._pair_locked_until[pair] < until: lock = PairLock(
self._pair_locked_until[pair] = until 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: 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. manually from within the strategy, to allow an easy way to unlock pairs.
:param pair: Unlock pair to allow trading again :param pair: Unlock pair to allow trading again
""" """
if pair in self._pair_locked_until: PairLock.unlock_pair(pair, datetime.now(timezone.utc))
del self._pair_locked_until[pair]
def is_pair_locked(self, pair: str, candle_date: datetime = None) -> bool: 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 :param candle_date: Date of the last candle. Optional, defaults to current date
:returns: locking state of the pair in question. :returns: locking state of the pair in question.
""" """
if pair not in self._pair_locked_until:
return False
if not candle_date: 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: else:
# Locking should happen until a new candle arrives
lock_time = timeframe_to_next_date(self.timeframe, candle_date) lock_time = timeframe_to_next_date(self.timeframe, candle_date)
# lock_time = candle_date + timedelta(minutes=timeframe_to_minutes(self.timeframe)) return PairLock.is_pair_locked(pair, lock_time)
return self._pair_locked_until[pair] > lock_time
def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame: def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
""" """

View File

@ -1,5 +1,4 @@
# pragma pylint: disable=missing-docstring, C0103 # pragma pylint: disable=missing-docstring, C0103
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock from unittest.mock import MagicMock
@ -12,7 +11,7 @@ from freqtrade.configuration import TimeRange
from freqtrade.data.dataprovider import DataProvider from freqtrade.data.dataprovider import DataProvider
from freqtrade.data.history import load_data from freqtrade.data.history import load_data
from freqtrade.exceptions import StrategyError from freqtrade.exceptions import StrategyError
from freqtrade.persistence import Trade from freqtrade.persistence import PairLock, Trade
from freqtrade.resolvers import StrategyResolver from freqtrade.resolvers import StrategyResolver
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
from tests.conftest import log_has, log_has_re 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) assert log_has('Skipping TA Analysis for already analyzed candle', caplog)
@pytest.mark.usefixtures("init_persistence")
def test_is_pair_locked(default_conf): def test_is_pair_locked(default_conf):
default_conf.update({'strategy': 'DefaultStrategy'}) default_conf.update({'strategy': 'DefaultStrategy'})
strategy = StrategyResolver.load_strategy(default_conf) strategy = StrategyResolver.load_strategy(default_conf)
# dict should be empty # No lock should be present
assert not strategy._pair_locked_until assert len(PairLock.query.all()) == 0
pair = 'ETH/BTC' pair = 'ETH/BTC'
assert not strategy.is_pair_locked(pair) assert not strategy.is_pair_locked(pair)
@ -372,11 +372,6 @@ def test_is_pair_locked(default_conf):
# ETH/BTC locked for 4 minutes # ETH/BTC locked for 4 minutes
assert strategy.is_pair_locked(pair) 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 # XRP/BTC should not be locked now
pair = 'XRP/BTC' pair = 'XRP/BTC'
assert not strategy.is_pair_locked(pair) assert not strategy.is_pair_locked(pair)
@ -393,7 +388,10 @@ def test_is_pair_locked(default_conf):
# Lock until 14:30 # Lock until 14:30
lock_time = datetime(2020, 5, 1, 14, 30, 0, tzinfo=timezone.utc) lock_time = datetime(2020, 5, 1, 14, 30, 0, tzinfo=timezone.utc)
strategy.lock_pair(pair, lock_time) 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) assert not strategy.is_pair_locked(pair)
# latest candle is from 14:20, lock goes to 14:30 # latest candle is from 14:20, lock goes to 14:30
assert strategy.is_pair_locked(pair, lock_time + timedelta(minutes=-10)) assert strategy.is_pair_locked(pair, lock_time + timedelta(minutes=-10))

View File

@ -15,7 +15,7 @@ from freqtrade.exceptions import (DependencyException, ExchangeError, Insufficie
InvalidOrderException, OperationalException, PricingError, InvalidOrderException, OperationalException, PricingError,
TemporaryError) TemporaryError)
from freqtrade.freqtradebot import FreqtradeBot 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.rpc import RPCMessageType
from freqtrade.state import RunMode, State from freqtrade.state import RunMode, State
from freqtrade.strategy.interface import SellCheckTuple, SellType 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 = Trade.query.first()
Trade.session = MagicMock() Trade.session = MagicMock()
PairLock.session = MagicMock()
freqtrade.config['dry_run'] = False freqtrade.config['dry_run'] = False
trade.stoploss_order_id = "abcd" 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'], freqtrade.execute_sell(trade=trade, limit=ticker_sell_down()['bid'],
sell_reason=SellType.STOP_LOSS) sell_reason=SellType.STOP_LOSS)
trade.close(ticker_sell_down()['bid']) trade.close(ticker_sell_down()['bid'])
assert trade.pair in freqtrade.strategy._pair_locked_until
assert freqtrade.strategy.is_pair_locked(trade.pair) assert freqtrade.strategy.is_pair_locked(trade.pair)
# reinit - should buy other pair. # reinit - should buy other pair.