diff --git a/build_helpers/schema.json b/build_helpers/schema.json index 7c4ea3596..100520c40 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -658,6 +658,7 @@ "DelistFilter", "FullTradesFilter", "OffsetFilter", + "PairInformationFilter", "PerformanceFilter", "PrecisionFilter", "PriceFilter", diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 37e5b369c..c34ace15c 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -31,6 +31,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged * [`DelistFilter`](#delistfilter) * [`FullTradesFilter`](#fulltradesfilter) * [`OffsetFilter`](#offsetfilter) +* [`PairInformationFilter`](#pairinformationfilter) * [`PerformanceFilter`](#performancefilter) * [`PrecisionFilter`](#precisionfilter) * [`PriceFilter`](#pricefilter) @@ -469,6 +470,65 @@ Example to remove the first 10 pairs from the pairlist, and takes the next 20 (t !!! Note An offset larger than the total length of the incoming pairlist will result in an empty pairlist. +#### PairInformationFilter + +Filters pairs based on the presence of certain information in the ccxt markets argument. + +To get the correct field - you can use the following code snippet to print the market information for a given pair, and find the correct field and value to filter for: + +``` python +import ccxt +from pprint import pprint +exchange = ccxt.binance({ + 'options': {'defaultType': 'swap'} + }) +lm = exchange.load_markets() +pprint(lm['XAU/USDT:USDT']) +``` + +``` json hl_lines="13 16" +{ + "id": "XAUUSDT", + "lowercaseId": "xauusdt", + "symbol": "XAU/USDT:USDT", + "base": "XAU", + "quote": "USDT", + "settle": "USDT", + "baseId": "XAU", + "quoteId": "USDT", + "settleId": "USDT", + "type": "swap", + // ... + "info": { + "symbol": "XAUUSDT", + "pair": "XAUUSDT", + "contractType": "TRADIFI_PERPETUAL", + "deliveryDate": 4133404800000, + "onboardDate": 1765440300000, + "status": "TRADING", + // ... + } +} + +``` + +As the highlighted lines show, the `contractType` field in the `info` section of the market data contains the value `TRADIFI_PERPETUAL`, which can be used to filter for TradeFi perpetual contracts. +Nested dictionaries can be accessed by using dot notation in the `info_key` - so the `contractType` field can be accessed with `info.contractType`. + +In this example, the resulting `PairInformationFilter` configuration will include pairs that have `TRADIFI_PERPETUAL` as their `contractType` in the `info` section of the market data. + +``` json +[ + // ... + { + "method": "PairInformationFilter", + "selection_mode": "whitelist", // can be whitelist or blacklist + "info_key" : "info.contractType", // can be any key in market data + "info_compare_value": "TRADIFI_PERPETUAL", // can be any matching value + } +] +``` + #### PerformanceFilter Sorts pairs by past trade performance, as follows: diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 3182a9d76..e86dde00d 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -66,6 +66,7 @@ AVAILABLE_PAIRLISTS = [ "DelistFilter", "FullTradesFilter", "OffsetFilter", + "PairInformationFilter", "PerformanceFilter", "PrecisionFilter", "PriceFilter", diff --git a/freqtrade/plugins/pairlist/PairInformationFilter.py b/freqtrade/plugins/pairlist/PairInformationFilter.py new file mode 100644 index 000000000..69b0811e6 --- /dev/null +++ b/freqtrade/plugins/pairlist/PairInformationFilter.py @@ -0,0 +1,91 @@ +"""Pair Information filter""" + +import logging + +from freqtrade.exceptions import OperationalException +from freqtrade.exchange.exchange_types import Tickers +from freqtrade.misc import safe_value_nested +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting + + +logger = logging.getLogger(__name__) + + +class PairInformationFilter(IPairList): + supports_backtesting = SupportsBacktesting.BIASED + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self._selection_mode: str = self._pairlistconfig.get("selection_mode", "whitelist") + self._info_key: str = self._pairlistconfig.get("info_key", "") + self._info_compare_value: str = self._pairlistconfig.get("info_compare_value", "") + + if not self._info_key: + raise OperationalException( + "`info_key` not specified. Please check your configuration " + 'for "pairlist.config.info_key"' + ) + if not self._info_compare_value: + raise OperationalException( + "`info_compare_value` not specified. Please check your configuration " + 'for "pairlist.config.info_compare_value"' + ) + + if self._selection_mode not in ["whitelist", "blacklist"]: + raise OperationalException( + "`selection_mode` not configured correctly. " + "Supported Modes are `whitelist` and `blacklist`" + ) + + def short_desc(self) -> str: + """ + Short whitelist method description - used for startup-messages + """ + return ( + f"{self.name} - Returns {self._selection_mode} pairs by comparing " + f"{self._info_key} matches {self._info_compare_value}." + ) + + @staticmethod + def description() -> str: + return "Filter pairs based upon any information in their market data." + + @staticmethod + def available_parameters() -> dict[str, PairlistParameter]: + return { + "selection_mode": { + "type": "option", + "default": "whitelist", + "options": ["whitelist", "blacklist"], + "description": "Whether to use filter as whitelist or blacklist", + "help": "Whether to use filter as whitelist or blacklist", + }, + "info_key": { + "type": "string", + "default": "", + "description": "The key in the market data to compare against", + "help": "The key in the market data to compare against", + }, + "info_compare_value": { + "type": "string", + "default": "", + "description": "The value to compare the key against", + "help": "The value to compare the key against", + }, + } + + def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: + whitelist_or_blacklist = self._selection_mode == "whitelist" + whitelist_pairlist: list[str] = [] + blacklist_pairlist: list[str] = [] + + # loop through and add them to either list based on the market info check + for pair in pairlist: + market = self._exchange.markets[pair] + if safe_value_nested(market, self._info_key, "") == self._info_compare_value: + whitelist_pairlist.append(pair) + else: + blacklist_pairlist.append(pair) + + return whitelist_pairlist if whitelist_or_blacklist else blacklist_pairlist diff --git a/tests/plugins/test_pairinformationfilter.py b/tests/plugins/test_pairinformationfilter.py new file mode 100644 index 000000000..0eed2aa51 --- /dev/null +++ b/tests/plugins/test_pairinformationfilter.py @@ -0,0 +1,171 @@ +from copy import deepcopy + +import pytest + +from freqtrade.exceptions import OperationalException +from freqtrade.plugins.pairlistmanager import PairListManager +from tests.conftest import get_markets, get_patched_exchange + + +@pytest.fixture(scope="function") +def pif_config(default_conf_usdt): + + default_conf_usdt["exchange"]["pair_whitelist"] = [ + "ETH/USDT", + "XRP/USDT", + "BTC/USDT", + ] + default_conf_usdt["exchange"]["pair_blacklist"] = ["BLK/USDT"] + + return default_conf_usdt + + +@pytest.mark.parametrize( + "missing_key,error_msg", + [ + ("info_key", "`info_key` not specified"), + ("info_compare_value", "`info_compare_value` not specified"), + ("selection_mode", "`selection_mode` not configured correctly"), + ], +) +def test_PairInformationFilter_validation(mocker, pif_config, missing_key, error_msg): + + pif_config["pairlists"] = [ + { + "method": "PairInformationFilter", + "selection_mode": "whitelist", + "info_key": "info.contractType", + "info_compare_value": "TRADIFI_PERPETUAL", + "refresh_period": 1800, + } + ] + exchange = get_patched_exchange(mocker, pif_config) + + with pytest.raises(OperationalException, match=error_msg): + if missing_key == "selection_mode": + pif_config["pairlists"][0]["selection_mode"] = "invalid_mode" + else: + pif_config["pairlists"][0].pop(missing_key) + PairListManager(exchange, pif_config) + + +def test_PairInformationFilter_filter_float(mocker, pif_config): + + pif_config["pairlists"] = [ + { + "method": "StaticPairList", + }, + { + "method": "PairInformationFilter", + "selection_mode": "whitelist", + "info_key": "limits.amount.min", + "info_compare_value": 0.01, + "refresh_period": 1800, + }, + ] + exchange = get_patched_exchange(mocker, pif_config) + pairlist_manager = PairListManager(exchange, pif_config) + + pairlist_manager.refresh_pairlist() + pairlist = pairlist_manager.whitelist + assert set(pairlist) == {"XRP/USDT"} + + +def _get_pairinformation_test_markets() -> dict: + markets = get_markets() + custom_markets = { + pair: deepcopy(markets[pair]) for pair in ["ETH/USDT", "XRP/USDT", "BTC/USDT"] + } + custom_markets["ETH/USDT"]["info"] = {"contractType": "TRADIFI_PERPETUAL"} + custom_markets["XRP/USDT"]["info"] = {} + custom_markets["BTC/USDT"]["info"] = {"contractType": "CURRENT_QUARTER"} + return custom_markets + + +def test_PairInformationFilter_filter_nested_info_string_whitelist(mocker, pif_config): + markets = _get_pairinformation_test_markets() + + pif_config["pairlists"] = [ + { + "method": "StaticPairList", + }, + { + "method": "PairInformationFilter", + "selection_mode": "whitelist", + "info_key": "info.contractType", + "info_compare_value": "TRADIFI_PERPETUAL", + "refresh_period": 1800, + }, + ] + exchange = get_patched_exchange(mocker, pif_config, mock_markets=markets) + pairlist_manager = PairListManager(exchange, pif_config) + + pairlist_manager.refresh_pairlist() + pairlist = pairlist_manager.whitelist + assert pairlist == ["ETH/USDT"] + + +def test_PairInformationFilter_filter_nested_info_string_blacklist(mocker, pif_config): + markets = _get_pairinformation_test_markets() + + pif_config["pairlists"] = [ + { + "method": "StaticPairList", + }, + { + "method": "PairInformationFilter", + "selection_mode": "blacklist", + "info_key": "info.contractType", + "info_compare_value": "TRADIFI_PERPETUAL", + "refresh_period": 1800, + }, + ] + exchange = get_patched_exchange(mocker, pif_config, mock_markets=markets) + pairlist_manager = PairListManager(exchange, pif_config) + + pairlist_manager.refresh_pairlist() + pairlist = pairlist_manager.whitelist + assert pairlist == ["XRP/USDT", "BTC/USDT"] + + +def test_PairInformationFilter_filter_nested_info_combi(mocker, pif_config): + markets = _get_pairinformation_test_markets() + + pif_config["pairlists"] = [ + { + "method": "StaticPairList", + }, + { + "method": "PairInformationFilter", + "selection_mode": "blacklist", + "info_key": "info.contractType", + "info_compare_value": "TRADIFI_PERPETUAL", + "refresh_period": 1800, + }, + { + "method": "PairInformationFilter", + "selection_mode": "whitelist", + "info_key": "info.contractType", + "info_compare_value": "CURRENT_QUARTER", + "refresh_period": 1800, + }, + ] + exchange = get_patched_exchange(mocker, pif_config, mock_markets=markets) + pairlist_manager = PairListManager(exchange, pif_config) + + pairlist_manager.refresh_pairlist() + pairlist = pairlist_manager.whitelist + assert pairlist == ["BTC/USDT"] + + desc = pairlist_manager._pairlist_handlers[1].description() + assert desc == "Filter pairs based upon any information in their market data." + short_desc = pairlist_manager._pairlist_handlers[1].short_desc() + assert short_desc == ( + "PairInformationFilter - Returns blacklist pairs by comparing " + "info.contractType matches TRADIFI_PERPETUAL." + ) + short_desc2 = pairlist_manager._pairlist_handlers[2].short_desc() + assert short_desc2 == ( + "PairInformationFilter - Returns whitelist pairs by comparing " + "info.contractType matches CURRENT_QUARTER." + ) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 9d2c4bed8..27b61b1e8 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -31,9 +31,11 @@ from tests.conftest import ( ) -# Exclude RemotePairList from tests. -# It has a mandatory parameter, and requires special handling, which happens in test_remotepairlist. -TESTABLE_PAIRLISTS = [p for p in AVAILABLE_PAIRLISTS if p not in ["RemotePairList"]] +# Exclude RemotePairList and PairInformationFilter from tests. +# They have mandatory parameters, and require special handling, which happens in explicit tests. +TESTABLE_PAIRLISTS = [ + p for p in AVAILABLE_PAIRLISTS if p not in ["RemotePairList", "PairInformationFilter"] +] @pytest.fixture(scope="function")