diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index cbf851fc2..4037ca704 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -6,6 +6,8 @@ from freqtrade.exchange.exchange import (get_exchange_bad_reason, # noqa: F401 available_exchanges) from freqtrade.exchange.exchange import (timeframe_to_seconds, # noqa: F401 timeframe_to_minutes, - timeframe_to_msecs) + timeframe_to_msecs, + timeframe_to_next_date, + timeframe_to_prev_date) from freqtrade.exchange.kraken import Kraken # noqa: F401 from freqtrade.exchange.binance import Binance # noqa: F401 diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 4e8228ebe..c0753db6f 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -6,7 +6,7 @@ import asyncio import inspect import logging from copy import deepcopy -from datetime import datetime +from datetime import datetime, timezone from math import ceil, floor from random import randint from typing import Any, Dict, List, Optional, Tuple @@ -790,13 +790,45 @@ def timeframe_to_seconds(ticker_interval: str) -> int: def timeframe_to_minutes(ticker_interval: str) -> int: """ - Same as above, but returns minutes. + Same as timeframe_to_seconds, but returns minutes. """ return ccxt.Exchange.parse_timeframe(ticker_interval) // 60 def timeframe_to_msecs(ticker_interval: str) -> int: """ - Same as above, but returns milliseconds. + Same as timeframe_to_seconds, but returns milliseconds. """ return ccxt.Exchange.parse_timeframe(ticker_interval) * 1000 + + +def timeframe_to_prev_date(timeframe: str, date: datetime = None) -> datetime: + """ + Use Timeframe and determine last possible candle. + :param timeframe: timeframe in string format (e.g. "5m") + :param date: date to use. Defaults to utcnow() + :returns: date of previous candle (with utc timezone) + """ + if not date: + date = datetime.now(timezone.utc) + timeframe_secs = timeframe_to_seconds(timeframe) + # Get offset based on timerame_secs + offset = date.timestamp() % timeframe_secs + # Subtract seconds passed since last offset + new_timestamp = date.timestamp() - offset + return datetime.fromtimestamp(new_timestamp, tz=timezone.utc) + + +def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime: + """ + Use Timeframe and determine next candle. + :param timeframe: timeframe in string format (e.g. "5m") + :param date: date to use. Defaults to utcnow() + :returns: date of next candle (with utc timezone) + """ + prevdate = timeframe_to_prev_date(timeframe, date) + timeframe_secs = timeframe_to_seconds(timeframe) + + # Add one interval to previous candle + new_timestamp = prevdate.timestamp() + timeframe_secs + return datetime.fromtimestamp(new_timestamp, tz=timezone.utc) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index b1cec77e2..68b45d96f 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -16,7 +16,7 @@ from freqtrade import (DependencyException, OperationalException, InvalidOrderEx from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge -from freqtrade.exchange import timeframe_to_minutes +from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date from freqtrade.persistence import Trade from freqtrade.rpc import RPCManager, RPCMessageType from freqtrade.resolvers import ExchangeResolver, StrategyResolver, PairListResolver @@ -283,6 +283,9 @@ class FreqtradeBot(object): buycount = 0 # running get_signal on historical data fetched for _pair in whitelist: + if self.strategy.is_pair_locked(_pair): + logger.info(f"Pair {_pair} is currently locked.") + continue (buy, sell) = self.strategy.get_signal( _pair, interval, self.dataprovider.ohlcv(_pair, self.strategy.ticker_interval)) @@ -674,6 +677,9 @@ class FreqtradeBot(object): if stoploss_order and stoploss_order['status'] == 'closed': trade.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value trade.update(stoploss_order) + # Lock pair for one candle to prevent immediate rebuys + self.strategy.lock_pair(trade.pair, + timeframe_to_next_date(self.config['ticker_interval'])) self._notify_sell(trade) return True @@ -884,6 +890,10 @@ class FreqtradeBot(object): if order.get('status', 'unknown') == 'closed': trade.update(order) Trade.session.flush() + + # Lock pair for one candle to prevent immediate rebuys + self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['ticker_interval'])) + self._notify_sell(trade) def _notify_sell(self, trade: Trade): diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 37aa97bb1..99f5f26de 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -4,7 +4,7 @@ This module defines the interface to apply for strategies """ import logging from abc import ABC, abstractmethod -from datetime import datetime +from datetime import datetime, timezone from enum import Enum from typing import Dict, List, NamedTuple, Optional, Tuple import warnings @@ -107,6 +107,7 @@ 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: @@ -154,6 +155,24 @@ class IStrategy(ABC): """ return self.__class__.__name__ + def lock_pair(self, pair: str, until: datetime) -> None: + """ + Locks pair until a given timestamp happens. + Locked pairs are not analyzed, and are prevented from opening new trades. + :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)` + """ + self._pair_locked_until[pair] = until + + def is_pair_locked(self, pair: str) -> bool: + """ + Checks if a pair is currently locked + """ + if pair not in self._pair_locked_until: + return False + return self._pair_locked_until[pair] >= datetime.now(timezone.utc) + def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Parses the given ticker history and returns a populated DataFrame @@ -260,8 +279,8 @@ class IStrategy(ABC): sell: bool, low: float = None, high: float = None, force_stoploss: float = 0) -> SellCheckTuple: """ - This function evaluate if on the condition required to trigger a sell has been reached - if the threshold is reached and updates the trade record. + This function evaluates if one of the conditions required to trigger a sell + has been reached, which can either be a stop-loss, ROI or sell-signal. :param low: Only used during backtesting to simulate stoploss :param high: Only used during backtesting, to simulate ROI :param force_stoploss: Externally provided stoploss diff --git a/freqtrade/tests/exchange/test_exchange.py b/freqtrade/tests/exchange/test_exchange.py index e8a7201f1..3747a2ad3 100644 --- a/freqtrade/tests/exchange/test_exchange.py +++ b/freqtrade/tests/exchange/test_exchange.py @@ -14,7 +14,11 @@ from pandas import DataFrame from freqtrade import (DependencyException, InvalidOrderException, OperationalException, TemporaryError) from freqtrade.exchange import Binance, Exchange, Kraken -from freqtrade.exchange.exchange import API_RETRY_COUNT +from freqtrade.exchange.exchange import (API_RETRY_COUNT, timeframe_to_minutes, + timeframe_to_msecs, + timeframe_to_next_date, + timeframe_to_prev_date, + timeframe_to_seconds) from freqtrade.resolvers.exchange_resolver import ExchangeResolver from freqtrade.tests.conftest import get_patched_exchange, log_has, log_has_re @@ -1540,3 +1544,74 @@ def test_get_valid_pair_combination(default_conf, mocker, markets): assert ex.get_valid_pair_combination("BTC", "ETH") == "ETH/BTC" with pytest.raises(DependencyException, match=r"Could not combine.* to get a valid pair."): ex.get_valid_pair_combination("NOPAIR", "ETH") + + +def test_timeframe_to_minutes(): + assert timeframe_to_minutes("5m") == 5 + assert timeframe_to_minutes("10m") == 10 + assert timeframe_to_minutes("1h") == 60 + assert timeframe_to_minutes("1d") == 1440 + + +def test_timeframe_to_seconds(): + assert timeframe_to_seconds("5m") == 300 + assert timeframe_to_seconds("10m") == 600 + assert timeframe_to_seconds("1h") == 3600 + assert timeframe_to_seconds("1d") == 86400 + + +def test_timeframe_to_msecs(): + assert timeframe_to_msecs("5m") == 300000 + assert timeframe_to_msecs("10m") == 600000 + assert timeframe_to_msecs("1h") == 3600000 + assert timeframe_to_msecs("1d") == 86400000 + + +def test_timeframe_to_prev_date(): + # 2019-08-12 13:22:08 + date = datetime.fromtimestamp(1565616128, tz=timezone.utc) + + tf_list = [ + # 5m -> 2019-08-12 13:20:00 + ("5m", datetime(2019, 8, 12, 13, 20, 0, tzinfo=timezone.utc)), + # 10m -> 2019-08-12 13:20:00 + ("10m", datetime(2019, 8, 12, 13, 20, 0, tzinfo=timezone.utc)), + # 1h -> 2019-08-12 13:00:00 + ("1h", datetime(2019, 8, 12, 13, 00, 0, tzinfo=timezone.utc)), + # 2h -> 2019-08-12 12:00:00 + ("2h", datetime(2019, 8, 12, 12, 00, 0, tzinfo=timezone.utc)), + # 4h -> 2019-08-12 12:00:00 + ("4h", datetime(2019, 8, 12, 12, 00, 0, tzinfo=timezone.utc)), + # 1d -> 2019-08-12 00:00:00 + ("1d", datetime(2019, 8, 12, 00, 00, 0, tzinfo=timezone.utc)), + ] + for interval, result in tf_list: + assert timeframe_to_prev_date(interval, date) == result + + date = datetime.now(tz=timezone.utc) + assert timeframe_to_prev_date("5m", date) < date + + +def test_timeframe_to_next_date(): + # 2019-08-12 13:22:08 + date = datetime.fromtimestamp(1565616128, tz=timezone.utc) + tf_list = [ + # 5m -> 2019-08-12 13:25:00 + ("5m", datetime(2019, 8, 12, 13, 25, 0, tzinfo=timezone.utc)), + # 10m -> 2019-08-12 13:30:00 + ("10m", datetime(2019, 8, 12, 13, 30, 0, tzinfo=timezone.utc)), + # 1h -> 2019-08-12 14:00:00 + ("1h", datetime(2019, 8, 12, 14, 00, 0, tzinfo=timezone.utc)), + # 2h -> 2019-08-12 14:00:00 + ("2h", datetime(2019, 8, 12, 14, 00, 0, tzinfo=timezone.utc)), + # 4h -> 2019-08-12 14:00:00 + ("4h", datetime(2019, 8, 12, 16, 00, 0, tzinfo=timezone.utc)), + # 1d -> 2019-08-13 00:00:00 + ("1d", datetime(2019, 8, 13, 0, 0, 0, tzinfo=timezone.utc)), + ] + + for interval, result in tf_list: + assert timeframe_to_next_date(interval, date) == result + + date = datetime.now(tz=timezone.utc) + assert timeframe_to_next_date("5m", date) > date diff --git a/freqtrade/tests/strategy/test_interface.py b/freqtrade/tests/strategy/test_interface.py index 0eb7630a1..36c9ffcd4 100644 --- a/freqtrade/tests/strategy/test_interface.py +++ b/freqtrade/tests/strategy/test_interface.py @@ -286,3 +286,19 @@ def test__analyze_ticker_internal_skip_analyze(ticker_history, mocker, caplog) - assert ret['sell'].sum() == 0 assert not log_has('TA Analysis Launched', caplog) assert log_has('Skipping TA Analysis for already analyzed candle', caplog) + + +def test_is_pair_locked(default_conf): + strategy = DefaultStrategy(default_conf) + # dict should be empty + assert not strategy._pair_locked_until + + pair = 'ETH/BTC' + assert not strategy.is_pair_locked(pair) + strategy.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime) + # ETH/BTC locked for 4 minutes + assert strategy.is_pair_locked(pair) + + # XRP/BTC should not be locked now + pair = 'XRP/BTC' + assert not strategy.is_pair_locked(pair) diff --git a/freqtrade/tests/test_freqtradebot.py b/freqtrade/tests/test_freqtradebot.py index 4dd3a892e..24d070d2d 100644 --- a/freqtrade/tests/test_freqtradebot.py +++ b/freqtrade/tests/test_freqtradebot.py @@ -2598,6 +2598,43 @@ def test_sell_profit_only_disable_loss(default_conf, limit_buy_order, fee, marke assert trade.sell_reason == SellType.SELL_SIGNAL.value +def test_locked_pairs(default_conf, ticker, fee, ticker_sell_down, markets, mocker, caplog) -> None: + patch_RPCManager(mocker) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + _load_markets=MagicMock(return_value={}), + get_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets) + ) + freqtrade = FreqtradeBot(default_conf) + patch_get_signal(freqtrade) + + # Create some test data + freqtrade.create_trades() + + trade = Trade.query.first() + assert trade + + # Decrease the price and sell it + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_ticker=ticker_sell_down + ) + + 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. + caplog.clear() + freqtrade.create_trades() + + assert log_has(f"Pair {trade.pair} is currently locked.", caplog) + + def test_ignore_roi_if_buy_signal(default_conf, limit_buy_order, fee, markets, mocker) -> None: patch_RPCManager(mocker) patch_exchange(mocker)