diff --git a/config_full.json.example b/config_full.json.example index e1be01690..d5bfd3fe1 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -66,7 +66,7 @@ }, {"method": "AgeFilter", "min_days_listed": 10}, {"method": "PrecisionFilter"}, - {"method": "PriceFilter", "low_price_ratio": 0.01}, + {"method": "PriceFilter", "low_price_ratio": 0.01, "min_price": 0.00000010}, {"method": "SpreadFilter", "max_spread_ratio": 0.005} ], "exchange": { diff --git a/docs/configuration.md b/docs/configuration.md index 09a1e76fe..a200d6411 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -662,16 +662,25 @@ Filters low-value coins which would not allow setting stoplosses. #### PriceFilter -The `PriceFilter` allows filtering of pairs by price. +The `PriceFilter` allows filtering of pairs by price. Currently the following price filters are supported: +* `min_price` +* `max_price` +* `low_price_ratio` -Currently, only `low_price_ratio` setting is implemented, where a raise of 1 price unit (pip) is below the `low_price_ratio` ratio. +The `min_price` setting removes pairs where the price is below the specified price. This is useful if you wish to avoid trading very low-priced pairs. +This option is disabled by default, and will only apply if set to <> 0. + +The `max_price` setting removes pairs where the price is above the specified price. This is useful if you wish to trade only low-priced pairs. +This option is disabled by default, and will only apply if set to <> 0. + +The `low_price_ratio` setting removes pairs where a raise of 1 price unit (pip) is above the `low_price_ratio` ratio. This option is disabled by default, and will only apply if set to <> 0. Calculation example: Min price precision is 8 decimals. If price is 0.00000011 - one step would be 0.00000012 - which is almost 10% higher than the previous value. -These pairs are dangerous since it may be impossible to place the desired stoploss - and often result in high losses. Here is what the PriceFilters takes over. +These pairs are dangerous since it may be impossible to place the desired stoploss - and often result in high losses. #### ShuffleFilter diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index a3a548176..8aab225c6 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -187,6 +187,11 @@ class Exchange: def timeframes(self) -> List[str]: return list((self._api.timeframes or {}).keys()) + @property + def ohlcv_candle_limit(self) -> int: + """exchange ohlcv candle limit""" + return int(self._ohlcv_candle_limit) + @property def markets(self) -> Dict: """exchange ccxt markets""" diff --git a/freqtrade/pairlist/AgeFilter.py b/freqtrade/pairlist/AgeFilter.py index b489a59bc..7b6b126c3 100644 --- a/freqtrade/pairlist/AgeFilter.py +++ b/freqtrade/pairlist/AgeFilter.py @@ -5,6 +5,7 @@ import logging import arrow from typing import Any, Dict +from freqtrade.exceptions import OperationalException from freqtrade.misc import plural from freqtrade.pairlist.IPairList import IPairList @@ -23,6 +24,13 @@ class AgeFilter(IPairList): super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) self._min_days_listed = pairlistconfig.get('min_days_listed', 10) + + if self._min_days_listed < 1: + raise OperationalException("AgeFilter requires min_days_listed must be >= 1") + if self._min_days_listed > exchange.ohlcv_candle_limit: + raise OperationalException("AgeFilter requires min_days_listed must not exceed " + "exchange max request size " + f"({exchange.ohlcv_candle_limit})") self._enabled = self._min_days_listed >= 1 @property diff --git a/freqtrade/pairlist/PriceFilter.py b/freqtrade/pairlist/PriceFilter.py index 29dd88a76..5ee1df078 100644 --- a/freqtrade/pairlist/PriceFilter.py +++ b/freqtrade/pairlist/PriceFilter.py @@ -18,7 +18,11 @@ class PriceFilter(IPairList): super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) self._low_price_ratio = pairlistconfig.get('low_price_ratio', 0) - self._enabled = self._low_price_ratio != 0 + self._min_price = pairlistconfig.get('min_price', 0) + self._max_price = pairlistconfig.get('max_price', 0) + self._enabled = ((self._low_price_ratio != 0) or + (self._min_price != 0) or + (self._max_price != 0)) @property def needstickers(self) -> bool: @@ -33,7 +37,18 @@ class PriceFilter(IPairList): """ Short whitelist method description - used for startup-messages """ - return f"{self.name} - Filtering pairs priced below {self._low_price_ratio * 100}%." + active_price_filters = [] + if self._low_price_ratio != 0: + active_price_filters.append(f"below {self._low_price_ratio * 100}%") + if self._min_price != 0: + active_price_filters.append(f"below {self._min_price:.8f}") + if self._max_price != 0: + active_price_filters.append(f"above {self._max_price:.8f}") + + if len(active_price_filters): + return f"{self.name} - Filtering pairs priced {' or '.join(active_price_filters)}." + + return f"{self.name} - No price filters configured." def _validate_pair(self, ticker) -> bool: """ @@ -46,10 +61,28 @@ class PriceFilter(IPairList): f"Removed {ticker['symbol']} from whitelist, because " "ticker['last'] is empty (Usually no trade in the last 24h).") return False - compare = self._exchange.price_get_one_pip(ticker['symbol'], ticker['last']) - changeperc = compare / ticker['last'] - if changeperc > self._low_price_ratio: - self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " - f"because 1 unit is {changeperc * 100:.3f}%") - return False + + # Perform low_price_ratio check. + if self._low_price_ratio != 0: + compare = self._exchange.price_get_one_pip(ticker['symbol'], ticker['last']) + changeperc = compare / ticker['last'] + if changeperc > self._low_price_ratio: + self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " + f"because 1 unit is {changeperc * 100:.3f}%") + return False + + # Perform min_price check. + if self._min_price != 0: + if ticker['last'] < self._min_price: + self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " + f"because last price < {self._min_price:.8f}") + return False + + # Perform max_price check. + if self._max_price != 0: + if ticker['last'] > self._max_price: + self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " + f"because last price > {self._max_price:.8f}") + return False + return True diff --git a/tests/pairlist/test_pairlist.py b/tests/pairlist/test_pairlist.py index a2644fe8c..e23102162 100644 --- a/tests/pairlist/test_pairlist.py +++ b/tests/pairlist/test_pairlist.py @@ -275,11 +275,16 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, {"method": "PriceFilter", "low_price_ratio": 0.03}], "USDT", ['ETH/USDT', 'NANO/USDT']), - # Hot is removed by precision_filter, Fuel by low_price_filter. + # Hot is removed by precision_filter, Fuel by low_price_ratio, Ripple by min_price. ([{"method": "VolumePairList", "number_assets": 6, "sort_key": "quoteVolume"}, {"method": "PrecisionFilter"}, - {"method": "PriceFilter", "low_price_ratio": 0.02}], - "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC']), + {"method": "PriceFilter", "low_price_ratio": 0.02, "min_price": 0.01}], + "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC']), + # Hot is removed by precision_filter, Fuel by low_price_ratio, Ethereum by max_price. + ([{"method": "VolumePairList", "number_assets": 6, "sort_key": "quoteVolume"}, + {"method": "PrecisionFilter"}, + {"method": "PriceFilter", "low_price_ratio": 0.02, "max_price": 0.05}], + "BTC", ['TKN/BTC', 'LTC/BTC', 'XRP/BTC']), # HOT and XRP are removed because below 1250 quoteVolume ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", "min_value": 1250}], @@ -319,7 +324,7 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): "BTC", 'filter_at_the_beginning'), # OperationalException expected # PriceFilter after StaticPairList ([{"method": "StaticPairList"}, - {"method": "PriceFilter", "low_price_ratio": 0.02}], + {"method": "PriceFilter", "low_price_ratio": 0.02, "min_price": 0.000001, "max_price": 0.1}], "BTC", ['ETH/BTC', 'TKN/BTC']), # PriceFilter only ([{"method": "PriceFilter", "low_price_ratio": 0.02}], @@ -396,6 +401,10 @@ def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, t r'would be <= stop limit.*', caplog) if pairlist['method'] == 'PriceFilter' and whitelist_result: assert (log_has_re(r'^Removed .* from whitelist, because 1 unit is .*%$', caplog) or + log_has_re(r'^Removed .* from whitelist, ' + r'because last price < .*%$', caplog) or + log_has_re(r'^Removed .* from whitelist, ' + r'because last price > .*%$', caplog) or log_has_re(r"^Removed .* from whitelist, because ticker\['last'\] " r"is empty.*", caplog)) if pairlist['method'] == 'VolumePairList': @@ -524,6 +533,37 @@ def test_volumepairlist_caching(mocker, markets, whitelist_conf, tickers): assert freqtrade.pairlists._pairlist_handlers[0]._last_refresh == lrf +def test_agefilter_min_days_listed_too_small(mocker, default_conf, markets, tickers, caplog): + default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}, + {'method': 'AgeFilter', 'min_days_listed': -1}] + + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + with pytest.raises(OperationalException, + match=r'AgeFilter requires min_days_listed must be >= 1'): + get_patched_freqtradebot(mocker, default_conf) + + +def test_agefilter_min_days_listed_too_large(mocker, default_conf, markets, tickers, caplog): + default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}, + {'method': 'AgeFilter', 'min_days_listed': 99999}] + + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + with pytest.raises(OperationalException, + match=r'AgeFilter requires min_days_listed must not exceed ' + r'exchange max request size \([0-9]+\)'): + get_patched_freqtradebot(mocker, default_conf) + + def test_agefilter_caching(mocker, markets, whitelist_conf_3, tickers, ohlcv_history_list): mocker.patch.multiple('freqtrade.exchange.Exchange', @@ -547,6 +587,36 @@ def test_agefilter_caching(mocker, markets, whitelist_conf_3, tickers, ohlcv_his assert freqtrade.exchange.get_historic_ohlcv.call_count == previous_call_count +@pytest.mark.parametrize("pairlistconfig,expected", [ + ({"method": "PriceFilter", "low_price_ratio": 0.001, "min_price": 0.00000010, + "max_price": 1.0}, "[{'PriceFilter': 'PriceFilter - Filtering pairs priced below " + "0.1% or below 0.00000010 or above 1.00000000.'}]" + ), + ({"method": "PriceFilter", "low_price_ratio": 0.001, "min_price": 0.00000010}, + "[{'PriceFilter': 'PriceFilter - Filtering pairs priced below 0.1% or below 0.00000010.'}]" + ), + ({"method": "PriceFilter", "low_price_ratio": 0.001, "max_price": 1.00010000}, + "[{'PriceFilter': 'PriceFilter - Filtering pairs priced below 0.1% or above 1.00010000.'}]" + ), + ({"method": "PriceFilter", "min_price": 0.00002000}, + "[{'PriceFilter': 'PriceFilter - Filtering pairs priced below 0.00002000.'}]" + ), + ({"method": "PriceFilter"}, + "[{'PriceFilter': 'PriceFilter - No price filters configured.'}]" + ), +]) +def test_pricefilter_desc(mocker, whitelist_conf, markets, pairlistconfig, expected): + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True) + ) + whitelist_conf['pairlists'] = [pairlistconfig] + + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + short_desc = str(freqtrade.pairlists.short_desc()) + assert short_desc == expected + + def test_pairlistmanager_no_pairlist(mocker, markets, whitelist_conf, caplog): mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True))