| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user