From 7ddaa09a2380dc2df81d0a570379a2808afa655f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 08:23:55 +0100 Subject: [PATCH 01/19] Refactor VolatilityFilter --- freqtrade/plugins/pairlist/VolatilityFilter.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index ef72486e1..f18af2c97 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -105,13 +105,13 @@ class VolatilityFilter(IPairList): since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days)) candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms) - if self._enabled: - for p in deepcopy(pairlist): - daily_candles = candles[(p, '1d', self._def_candletype)] if ( - p, '1d', self._def_candletype) in candles else None - if not self._validate_pair_loc(p, daily_candles): - pairlist.remove(p) - return pairlist + resulting_pairlist: List[str] = [] + for p in pairlist: + daily_candles = candles[(p, '1d', self._def_candletype)] if ( + p, '1d', self._def_candletype) in candles else None + if self._validate_pair_loc(p, daily_candles): + resulting_pairlist.append(p) + return resulting_pairlist def _validate_pair_loc(self, pair: str, daily_candles: Optional[DataFrame]) -> bool: """ From 0bf73cc64b66cee16f9d0de95e10fdc553e989c8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 13:11:43 +0100 Subject: [PATCH 02/19] Voliatilityfilter - sorting --- .../plugins/pairlist/VolatilityFilter.py | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index f18af2c97..70b48f7d2 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -37,6 +37,7 @@ class VolatilityFilter(IPairList): self._max_volatility = pairlistconfig.get('max_volatility', sys.maxsize) self._refresh_period = pairlistconfig.get('refresh_period', 1440) self._def_candletype = self._config['candle_type_def'] + self._sort_direction: Optional[str] = pairlistconfig.get('sort_direction', None) self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period) @@ -89,6 +90,13 @@ class VolatilityFilter(IPairList): "description": "Maximum Volatility", "help": "Maximum volatility a pair must have to be considered.", }, + "sort_direction": { + "type": "option", + "default": None, + "options": [None, "asc", "desc"], + "description": "Sort pairlist", + "help": "Sort Pairlist", + }, **IPairList.refresh_period_parameter() } @@ -106,14 +114,34 @@ class VolatilityFilter(IPairList): candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms) resulting_pairlist: List[str] = [] + volatilitys: Dict[str, float] = {} for p in pairlist: daily_candles = candles[(p, '1d', self._def_candletype)] if ( p, '1d', self._def_candletype) in candles else None - if self._validate_pair_loc(p, daily_candles): - resulting_pairlist.append(p) + + if daily_candles is not None and not daily_candles.empty: + volatility_avg = self._calculate_volatility(deepcopy(daily_candles)) + + if self._validate_pair_loc(p, volatility_avg): + resulting_pairlist.append(p) + if self._sort_direction: + volatilitys[p] = volatility_avg if not np.isnan(volatility_avg) else 0 + + if self._sort_direction: + resulting_pairlist = sorted(resulting_pairlist, + key=lambda p: volatilitys[p], + reverse=self._sort_direction == 'desc') return resulting_pairlist - def _validate_pair_loc(self, pair: str, daily_candles: Optional[DataFrame]) -> bool: + def _calculate_volatility(self, daily_candles: DataFrame) -> float: + returns = (np.log(daily_candles["close"].shift(1) / daily_candles["close"])) + returns.fillna(0, inplace=True) + + volatility_series = returns.rolling(window=self._days).std() * np.sqrt(self._days) + volatility_avg = volatility_series.mean() + return volatility_avg + + def _validate_pair_loc(self, pair: str, volatility_avg: float) -> bool: """ Validate trading range :param pair: Pair that's currently validated @@ -125,23 +153,17 @@ class VolatilityFilter(IPairList): return cached_res result = False - if daily_candles is not None and not daily_candles.empty: - returns = (np.log(daily_candles["close"].shift(1) / daily_candles["close"])) - returns.fillna(0, inplace=True) - volatility_series = returns.rolling(window=self._days).std() * np.sqrt(self._days) - volatility_avg = volatility_series.mean() - - if self._min_volatility <= volatility_avg <= self._max_volatility: - result = True - else: - self.log_once(f"Removed {pair} from whitelist, because volatility " - f"over {self._days} {plural(self._days, 'day')} " - f"is: {volatility_avg:.3f} " - f"which is not in the configured range of " - f"{self._min_volatility}-{self._max_volatility}.", - logger.info) - result = False - self._pair_cache[pair] = result + if self._min_volatility <= volatility_avg <= self._max_volatility: + result = True + else: + self.log_once(f"Removed {pair} from whitelist, because volatility " + f"over {self._days} {plural(self._days, 'day')} " + f"is: {volatility_avg:.3f} " + f"which is not in the configured range of " + f"{self._min_volatility}-{self._max_volatility}.", + logger.info) + result = False + self._pair_cache[pair] = result return result From 38ca58c728a752bf3eaa9164f1a724ea07807a95 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 13:12:38 +0100 Subject: [PATCH 03/19] Add verification for volatilityfilter --- freqtrade/plugins/pairlist/VolatilityFilter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 70b48f7d2..ff1525d70 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -47,6 +47,9 @@ class VolatilityFilter(IPairList): if self._days > candle_limit: raise OperationalException("VolatilityFilter requires lookback_days to not " f"exceed exchange max request size ({candle_limit})") + if self._sort_direction not in [None, 'asc', 'desc']: + raise OperationalException("VolatilityFilter requires sort_direction to be " + "either None (undefined), 'asc' or 'desc'") @property def needstickers(self) -> bool: From eaf70428c161a790a7e72fdf0549a78591a186f7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 13:20:59 +0100 Subject: [PATCH 04/19] Improve volatility tests --- tests/plugins/test_pairlist.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index d125f8896..64203d9a6 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -748,6 +748,32 @@ def test_PerformanceFilter_error(mocker, whitelist_conf, caplog) -> None: assert log_has("PerformanceFilter is not available in this mode.", caplog) +def test_VolatilityFilter_error(mocker, whitelist_conf) -> None: + volatility_filter = {"method": "VolatilityFilter", "lookback_days": -1} + whitelist_conf['pairlists'] = [{"method": "StaticPairList"}, volatility_filter] + + mocker.patch(f'{EXMS}.exchange_has', MagicMock(return_value=True)) + exchange_mock = MagicMock() + exchange_mock.ohlcv_candle_limit = MagicMock(return_value=1000) + + with pytest.raises(OperationalException, + match=r"VolatilityFilter requires lookback_days to be >= 1*"): + PairListManager(exchange_mock, whitelist_conf, MagicMock()) + + volatility_filter = {"method": "VolatilityFilter", "lookback_days": 2000} + whitelist_conf['pairlists'] = [{"method": "StaticPairList"}, volatility_filter] + with pytest.raises(OperationalException, + match=r"VolatilityFilter requires lookback_days to not exceed exchange max"): + PairListManager(exchange_mock, whitelist_conf, MagicMock()) + + volatility_filter = {"method": "VolatilityFilter", "sort_direction": "Random"} + whitelist_conf['pairlists'] = [{"method": "StaticPairList"}, volatility_filter] + with pytest.raises(OperationalException, + match=r"VolatilityFilter requires sort_direction to be either " + r"None .*'asc'.*'desc'"): + PairListManager(exchange_mock, whitelist_conf, MagicMock()) + + def test_ShuffleFilter_init(mocker, whitelist_conf, caplog) -> None: whitelist_conf['pairlists'] = [ {"method": "StaticPairList"}, From 31e254313425892abb6a51ccd205185b85e39693 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 13:30:38 +0100 Subject: [PATCH 05/19] Enhance generate_test_data with parametrizable random seed --- tests/conftest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9c81c050d..c1c35fc9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -142,8 +142,8 @@ def generate_trades_history(n_rows, start_date: Optional[datetime] = None, days= return df -def generate_test_data(timeframe: str, size: int, start: str = '2020-07-05'): - np.random.seed(42) +def generate_test_data(timeframe: str, size: int, start: str = '2020-07-05', random_seed=42): + np.random.seed(random_seed) base = np.random.normal(20, 2, size=size) if timeframe == '1y': @@ -174,9 +174,9 @@ def generate_test_data(timeframe: str, size: int, start: str = '2020-07-05'): return df -def generate_test_data_raw(timeframe: str, size: int, start: str = '2020-07-05'): +def generate_test_data_raw(timeframe: str, size: int, start: str = '2020-07-05', random_seed=42): """ Generates data in the ohlcv format used by ccxt """ - df = generate_test_data(timeframe, size, start) + df = generate_test_data(timeframe, size, start, random_seed) df['date'] = df.loc[:, 'date'].view(np.int64) // 1000 // 1000 return list(list(x) for x in zip(*(df[x].values.tolist() for x in df.columns))) From 91ba4f642425e239d77acb4ef4a9f211dabe992d Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 13:31:26 +0100 Subject: [PATCH 06/19] Add test for volatilityFilter sorting --- tests/plugins/test_pairlist.py | 35 +++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 64203d9a6..f16f5dce9 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -19,7 +19,7 @@ from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist, from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.resolvers import PairListResolver from freqtrade.util.datetime_helpers import dt_now -from tests.conftest import (EXMS, create_mock_trades_usdt, get_patched_exchange, +from tests.conftest import (EXMS, create_mock_trades_usdt, generate_test_data, get_patched_exchange, get_patched_freqtradebot, log_has, log_has_re, num_log_has) @@ -774,6 +774,39 @@ def test_VolatilityFilter_error(mocker, whitelist_conf) -> None: PairListManager(exchange_mock, whitelist_conf, MagicMock()) +@pytest.mark.parametrize('sort_direction', ['asc', 'desc']) +def test_VolatilityFilter_sort(mocker, whitelist_conf, time_machine, sort_direction) -> None: + volatility_filter = {"method": "VolatilityFilter", "sort_direction": sort_direction} + whitelist_conf['pairlists'] = [{"method": "StaticPairList"}, volatility_filter] + + df1 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=42) + df2 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=1) + assert not df1.equals(df2) + time_machine.move_to('2022-01-15 00:00:00+00:00') + + ohlcv_data = { + ('ETH/BTC', '1d', CandleType.SPOT): df1, + ('TKN/BTC', '1d', CandleType.SPOT): df2, + + } + mocker.patch.multiple( + EXMS, + exchange_has=MagicMock(return_value=True), + refresh_latest_ohlcv=MagicMock(return_value=ohlcv_data), + ) + + exchange = get_patched_exchange(mocker, whitelist_conf) + exchange.ohlcv_candle_limit = MagicMock(return_value=1000) + plm = PairListManager(exchange, whitelist_conf, MagicMock()) + + assert exchange.ohlcv_candle_limit.call_count == 1 + plm.refresh_pairlist() + assert exchange.ohlcv_candle_limit.call_count == 1 + assert plm.whitelist == ( + ['ETH/BTC', 'TKN/BTC'] if sort_direction == 'asc' else ['TKN/BTC', 'ETH/BTC'] + ) + + def test_ShuffleFilter_init(mocker, whitelist_conf, caplog) -> None: whitelist_conf['pairlists'] = [ {"method": "StaticPairList"}, From 866ff55d840d747e51995379d677e81735a6fad2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 13:34:42 +0100 Subject: [PATCH 07/19] document sort_direction mode --- docs/includes/pairlists.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index d1dd2cda7..844e30ff9 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -460,7 +460,7 @@ Volatility is the degree of historical variation of a pairs over time, it is mea This filter removes pairs if the average volatility over a `lookback_days` days is below `min_volatility` or above `max_volatility`. Since this is a filter that requires additional data, the results are cached for `refresh_period`. -This filter can be used to narrow down your pairs to a certain volatility or avoid very volatile pairs. +This filter can be used to narrow down your pairs to a certain volatility or avoid very volatile pairs. In the below example: If the volatility over the last 10 days is not in the range of 0.05-0.50, remove the pair from the whitelist. The filter is applied every 24h. @@ -477,6 +477,9 @@ If the volatility over the last 10 days is not in the range of 0.05-0.50, remove ] ``` +Adding `"sort_direction": "asc"` or `"sort_direction": "desc"` enables sorting mode for this pairlist. +When sorting, caching will be applied at the candle level - ignoring `refresh_period` (the candle's won't change anyway). + ### Full example of Pairlist Handlers The below example blacklists `BNB/BTC`, uses `VolumePairList` with `20` assets, sorting pairs by `quoteVolume` and applies [`PrecisionFilter`](#precisionfilter) and [`PriceFilter`](#pricefilter), filtering all assets where 1 price unit is > 1%. Then the [`SpreadFilter`](#spreadfilter) and [`VolatilityFilter`](#volatilityfilter) is applied and pairs are finally shuffled with the random seed set to some predefined value. From 88a2995b4c66baa0790493a1c291113bfc70354f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 13:39:46 +0100 Subject: [PATCH 08/19] Fix wrong typehint --- freqtrade/plugins/pairlist/VolatilityFilter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index ff1525d70..d71d13a4d 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -148,7 +148,7 @@ class VolatilityFilter(IPairList): """ Validate trading range :param pair: Pair that's currently validated - :param daily_candles: Downloaded daily candles + :param volatility_avg: Average volatility :return: True if the pair can stay, false if it should be removed """ # Check symbol in cache From 7af46628f8386bb6061090d2d04739906b675a9d Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 13:50:54 +0100 Subject: [PATCH 09/19] Simplify rangeStability Filter --- .../plugins/pairlist/rangestabilityfilter.py | 81 ++++++++++--------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 49fba59b9..d66ea92ec 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -2,9 +2,8 @@ Rate of change pairlist filter """ import logging -from copy import deepcopy from datetime import timedelta -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from cachetools import TTLCache from pandas import DataFrame @@ -103,45 +102,55 @@ class RangeStabilityFilter(IPairList): since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days + 1)) candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms) - if self._enabled: - for p in deepcopy(pairlist): - daily_candles = candles[(p, '1d', self._def_candletype)] if ( - p, '1d', self._def_candletype) in candles else None - if not self._validate_pair_loc(p, daily_candles): - pairlist.remove(p) - return pairlist + resulting_pairlist: List[str] = [] - def _validate_pair_loc(self, pair: str, daily_candles: Optional[DataFrame]) -> bool: - """ - Validate trading range - :param pair: Pair that's currently validated - :param daily_candles: Downloaded daily candles - :return: True if the pair can stay, false if it should be removed - """ + for p in pairlist: + daily_candles = candles.get((p, '1d', self._def_candletype), None) + + pct_change = self._calculate_rate_of_change(p, daily_candles) + + if pct_change is not None and self._validate_pair_loc(p, pct_change): + resulting_pairlist.append(p) + else: + self.log_once(f"Removed {p} from whitelist, no candles found.", logger.info) + + return resulting_pairlist + + def _calculate_rate_of_change(self, pair: str, daily_candles: DataFrame) -> float: # Check symbol in cache - if (cached_res := self._pair_cache.get(pair, None)) is not None: - return cached_res - - result = True + if (pct_change := self._pair_cache.get(pair, None)) is not None: + return pct_change if daily_candles is not None and not daily_candles.empty: + highest_high = daily_candles['high'].max() lowest_low = daily_candles['low'].min() pct_change = ((highest_high - lowest_low) / lowest_low) if lowest_low > 0 else 0 - if pct_change < self._min_rate_of_change: - self.log_once(f"Removed {pair} from whitelist, because rate of change " - f"over {self._days} {plural(self._days, 'day')} is {pct_change:.3f}, " - f"which is below the threshold of {self._min_rate_of_change}.", - logger.info) - result = False - if self._max_rate_of_change: - if pct_change > self._max_rate_of_change: - self.log_once( - f"Removed {pair} from whitelist, because rate of change " - f"over {self._days} {plural(self._days, 'day')} is {pct_change:.3f}, " - f"which is above the threshold of {self._max_rate_of_change}.", - logger.info) - result = False - self._pair_cache[pair] = result + self._pair_cache[pair] = pct_change + return pct_change else: - self.log_once(f"Removed {pair} from whitelist, no candles found.", logger.info) + return None + + def _validate_pair_loc(self, pair: str, pct_change: float) -> bool: + """ + Validate trading range + :param pair: Pair that's currently validated + :param pct_change: Rate of change + :return: True if the pair can stay, false if it should be removed + """ + + result = True + if pct_change < self._min_rate_of_change: + self.log_once(f"Removed {pair} from whitelist, because rate of change " + f"over {self._days} {plural(self._days, 'day')} is {pct_change:.3f}, " + f"which is below the threshold of {self._min_rate_of_change}.", + logger.info) + result = False + if self._max_rate_of_change: + if pct_change > self._max_rate_of_change: + self.log_once( + f"Removed {pair} from whitelist, because rate of change " + f"over {self._days} {plural(self._days, 'day')} is {pct_change:.3f}, " + f"which is above the threshold of {self._max_rate_of_change}.", + logger.info) + result = False return result From 3677953d90e6b93e98bb9f6294a5959428750a46 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 13:54:52 +0100 Subject: [PATCH 10/19] Properly cache volatility-average --- docs/includes/pairlists.md | 1 - .../plugins/pairlist/VolatilityFilter.py | 42 +++++++++---------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 844e30ff9..51c38fcce 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -478,7 +478,6 @@ If the volatility over the last 10 days is not in the range of 0.05-0.50, remove ``` Adding `"sort_direction": "asc"` or `"sort_direction": "desc"` enables sorting mode for this pairlist. -When sorting, caching will be applied at the candle level - ignoring `refresh_period` (the candle's won't change anyway). ### Full example of Pairlist Handlers diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index d71d13a4d..36f24af4b 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -119,16 +119,14 @@ class VolatilityFilter(IPairList): resulting_pairlist: List[str] = [] volatilitys: Dict[str, float] = {} for p in pairlist: - daily_candles = candles[(p, '1d', self._def_candletype)] if ( - p, '1d', self._def_candletype) in candles else None + daily_candles = candles.get((p, '1d', self._def_candletype), None) - if daily_candles is not None and not daily_candles.empty: - volatility_avg = self._calculate_volatility(deepcopy(daily_candles)) + volatility_avg = self._calculate_volatility(p, daily_candles) - if self._validate_pair_loc(p, volatility_avg): - resulting_pairlist.append(p) - if self._sort_direction: - volatilitys[p] = volatility_avg if not np.isnan(volatility_avg) else 0 + if volatility_avg is not None and self._validate_pair_loc(p, volatility_avg): + resulting_pairlist.append(p) + if self._sort_direction: + volatilitys[p] = volatility_avg if not np.isnan(volatility_avg) else 0 if self._sort_direction: resulting_pairlist = sorted(resulting_pairlist, @@ -136,13 +134,22 @@ class VolatilityFilter(IPairList): reverse=self._sort_direction == 'desc') return resulting_pairlist - def _calculate_volatility(self, daily_candles: DataFrame) -> float: - returns = (np.log(daily_candles["close"].shift(1) / daily_candles["close"])) - returns.fillna(0, inplace=True) + def _calculate_volatility(self, pair: str, daily_candles: DataFrame) -> float: + # Check symbol in cache + if (volatility_avg := self._pair_cache.get(pair, None)) is not None: + return volatility_avg - volatility_series = returns.rolling(window=self._days).std() * np.sqrt(self._days) - volatility_avg = volatility_series.mean() - return volatility_avg + if daily_candles is not None and not daily_candles.empty: + returns = (np.log(daily_candles["close"].shift(1) / daily_candles["close"])) + returns.fillna(0, inplace=True) + + volatility_series = returns.rolling(window=self._days).std() * np.sqrt(self._days) + volatility_avg = volatility_series.mean() + self._pair_cache[pair] = volatility_avg + + return volatility_avg + else: + return None def _validate_pair_loc(self, pair: str, volatility_avg: float) -> bool: """ @@ -151,11 +158,6 @@ class VolatilityFilter(IPairList): :param volatility_avg: Average volatility :return: True if the pair can stay, false if it should be removed """ - # Check symbol in cache - if (cached_res := self._pair_cache.get(pair, None)) is not None: - return cached_res - - result = False if self._min_volatility <= volatility_avg <= self._max_volatility: result = True @@ -167,6 +169,4 @@ class VolatilityFilter(IPairList): f"{self._min_volatility}-{self._max_volatility}.", logger.info) result = False - self._pair_cache[pair] = result - return result From 81de29a1e3b9f28dc6c77bd4c8f28d1fe66cbc83 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 14:00:50 +0100 Subject: [PATCH 11/19] Improve conditions for removal of pairs --- freqtrade/plugins/pairlist/VolatilityFilter.py | 8 ++++++-- freqtrade/plugins/pairlist/rangestabilityfilter.py | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 36f24af4b..224dfcca8 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -123,8 +123,12 @@ class VolatilityFilter(IPairList): volatility_avg = self._calculate_volatility(p, daily_candles) - if volatility_avg is not None and self._validate_pair_loc(p, volatility_avg): - resulting_pairlist.append(p) + if volatility_avg is not None: + if self._validate_pair_loc(p, volatility_avg): + resulting_pairlist.append(p) + else: + self.log_once(f"Removed {p} from whitelist, no candles found.", logger.info) + if self._sort_direction: volatilitys[p] = volatility_avg if not np.isnan(volatility_avg) else 0 diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index d66ea92ec..ff0ec80e4 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -109,8 +109,9 @@ class RangeStabilityFilter(IPairList): pct_change = self._calculate_rate_of_change(p, daily_candles) - if pct_change is not None and self._validate_pair_loc(p, pct_change): - resulting_pairlist.append(p) + if pct_change is not None: + if self._validate_pair_loc(p, pct_change): + resulting_pairlist.append(p) else: self.log_once(f"Removed {p} from whitelist, no candles found.", logger.info) From 6a313aa9e38210823e9372acc07d301cda138dfb Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 14:03:26 +0100 Subject: [PATCH 12/19] Improve help wording --- freqtrade/plugins/pairlist/VolatilityFilter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 224dfcca8..301a92b7d 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -98,7 +98,7 @@ class VolatilityFilter(IPairList): "default": None, "options": [None, "asc", "desc"], "description": "Sort pairlist", - "help": "Sort Pairlist", + "help": "Sort Pairlist ascending or descending by volatility.", }, **IPairList.refresh_period_parameter() } From 9dd59672756fe2a9f85ac5d6dcc34222fa8b4e9c Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 14:03:50 +0100 Subject: [PATCH 13/19] Add sorting capabilities to rangeStabilityFilter --- .../plugins/pairlist/rangestabilityfilter.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index ff0ec80e4..0bd35997c 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -3,7 +3,7 @@ Rate of change pairlist filter """ import logging from datetime import timedelta -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from cachetools import TTLCache from pandas import DataFrame @@ -31,6 +31,7 @@ class RangeStabilityFilter(IPairList): self._max_rate_of_change = pairlistconfig.get('max_rate_of_change') self._refresh_period = pairlistconfig.get('refresh_period', 86400) self._def_candletype = self._config['candle_type_def'] + self._sort_direction: Optional[str] = pairlistconfig.get('sort_direction', None) self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period) @@ -40,7 +41,9 @@ class RangeStabilityFilter(IPairList): if self._days > candle_limit: raise OperationalException("RangeStabilityFilter requires lookback_days to not " f"exceed exchange max request size ({candle_limit})") - + if self._sort_direction not in [None, 'asc', 'desc']: + raise OperationalException("RangeStabilityFilter requires sort_direction to be " + "either None (undefined), 'asc' or 'desc'") @property def needstickers(self) -> bool: """ @@ -86,6 +89,13 @@ class RangeStabilityFilter(IPairList): "description": "Maximum Rate of Change", "help": "Maximum rate of change to filter pairs.", }, + "sort_direction": { + "type": "option", + "default": None, + "options": [None, "asc", "desc"], + "description": "Sort pairlist", + "help": "Sort Pairlist ascending or descending by rate of change.", + }, **IPairList.refresh_period_parameter() } @@ -103,6 +113,7 @@ class RangeStabilityFilter(IPairList): candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms) resulting_pairlist: List[str] = [] + pct_changes: Dict[str, float] = {} for p in pairlist: daily_candles = candles.get((p, '1d', self._def_candletype), None) @@ -112,9 +123,14 @@ class RangeStabilityFilter(IPairList): if pct_change is not None: if self._validate_pair_loc(p, pct_change): resulting_pairlist.append(p) + pct_changes[p] = pct_change else: self.log_once(f"Removed {p} from whitelist, no candles found.", logger.info) + if self._sort_direction: + resulting_pairlist = sorted(resulting_pairlist, + key=lambda p: pct_changes[p], + reverse=self._sort_direction == 'desc') return resulting_pairlist def _calculate_rate_of_change(self, pair: str, daily_candles: DataFrame) -> float: From 2704f6e758e9a0a926bd2599f329dc51834e2cd1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 14:05:25 +0100 Subject: [PATCH 14/19] Improve test --- tests/plugins/test_pairlist.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index f16f5dce9..43a99df33 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -789,10 +789,11 @@ def test_VolatilityFilter_sort(mocker, whitelist_conf, time_machine, sort_direct ('TKN/BTC', '1d', CandleType.SPOT): df2, } + ohlcv_mock = MagicMock(return_value=ohlcv_data) mocker.patch.multiple( EXMS, exchange_has=MagicMock(return_value=True), - refresh_latest_ohlcv=MagicMock(return_value=ohlcv_data), + refresh_latest_ohlcv=ohlcv_mock, ) exchange = get_patched_exchange(mocker, whitelist_conf) @@ -801,11 +802,16 @@ def test_VolatilityFilter_sort(mocker, whitelist_conf, time_machine, sort_direct assert exchange.ohlcv_candle_limit.call_count == 1 plm.refresh_pairlist() + assert ohlcv_mock.call_count == 1 assert exchange.ohlcv_candle_limit.call_count == 1 assert plm.whitelist == ( ['ETH/BTC', 'TKN/BTC'] if sort_direction == 'asc' else ['TKN/BTC', 'ETH/BTC'] ) + plm.refresh_pairlist() + assert exchange.ohlcv_candle_limit.call_count == 1 + assert ohlcv_mock.call_count == 1 + def test_ShuffleFilter_init(mocker, whitelist_conf, caplog) -> None: whitelist_conf['pairlists'] = [ From b972ee78ec34bff99d1b111452201be45bfa3a45 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 14:41:06 +0100 Subject: [PATCH 15/19] Enhance rangeStability test --- tests/plugins/test_pairlist.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 43a99df33..6cf331c86 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1160,6 +1160,13 @@ def test_rangestabilityfilter_checks(mocker, default_conf, markets, tickers): match='RangeStabilityFilter requires lookback_days to be >= 1'): get_patched_freqtradebot(mocker, default_conf) + default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}, + {'method': 'RangeStabilityFilter', 'sort_direction': 'something'}] + + with pytest.raises(OperationalException, + match='RangeStabilityFilter requires sort_direction to be either None.*'): + get_patched_freqtradebot(mocker, default_conf) + @pytest.mark.parametrize('min_rate_of_change,max_rate_of_change,expected_length', [ (0.01, 0.99, 5), From e82d9e2f5568c1f2847fb2cbf7cfe5d8cf944973 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 14:45:15 +0100 Subject: [PATCH 16/19] Test volatilityfilter with more pairs --- tests/plugins/test_pairlist.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 6cf331c86..5d7497273 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -775,18 +775,29 @@ def test_VolatilityFilter_error(mocker, whitelist_conf) -> None: @pytest.mark.parametrize('sort_direction', ['asc', 'desc']) -def test_VolatilityFilter_sort(mocker, whitelist_conf, time_machine, sort_direction) -> None: - volatility_filter = {"method": "VolatilityFilter", "sort_direction": sort_direction} - whitelist_conf['pairlists'] = [{"method": "StaticPairList"}, volatility_filter] +def test_VolatilityFilter_sort( + mocker, whitelist_conf, tickers, time_machine, sort_direction) -> None: + whitelist_conf['pairlists'] = [ + {'method': 'VolumePairList', 'number_assets': 10}, + {"method": "VolatilityFilter", "sort_direction": sort_direction}] df1 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=42) - df2 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=1) + df2 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=2) + df3 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=3) + df4 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=4) + df5 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=5) + df6 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=6) + assert not df1.equals(df2) time_machine.move_to('2022-01-15 00:00:00+00:00') ohlcv_data = { ('ETH/BTC', '1d', CandleType.SPOT): df1, ('TKN/BTC', '1d', CandleType.SPOT): df2, + ('LTC/BTC', '1d', CandleType.SPOT): df3, + ('XRP/BTC', '1d', CandleType.SPOT): df4, + ('HOT/BTC', '1d', CandleType.SPOT): df5, + ('BLK/BTC', '1d', CandleType.SPOT): df6, } ohlcv_mock = MagicMock(return_value=ohlcv_data) @@ -794,22 +805,25 @@ def test_VolatilityFilter_sort(mocker, whitelist_conf, time_machine, sort_direct EXMS, exchange_has=MagicMock(return_value=True), refresh_latest_ohlcv=ohlcv_mock, + get_tickers=tickers + ) exchange = get_patched_exchange(mocker, whitelist_conf) exchange.ohlcv_candle_limit = MagicMock(return_value=1000) plm = PairListManager(exchange, whitelist_conf, MagicMock()) - assert exchange.ohlcv_candle_limit.call_count == 1 + assert exchange.ohlcv_candle_limit.call_count == 2 plm.refresh_pairlist() assert ohlcv_mock.call_count == 1 - assert exchange.ohlcv_candle_limit.call_count == 1 + assert exchange.ohlcv_candle_limit.call_count == 2 assert plm.whitelist == ( - ['ETH/BTC', 'TKN/BTC'] if sort_direction == 'asc' else ['TKN/BTC', 'ETH/BTC'] + ['XRP/BTC', 'ETH/BTC', 'LTC/BTC', 'TKN/BTC'] if sort_direction == 'asc' + else ['TKN/BTC', 'LTC/BTC', 'ETH/BTC', 'XRP/BTC'] ) plm.refresh_pairlist() - assert exchange.ohlcv_candle_limit.call_count == 1 + assert exchange.ohlcv_candle_limit.call_count == 2 assert ohlcv_mock.call_count == 1 From 67152ad48a1fbf7a621acb779fe4b7aa2b200cac Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 14:56:42 +0100 Subject: [PATCH 17/19] Improve and parametrize pairlist tests --- .../plugins/pairlist/VolatilityFilter.py | 1 - .../plugins/pairlist/rangestabilityfilter.py | 1 + tests/plugins/test_pairlist.py | 35 ++++++++++++++----- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 301a92b7d..ca375fcda 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -3,7 +3,6 @@ Volatility pairlist filter """ import logging import sys -from copy import deepcopy from datetime import timedelta from typing import Any, Dict, List, Optional diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 0bd35997c..730bb3d78 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -44,6 +44,7 @@ class RangeStabilityFilter(IPairList): if self._sort_direction not in [None, 'asc', 'desc']: raise OperationalException("RangeStabilityFilter requires sort_direction to be " "either None (undefined), 'asc' or 'desc'") + @property def needstickers(self) -> bool: """ diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 5d7497273..57affc731 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -774,12 +774,34 @@ def test_VolatilityFilter_error(mocker, whitelist_conf) -> None: PairListManager(exchange_mock, whitelist_conf, MagicMock()) -@pytest.mark.parametrize('sort_direction', ['asc', 'desc']) -def test_VolatilityFilter_sort( - mocker, whitelist_conf, tickers, time_machine, sort_direction) -> None: +@pytest.mark.parametrize('pairlist,expected_pairlist', [ + ({"method": "VolatilityFilter", "sort_direction": "asc"}, + ['XRP/BTC', 'ETH/BTC', 'LTC/BTC', 'TKN/BTC']), + ({"method": "VolatilityFilter", "sort_direction": "desc"}, + ['TKN/BTC', 'LTC/BTC', 'ETH/BTC', 'XRP/BTC']), + ({"method": "VolatilityFilter", "sort_direction": "desc", 'min_volatility': 0.4}, + ['TKN/BTC', 'LTC/BTC', 'ETH/BTC']), + ({"method": "VolatilityFilter", "sort_direction": "asc", 'min_volatility': 0.4}, + ['ETH/BTC', 'LTC/BTC', 'TKN/BTC']), + ({"method": "VolatilityFilter", "sort_direction": "desc", 'max_volatility': 0.5}, + ['LTC/BTC', 'ETH/BTC', 'XRP/BTC']), + ({"method": "VolatilityFilter", "sort_direction": "asc", 'max_volatility': 0.5}, + ['XRP/BTC', 'ETH/BTC', 'LTC/BTC']), + ({"method": "RangeStabilityFilter", "sort_direction": "asc"}, + ['ETH/BTC', 'XRP/BTC', 'LTC/BTC', 'TKN/BTC']), + ({"method": "RangeStabilityFilter", "sort_direction": "desc"}, + ['TKN/BTC', 'LTC/BTC', 'XRP/BTC', 'ETH/BTC']), + ({"method": "RangeStabilityFilter", "sort_direction": "asc", 'min_rate_of_change': 0.4}, + ['XRP/BTC', 'LTC/BTC', 'TKN/BTC']), + ({"method": "RangeStabilityFilter", "sort_direction": "desc", 'min_rate_of_change': 0.4}, + ['TKN/BTC', 'LTC/BTC', 'XRP/BTC']), +]) +def test_VolatilityFilter_RangeStabilityFilter_sort( + mocker, whitelist_conf, tickers, time_machine, pairlist, expected_pairlist) -> None: whitelist_conf['pairlists'] = [ {'method': 'VolumePairList', 'number_assets': 10}, - {"method": "VolatilityFilter", "sort_direction": sort_direction}] + pairlist + ] df1 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=42) df2 = generate_test_data('1d', 10, '2022-01-05 00:00:00+00:00', random_seed=2) @@ -817,10 +839,7 @@ def test_VolatilityFilter_sort( plm.refresh_pairlist() assert ohlcv_mock.call_count == 1 assert exchange.ohlcv_candle_limit.call_count == 2 - assert plm.whitelist == ( - ['XRP/BTC', 'ETH/BTC', 'LTC/BTC', 'TKN/BTC'] if sort_direction == 'asc' - else ['TKN/BTC', 'LTC/BTC', 'ETH/BTC', 'XRP/BTC'] - ) + assert plm.whitelist == expected_pairlist plm.refresh_pairlist() assert exchange.ohlcv_candle_limit.call_count == 2 From 817ad6440280ed2fd417307766676c1a89ab8577 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 15:00:28 +0100 Subject: [PATCH 18/19] Add docs for rangeStability sorting --- docs/includes/pairlists.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 51c38fcce..960f2d210 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -450,6 +450,8 @@ If the trading range over the last 10 days is <1% or >99%, remove the pair from ] ``` +Adding `"sort_direction": "asc"` or `"sort_direction": "desc"` enables sorting for this pairlist. + !!! Tip This Filter can be used to automatically remove stable coin pairs, which have a very low trading range, and are therefore extremely difficult to trade with profit. Additionally, it can also be used to automatically remove pairs with extreme high/low variance over a given amount of time. From e80ad309f1b7b36ac23e5a7d09cb04c21f8f6465 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Feb 2024 15:04:54 +0100 Subject: [PATCH 19/19] Improve type safety, refactor volatilityfilter --- freqtrade/plugins/pairlist/VolatilityFilter.py | 10 +++++----- freqtrade/plugins/pairlist/rangestabilityfilter.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index ca375fcda..cdd171e91 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -95,7 +95,7 @@ class VolatilityFilter(IPairList): "sort_direction": { "type": "option", "default": None, - "options": [None, "asc", "desc"], + "options": ["", "asc", "desc"], "description": "Sort pairlist", "help": "Sort Pairlist ascending or descending by volatility.", }, @@ -125,19 +125,19 @@ class VolatilityFilter(IPairList): if volatility_avg is not None: if self._validate_pair_loc(p, volatility_avg): resulting_pairlist.append(p) + volatilitys[p] = ( + volatility_avg if volatility_avg and not np.isnan(volatility_avg) else 0 + ) else: self.log_once(f"Removed {p} from whitelist, no candles found.", logger.info) - if self._sort_direction: - volatilitys[p] = volatility_avg if not np.isnan(volatility_avg) else 0 - if self._sort_direction: resulting_pairlist = sorted(resulting_pairlist, key=lambda p: volatilitys[p], reverse=self._sort_direction == 'desc') return resulting_pairlist - def _calculate_volatility(self, pair: str, daily_candles: DataFrame) -> float: + def _calculate_volatility(self, pair: str, daily_candles: DataFrame) -> Optional[float]: # Check symbol in cache if (volatility_avg := self._pair_cache.get(pair, None)) is not None: return volatility_avg diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 730bb3d78..0480f60d0 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -93,7 +93,7 @@ class RangeStabilityFilter(IPairList): "sort_direction": { "type": "option", "default": None, - "options": [None, "asc", "desc"], + "options": ["", "asc", "desc"], "description": "Sort pairlist", "help": "Sort Pairlist ascending or descending by rate of change.", }, @@ -134,7 +134,7 @@ class RangeStabilityFilter(IPairList): reverse=self._sort_direction == 'desc') return resulting_pairlist - def _calculate_rate_of_change(self, pair: str, daily_candles: DataFrame) -> float: + def _calculate_rate_of_change(self, pair: str, daily_candles: DataFrame) -> Optional[float]: # Check symbol in cache if (pct_change := self._pair_cache.get(pair, None)) is not None: return pct_change