| @@ -6,6 +6,8 @@ from freqtrade.exchange.exchange import (get_exchange_bad_reason,  # noqa: F401 | |||||||
|                                          available_exchanges) |                                          available_exchanges) | ||||||
| from freqtrade.exchange.exchange import (timeframe_to_seconds,  # noqa: F401 | from freqtrade.exchange.exchange import (timeframe_to_seconds,  # noqa: F401 | ||||||
|                                          timeframe_to_minutes, |                                          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.kraken import Kraken  # noqa: F401 | ||||||
| from freqtrade.exchange.binance import Binance  # noqa: F401 | from freqtrade.exchange.binance import Binance  # noqa: F401 | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ import asyncio | |||||||
| import inspect | import inspect | ||||||
| import logging | import logging | ||||||
| from copy import deepcopy | from copy import deepcopy | ||||||
| from datetime import datetime | from datetime import datetime, timezone | ||||||
| from math import ceil, floor | from math import ceil, floor | ||||||
| from random import randint | from random import randint | ||||||
| from typing import Any, Dict, List, Optional, Tuple | 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: | 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 |     return ccxt.Exchange.parse_timeframe(ticker_interval) // 60 | ||||||
|  |  | ||||||
|  |  | ||||||
| def timeframe_to_msecs(ticker_interval: str) -> int: | 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 |     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) | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ from freqtrade import (DependencyException, OperationalException, InvalidOrderEx | |||||||
| from freqtrade.data.converter import order_book_to_dataframe | from freqtrade.data.converter import order_book_to_dataframe | ||||||
| from freqtrade.data.dataprovider import DataProvider | from freqtrade.data.dataprovider import DataProvider | ||||||
| from freqtrade.edge import Edge | 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.persistence import Trade | ||||||
| from freqtrade.rpc import RPCManager, RPCMessageType | from freqtrade.rpc import RPCManager, RPCMessageType | ||||||
| from freqtrade.resolvers import ExchangeResolver, StrategyResolver, PairListResolver | from freqtrade.resolvers import ExchangeResolver, StrategyResolver, PairListResolver | ||||||
| @@ -283,6 +283,9 @@ class FreqtradeBot(object): | |||||||
|         buycount = 0 |         buycount = 0 | ||||||
|         # running get_signal on historical data fetched |         # running get_signal on historical data fetched | ||||||
|         for _pair in whitelist: |         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( |             (buy, sell) = self.strategy.get_signal( | ||||||
|                 _pair, interval, self.dataprovider.ohlcv(_pair, self.strategy.ticker_interval)) |                 _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': |         if stoploss_order and stoploss_order['status'] == 'closed': | ||||||
|             trade.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value |             trade.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value | ||||||
|             trade.update(stoploss_order) |             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) |             self._notify_sell(trade) | ||||||
|             return True |             return True | ||||||
|  |  | ||||||
| @@ -884,6 +890,10 @@ class FreqtradeBot(object): | |||||||
|         if order.get('status', 'unknown') == 'closed': |         if order.get('status', 'unknown') == 'closed': | ||||||
|             trade.update(order) |             trade.update(order) | ||||||
|         Trade.session.flush() |         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) |         self._notify_sell(trade) | ||||||
|  |  | ||||||
|     def _notify_sell(self, trade: Trade): |     def _notify_sell(self, trade: Trade): | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ This module defines the interface to apply for strategies | |||||||
| """ | """ | ||||||
| import logging | import logging | ||||||
| from abc import ABC, abstractmethod | from abc import ABC, abstractmethod | ||||||
| from datetime import datetime | from datetime import datetime, timezone | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from typing import Dict, List, NamedTuple, Optional, Tuple | from typing import Dict, List, NamedTuple, Optional, Tuple | ||||||
| import warnings | import warnings | ||||||
| @@ -107,6 +107,7 @@ 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: | ||||||
| @@ -154,6 +155,24 @@ class IStrategy(ABC): | |||||||
|         """ |         """ | ||||||
|         return self.__class__.__name__ |         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: |     def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame: | ||||||
|         """ |         """ | ||||||
|         Parses the given ticker history and returns a populated 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, |                     sell: bool, low: float = None, high: float = None, | ||||||
|                     force_stoploss: float = 0) -> SellCheckTuple: |                     force_stoploss: float = 0) -> SellCheckTuple: | ||||||
|         """ |         """ | ||||||
|         This function evaluate if on the condition required to trigger a sell has been reached |         This function evaluates if one of the conditions required to trigger a sell | ||||||
|         if the threshold is reached and updates the trade record. |         has been reached, which can either be a stop-loss, ROI or sell-signal. | ||||||
|         :param low: Only used during backtesting to simulate stoploss |         :param low: Only used during backtesting to simulate stoploss | ||||||
|         :param high: Only used during backtesting, to simulate ROI |         :param high: Only used during backtesting, to simulate ROI | ||||||
|         :param force_stoploss: Externally provided stoploss |         :param force_stoploss: Externally provided stoploss | ||||||
|   | |||||||
| @@ -14,7 +14,11 @@ from pandas import DataFrame | |||||||
| from freqtrade import (DependencyException, InvalidOrderException, | from freqtrade import (DependencyException, InvalidOrderException, | ||||||
|                        OperationalException, TemporaryError) |                        OperationalException, TemporaryError) | ||||||
| from freqtrade.exchange import Binance, Exchange, Kraken | 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.resolvers.exchange_resolver import ExchangeResolver | ||||||
| from freqtrade.tests.conftest import get_patched_exchange, log_has, log_has_re | 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" |     assert ex.get_valid_pair_combination("BTC", "ETH") == "ETH/BTC" | ||||||
|     with pytest.raises(DependencyException, match=r"Could not combine.* to get a valid pair."): |     with pytest.raises(DependencyException, match=r"Could not combine.* to get a valid pair."): | ||||||
|         ex.get_valid_pair_combination("NOPAIR", "ETH") |         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 | ||||||
|   | |||||||
| @@ -286,3 +286,19 @@ def test__analyze_ticker_internal_skip_analyze(ticker_history, mocker, caplog) - | |||||||
|     assert ret['sell'].sum() == 0 |     assert ret['sell'].sum() == 0 | ||||||
|     assert not log_has('TA Analysis Launched', caplog) |     assert not log_has('TA Analysis Launched', caplog) | ||||||
|     assert log_has('Skipping TA Analysis for already analyzed candle', 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) | ||||||
|   | |||||||
| @@ -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 |     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: | def test_ignore_roi_if_buy_signal(default_conf, limit_buy_order, fee, markets, mocker) -> None: | ||||||
|     patch_RPCManager(mocker) |     patch_RPCManager(mocker) | ||||||
|     patch_exchange(mocker) |     patch_exchange(mocker) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user