From 9719f287957675238856670de6053678eccd2cfd Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 25 Feb 2026 15:51:56 +0900 Subject: [PATCH 01/11] add crossmarketfilter --- docs/includes/pairlists.md | 9 +- freqtrade/constants.py | 1 + .../plugins/pairlist/CrossMarketFilter.py | 99 +++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 freqtrade/plugins/pairlist/CrossMarketFilter.py diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 2ee7533f2..dc73223d5 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -4,7 +4,7 @@ Pairlist Handlers define the list of pairs (pairlist) that the bot should trade. In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list) and [`PercentChangePairList`](#percent-change-pair-list) Pairlist Handlers). -Additionally, [`AgeFilter`](#agefilter), [`DelistFilter`](#delistfilter), [`PrecisionFilter`](#precisionfilter), [`PriceFilter`](#pricefilter), [`ShuffleFilter`](#shufflefilter), [`SpreadFilter`](#spreadfilter) and [`VolatilityFilter`](#volatilityfilter) act as Pairlist Filters, removing certain pairs and/or moving their positions in the pairlist. +Additionally, [`AgeFilter`](#agefilter), [`CrossMarketFilter`](#crossmarketfilter), [`DelistFilter`](#delistfilter), [`PrecisionFilter`](#precisionfilter), [`PriceFilter`](#pricefilter), [`ShuffleFilter`](#shufflefilter), [`SpreadFilter`](#spreadfilter) and [`VolatilityFilter`](#volatilityfilter) act as Pairlist Filters, removing certain pairs and/or moving their positions in the pairlist. If multiple Pairlist Handlers are used, they are chained and a combination of all Pairlist Handlers forms the resulting pairlist the bot uses for trading and backtesting. Pairlist Handlers are executed in the sequence they are configured. You can define either `StaticPairList`, `VolumePairList`, `ProducerPairList`, `RemotePairList`, `MarketCapPairList` or `PercentChangePairList` as the starting Pairlist Handler. @@ -27,6 +27,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged * [`RemotePairList`](#remotepairlist) * [`MarketCapPairList`](#marketcappairlist) * [`AgeFilter`](#agefilter) +* [`CrossMarketFilter`](#crossmarketfilter) * [`DelistFilter`](#delistfilter) * [`FullTradesFilter`](#fulltradesfilter) * [`OffsetFilter`](#offsetfilter) @@ -412,6 +413,12 @@ be caught out buying before the pair has finished dropping in price. This filter allows freqtrade to ignore pairs until they have been listed for at least `min_days_listed` days and listed before `max_days_listed`. +#### CrossMarketFilter + +Filter pairs based of their availability on the opposite market. So for spot pairs, it will be checked against futures market, and vice versa. + +The `mode` setting defines whether the plugin will filters in (whitelist `mode`) or filters out (blacklist `mode`) based of the availability on the opposite market. By default, the plugin will be in whitelist mode. + #### DelistFilter Removes pairs that will be delisted on the exchange maximum `max_days_from_now` days from now (defaults to `0` which remove all future delisted pairs no matter how far from now). Currently this filter only supports following exchanges: diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 5a57f773f..38d4f6017 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -62,6 +62,7 @@ AVAILABLE_PAIRLISTS = [ "RemotePairList", "MarketCapPairList", "AgeFilter", + "CrossMarketFilter", "DelistFilter", "FullTradesFilter", "OffsetFilter", diff --git a/freqtrade/plugins/pairlist/CrossMarketFilter.py b/freqtrade/plugins/pairlist/CrossMarketFilter.py new file mode 100644 index 000000000..67701eae2 --- /dev/null +++ b/freqtrade/plugins/pairlist/CrossMarketFilter.py @@ -0,0 +1,99 @@ +""" +Price pair list filter +""" + +import logging + +import ccxt.pro as ccxt_pro + +from freqtrade.exceptions import OperationalException +from freqtrade.exchange.exchange_types import Tickers +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting + + +logger = logging.getLogger(__name__) + + +class CrossMarketFilter(IPairList): + supports_backtesting = SupportsBacktesting.BIASED + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self._mode: str = self._pairlistconfig.get("mode", "whitelist") + self._trading_mode: str = self._config["trading_mode"] + self._stake_currency: str = self._config["stake_currency"] + self._target_mode = "futures" if self._trading_mode == "spot" else "spot" + + @property + def needstickers(self) -> bool: + """ + Boolean property defining if tickers are necessary. + If no Pairlist requires tickers, an empty Dict is passed + as tickers argument to filter_pairlist + """ + return False + + def short_desc(self) -> str: + """ + Short whitelist method description - used for startup-messages + """ + mode = self._mode + target_mode = self._target_mode + msg = f"{self.name} - {mode.capitalize()} pairs that exists on {target_mode} market." + return msg + + @staticmethod + def description() -> str: + return "Filter pairs if they exist on another market." + + @staticmethod + def available_parameters() -> dict[str, PairlistParameter]: + return { + "mode": { + "type": "option", + "default": "whitelist", + "options": ["whitelist", "blacklist"], + "description": "Mode of operation", + "help": "Mode of operation (whitelist/blacklist)", + }, + } + + def get_base_list(self): + target_mode = self._target_mode + spot_only = True if target_mode == "spot" else False + futures_only = True if target_mode == "futures" else False + bases = [ + v.get("base", "") + for k, v in self._exchange.get_markets( + quote_currencies=[self._stake_currency], + tradable_only=False, + active_only=True, + spot_only=spot_only, + futures_only=futures_only, + ).items() + ] + return bases + + prefixes = ("1000", "1000000", "1M", "K", "M") + + def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: + bases = self.get_base_list() + is_whitelist_mode = self._mode == "whitelist" + whitelisted_pairlist: list[str] = [] + filtered_pairlist = pairlist.copy() + + for pair in pairlist: + base = self._exchange.get_pair_base_currency(pair) + found_in_bases = base in bases + if not found_in_bases: + for prefix in self.prefixes: + test_prefix = f"{prefix}{base}" + if test_prefix in bases: + found_in_bases = True + break + if found_in_bases: + whitelisted_pairlist.append(pair) + filtered_pairlist.remove(pair) + + return whitelisted_pairlist if is_whitelist_mode else filtered_pairlist From 8fb21e8f50a9015af021805e8514f9c9716077df Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 25 Feb 2026 17:17:26 +0900 Subject: [PATCH 02/11] change into pairlist plugin --- docs/includes/pairlists.md | 20 ++++---- freqtrade/constants.py | 2 +- ...MarketFilter.py => CrossMarketPairlist.py} | 50 ++++++++++++++++--- 3 files changed, 55 insertions(+), 17 deletions(-) rename freqtrade/plugins/pairlist/{CrossMarketFilter.py => CrossMarketPairlist.py} (62%) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index dc73223d5..2a5540b00 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -2,11 +2,11 @@ Pairlist Handlers define the list of pairs (pairlist) that the bot should trade. They are configured in the `pairlists` section of the configuration settings. -In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list) and [`PercentChangePairList`](#percent-change-pair-list) Pairlist Handlers). +In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list), [`CrossMarketPairlist`](#crossmarketpairlist), [`MarketCapPairlist`](#marketcappairlist) and [`PercentChangePairList`](#percent-change-pair-list) Pairlist Handlers). -Additionally, [`AgeFilter`](#agefilter), [`CrossMarketFilter`](#crossmarketfilter), [`DelistFilter`](#delistfilter), [`PrecisionFilter`](#precisionfilter), [`PriceFilter`](#pricefilter), [`ShuffleFilter`](#shufflefilter), [`SpreadFilter`](#spreadfilter) and [`VolatilityFilter`](#volatilityfilter) act as Pairlist Filters, removing certain pairs and/or moving their positions in the pairlist. +Additionally, [`AgeFilter`](#agefilter), [`DelistFilter`](#delistfilter), [`PrecisionFilter`](#precisionfilter), [`PriceFilter`](#pricefilter), [`ShuffleFilter`](#shufflefilter), [`SpreadFilter`](#spreadfilter) and [`VolatilityFilter`](#volatilityfilter) act as Pairlist Filters, removing certain pairs and/or moving their positions in the pairlist. -If multiple Pairlist Handlers are used, they are chained and a combination of all Pairlist Handlers forms the resulting pairlist the bot uses for trading and backtesting. Pairlist Handlers are executed in the sequence they are configured. You can define either `StaticPairList`, `VolumePairList`, `ProducerPairList`, `RemotePairList`, `MarketCapPairList` or `PercentChangePairList` as the starting Pairlist Handler. +If multiple Pairlist Handlers are used, they are chained and a combination of all Pairlist Handlers forms the resulting pairlist the bot uses for trading and backtesting. Pairlist Handlers are executed in the sequence they are configured. You can define either `StaticPairList`, `VolumePairList`, `ProducerPairList`, `RemotePairList`, `MarketCapPairList`, `PercentChangePairList` or `CrossMarketPairList` as the starting Pairlist Handler. Inactive markets are always removed from the resulting pairlist. Explicitly blacklisted pairs (those in the `pair_blacklist` configuration setting) are also always removed from the resulting pairlist. @@ -26,8 +26,8 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged * [`ProducerPairList`](#producerpairlist) * [`RemotePairList`](#remotepairlist) * [`MarketCapPairList`](#marketcappairlist) +* [`CrossMarketPairlist`](#crossmarketpairlist) * [`AgeFilter`](#agefilter) -* [`CrossMarketFilter`](#crossmarketfilter) * [`DelistFilter`](#delistfilter) * [`FullTradesFilter`](#fulltradesfilter) * [`OffsetFilter`](#offsetfilter) @@ -403,6 +403,12 @@ Coins like 1000PEPE/USDT or KPEPE/USDT:USDT are detected on a best effort basis, !!! Danger "Duplicate symbols in coingecko" Coingecko often has duplicate symbols, where the same symbol is used for different coins. Freqtrade will use the symbol as is and try to search for it on the exchange. If the symbol exists - it will be used. Freqtrade will however not check if the _intended_ symbol is the one coingecko meant. This can sometimes lead to unexpected results, especially on low volume coins or with meme coin categories. +#### CrossMarketPairlist + +Generate or filter pairs based of their availability on the opposite market. So for spot pairs, it will be checked against futures market, and vice versa. + +The `mode` setting defines whether the plugin will filters in (whitelist `mode`) or filters out (blacklist `mode`) pairs if they are active on opposite market. By default, the plugin will be in whitelist mode. + #### AgeFilter Removes pairs that have been listed on the exchange for less than `min_days_listed` days (defaults to `10`) or more than `max_days_listed` days (defaults `None` mean infinity). @@ -413,12 +419,6 @@ be caught out buying before the pair has finished dropping in price. This filter allows freqtrade to ignore pairs until they have been listed for at least `min_days_listed` days and listed before `max_days_listed`. -#### CrossMarketFilter - -Filter pairs based of their availability on the opposite market. So for spot pairs, it will be checked against futures market, and vice versa. - -The `mode` setting defines whether the plugin will filters in (whitelist `mode`) or filters out (blacklist `mode`) based of the availability on the opposite market. By default, the plugin will be in whitelist mode. - #### DelistFilter Removes pairs that will be delisted on the exchange maximum `max_days_from_now` days from now (defaults to `0` which remove all future delisted pairs no matter how far from now). Currently this filter only supports following exchanges: diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 38d4f6017..68fada77e 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -61,8 +61,8 @@ AVAILABLE_PAIRLISTS = [ "ProducerPairList", "RemotePairList", "MarketCapPairList", + "CrossMarketPairlist", "AgeFilter", - "CrossMarketFilter", "DelistFilter", "FullTradesFilter", "OffsetFilter", diff --git a/freqtrade/plugins/pairlist/CrossMarketFilter.py b/freqtrade/plugins/pairlist/CrossMarketPairlist.py similarity index 62% rename from freqtrade/plugins/pairlist/CrossMarketFilter.py rename to freqtrade/plugins/pairlist/CrossMarketPairlist.py index 67701eae2..2a3cd6615 100644 --- a/freqtrade/plugins/pairlist/CrossMarketFilter.py +++ b/freqtrade/plugins/pairlist/CrossMarketPairlist.py @@ -4,17 +4,15 @@ Price pair list filter import logging -import ccxt.pro as ccxt_pro - -from freqtrade.exceptions import OperationalException from freqtrade.exchange.exchange_types import Tickers from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting +from freqtrade.util import FtTTLCache logger = logging.getLogger(__name__) -class CrossMarketFilter(IPairList): +class CrossMarketPairlist(IPairList): supports_backtesting = SupportsBacktesting.BIASED def __init__(self, *args, **kwargs) -> None: @@ -24,6 +22,8 @@ class CrossMarketFilter(IPairList): self._trading_mode: str = self._config["trading_mode"] self._stake_currency: str = self._config["stake_currency"] self._target_mode = "futures" if self._trading_mode == "spot" else "spot" + self._refresh_period = self._pairlistconfig.get("refresh_period", 1800) + self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period) @property def needstickers(self) -> bool: @@ -57,6 +57,7 @@ class CrossMarketFilter(IPairList): "description": "Mode of operation", "help": "Mode of operation (whitelist/blacklist)", }, + **IPairList.refresh_period_parameter(), } def get_base_list(self): @@ -77,6 +78,35 @@ class CrossMarketFilter(IPairList): prefixes = ("1000", "1000000", "1M", "K", "M") + def gen_pairlist(self, tickers: Tickers) -> list[str]: + """ + Generate the pairlist + :param tickers: Tickers (from exchange.get_tickers). May be cached. + :return: List of pairs + """ + # Generate dynamic whitelist + # Must always run if this pairlist is the first in the list. + pairlist = self._pair_cache.get("pairlist") + if pairlist: + # Item found - no refresh necessary + return pairlist.copy() + else: + # Use fresh pairlist + # Check if pair quote currency equals to the stake currency. + _pairlist = [ + k + for k in self._exchange.get_markets( + quote_currencies=[self._stake_currency], tradable_only=True, active_only=True + ).keys() + ] + + _pairlist = self.verify_blacklist(_pairlist, logger.info) + + pairlist = self.filter_pairlist(_pairlist, tickers) + self._pair_cache["pairlist"] = pairlist.copy() + + return pairlist + def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: bases = self.get_base_list() is_whitelist_mode = self._mode == "whitelist" @@ -88,10 +118,18 @@ class CrossMarketFilter(IPairList): found_in_bases = base in bases if not found_in_bases: for prefix in self.prefixes: + # Check in case of PEPE needs to be changed to 1000PEPE for example test_prefix = f"{prefix}{base}" - if test_prefix in bases: - found_in_bases = True + found_in_bases = test_prefix in bases + if found_in_bases: break + + # Check in case of 1000PEPE needs to be changed to PEPE for example + if base.startswith(prefix): + temp_base = base.removeprefix(prefix) + found_in_bases = temp_base in bases + if found_in_bases: + break if found_in_bases: whitelisted_pairlist.append(pair) filtered_pairlist.remove(pair) From 991a1b1ab72ff8cfc1023908b226e8ff76fa44ea Mon Sep 17 00:00:00 2001 From: Stefano Date: Sat, 28 Feb 2026 10:58:34 +0900 Subject: [PATCH 03/11] add tests --- docs/includes/pairlists.md | 6 +- freqtrade/constants.py | 2 +- ...rketPairlist.py => CrossMarketPairList.py} | 26 +++-- tests/plugins/test_pairlist.py | 101 ++++++++++++++++++ 4 files changed, 120 insertions(+), 15 deletions(-) rename freqtrade/plugins/pairlist/{CrossMarketPairlist.py => CrossMarketPairList.py} (85%) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 2a5540b00..604851aa5 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -2,7 +2,7 @@ Pairlist Handlers define the list of pairs (pairlist) that the bot should trade. They are configured in the `pairlists` section of the configuration settings. -In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list), [`CrossMarketPairlist`](#crossmarketpairlist), [`MarketCapPairlist`](#marketcappairlist) and [`PercentChangePairList`](#percent-change-pair-list) Pairlist Handlers). +In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list), [`CrossMarketPairList`](#CrossMarketPairList), [`MarketCapPairlist`](#marketcappairlist) and [`PercentChangePairList`](#percent-change-pair-list) Pairlist Handlers). Additionally, [`AgeFilter`](#agefilter), [`DelistFilter`](#delistfilter), [`PrecisionFilter`](#precisionfilter), [`PriceFilter`](#pricefilter), [`ShuffleFilter`](#shufflefilter), [`SpreadFilter`](#spreadfilter) and [`VolatilityFilter`](#volatilityfilter) act as Pairlist Filters, removing certain pairs and/or moving their positions in the pairlist. @@ -26,7 +26,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged * [`ProducerPairList`](#producerpairlist) * [`RemotePairList`](#remotepairlist) * [`MarketCapPairList`](#marketcappairlist) -* [`CrossMarketPairlist`](#crossmarketpairlist) +* [`CrossMarketPairList`](#CrossMarketPairList) * [`AgeFilter`](#agefilter) * [`DelistFilter`](#delistfilter) * [`FullTradesFilter`](#fulltradesfilter) @@ -403,7 +403,7 @@ Coins like 1000PEPE/USDT or KPEPE/USDT:USDT are detected on a best effort basis, !!! Danger "Duplicate symbols in coingecko" Coingecko often has duplicate symbols, where the same symbol is used for different coins. Freqtrade will use the symbol as is and try to search for it on the exchange. If the symbol exists - it will be used. Freqtrade will however not check if the _intended_ symbol is the one coingecko meant. This can sometimes lead to unexpected results, especially on low volume coins or with meme coin categories. -#### CrossMarketPairlist +#### CrossMarketPairList Generate or filter pairs based of their availability on the opposite market. So for spot pairs, it will be checked against futures market, and vice versa. diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 659b90f72..3182a9d76 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -61,7 +61,7 @@ AVAILABLE_PAIRLISTS = [ "ProducerPairList", "RemotePairList", "MarketCapPairList", - "CrossMarketPairlist", + "CrossMarketPairList", "AgeFilter", "DelistFilter", "FullTradesFilter", diff --git a/freqtrade/plugins/pairlist/CrossMarketPairlist.py b/freqtrade/plugins/pairlist/CrossMarketPairList.py similarity index 85% rename from freqtrade/plugins/pairlist/CrossMarketPairlist.py rename to freqtrade/plugins/pairlist/CrossMarketPairList.py index 2a3cd6615..340ce4757 100644 --- a/freqtrade/plugins/pairlist/CrossMarketPairlist.py +++ b/freqtrade/plugins/pairlist/CrossMarketPairList.py @@ -4,6 +4,7 @@ Price pair list filter import logging +from freqtrade.constants import PairPrefixes from freqtrade.exchange.exchange_types import Tickers from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting from freqtrade.util import FtTTLCache @@ -12,7 +13,7 @@ from freqtrade.util import FtTTLCache logger = logging.getLogger(__name__) -class CrossMarketPairlist(IPairList): +class CrossMarketPairList(IPairList): supports_backtesting = SupportsBacktesting.BIASED def __init__(self, *args, **kwargs) -> None: @@ -76,8 +77,6 @@ class CrossMarketPairlist(IPairList): ] return bases - prefixes = ("1000", "1000000", "1M", "K", "M") - def gen_pairlist(self, tickers: Tickers) -> list[str]: """ Generate the pairlist @@ -115,21 +114,26 @@ class CrossMarketPairlist(IPairList): for pair in pairlist: base = self._exchange.get_pair_base_currency(pair) + if not base: + filtered_pairlist.remove(pair) + continue found_in_bases = base in bases if not found_in_bases: - for prefix in self.prefixes: - # Check in case of PEPE needs to be changed to 1000PEPE for example + for prefix in PairPrefixes: + # Check in case of PEPE needs to be changed into 1000PEPE for example test_prefix = f"{prefix}{base}" found_in_bases = test_prefix in bases if found_in_bases: break - # Check in case of 1000PEPE needs to be changed to PEPE for example - if base.startswith(prefix): - temp_base = base.removeprefix(prefix) - found_in_bases = temp_base in bases - if found_in_bases: - break + # Avoid false positive since there are KAVA and AVA pairs, which aren't related + if prefix != "K": + # Check in case of 1000PEPE needs to be changed into PEPE for example + if base.startswith(prefix): + temp_base = base.removeprefix(prefix) + found_in_bases = temp_base in bases + if found_in_bases: + break if found_in_bases: whitelisted_pairlist.append(pair) filtered_pairlist.remove(pair) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index e3b63bdb3..60598ee24 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -2584,6 +2584,107 @@ def test_MarketCapPairList_exceptions(mocker, default_conf_usdt, caplog): PairListManager(exchange, default_conf_usdt) +@pytest.mark.parametrize( + "pairlists,trade_mode,result", + [ + ( + [ + # Whitelist mode on spot + {"method": "StaticPairList", "allow_inactive": True}, + {"method": "CrossMarketPairList", "mode": "whitelist"}, + ], + "spot", + ["ETH/USDT"], + ), + ( + [ + # Blacklist mode on spot + {"method": "StaticPairList", "allow_inactive": True}, + {"method": "CrossMarketPairList", "mode": "blacklist"}, + ], + "spot", + ["LTC/USDT", "XRP/USDT", "NEO/USDT", "TKN/USDT", "BTC/USDT"], + ), + ( + [ + # Whitelist mode on futures + {"method": "StaticPairList", "allow_inactive": True}, + {"method": "CrossMarketPairList", "mode": "whitelist"}, + ], + "futures", + ["ETH/USDT:USDT"], + ), + ( + [ + # Blacklist mode on futures + {"method": "StaticPairList", "allow_inactive": True}, + {"method": "CrossMarketPairList", "mode": "blacklist"}, + ], + "futures", + ["ADA/USDT:USDT"], + ), + ( + [ + # CrossMarketPairList as generator, whitelist mode, spot market + {"method": "CrossMarketPairList", "mode": "whitelist"}, + ], + "spot", + ["ETH/USDT"], + ), + ( + [ + # CrossMarketPairList as generator, blacklist mode, spot market + {"method": "CrossMarketPairList", "mode": "blacklist"}, + ], + "spot", + ["BTC/USDT", "XRP/USDT", "NEO/USDT", "TKN/USDT"], + ), + ( + [ + # CrossMarketPairList as generator, whitelist mode, futures market + {"method": "CrossMarketPairList", "mode": "whitelist"}, + ], + "futures", + ["ETH/USDT:USDT"], + ), + ( + [ + # CrossMarketPairList as generator, blacklist mode, futures market + {"method": "CrossMarketPairList", "mode": "blacklist"}, + ], + "futures", + ["ADA/USDT:USDT"], + ), + ], +) +def test_CrossMarketPairlist_filter( + mocker, default_conf_usdt, trade_mode, markets, pairlists, result +): + default_conf_usdt["trading_mode"] = trade_mode + if trade_mode == "spot": + default_conf_usdt["exchange"]["pair_whitelist"].extend(["BTC/USDT", "ETC/USDT", "ADA/USDT"]) + else: + default_conf_usdt["exchange"]["pair_whitelist"] = [ + "BTC/USDT:USDT", + "ETH/USDT:USDT", + "ETC/USDT:USDT", + "ADA/USDT:USDT", + ] + default_conf_usdt["pairlists"] = pairlists + mocker.patch.multiple( + EXMS, + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + ) + + exchange = get_patched_exchange(mocker, default_conf_usdt) + + pm = PairListManager(exchange, default_conf_usdt) + pm.refresh_pairlist() + + assert pm.whitelist == result + + @pytest.mark.parametrize( "pairlists,expected_error,expected_warning", [ From 818162b6301e7d65cf272f1b26d7166011b155de Mon Sep 17 00:00:00 2001 From: Stefano Date: Sat, 28 Feb 2026 11:01:45 +0900 Subject: [PATCH 04/11] fix letters' case --- docs/includes/pairlists.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 604851aa5..bd1308491 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -2,7 +2,7 @@ Pairlist Handlers define the list of pairs (pairlist) that the bot should trade. They are configured in the `pairlists` section of the configuration settings. -In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list), [`CrossMarketPairList`](#CrossMarketPairList), [`MarketCapPairlist`](#marketcappairlist) and [`PercentChangePairList`](#percent-change-pair-list) Pairlist Handlers). +In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list), [`CrossMarketPairList`](#crossmarketpairlist), [`MarketCapPairlist`](#marketcappairlist) and [`PercentChangePairList`](#percent-change-pair-list) Pairlist Handlers). Additionally, [`AgeFilter`](#agefilter), [`DelistFilter`](#delistfilter), [`PrecisionFilter`](#precisionfilter), [`PriceFilter`](#pricefilter), [`ShuffleFilter`](#shufflefilter), [`SpreadFilter`](#spreadfilter) and [`VolatilityFilter`](#volatilityfilter) act as Pairlist Filters, removing certain pairs and/or moving their positions in the pairlist. @@ -26,7 +26,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged * [`ProducerPairList`](#producerpairlist) * [`RemotePairList`](#remotepairlist) * [`MarketCapPairList`](#marketcappairlist) -* [`CrossMarketPairList`](#CrossMarketPairList) +* [`CrossMarketPairList`](#crossmarketpairlist) * [`AgeFilter`](#agefilter) * [`DelistFilter`](#delistfilter) * [`FullTradesFilter`](#fulltradesfilter) From 3b59616cbeb75c0ca49b108fcdda945cd4d927e4 Mon Sep 17 00:00:00 2001 From: Stefano Date: Sat, 28 Feb 2026 11:03:24 +0900 Subject: [PATCH 05/11] add to checma --- build_helpers/schema.json | 1 + 1 file changed, 1 insertion(+) diff --git a/build_helpers/schema.json b/build_helpers/schema.json index 05fcba2f2..8bf56b2a1 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -649,6 +649,7 @@ "ProducerPairList", "RemotePairList", "MarketCapPairList", + "CrossMarketPairList", "AgeFilter", "DelistFilter", "FullTradesFilter", From 64f87fe1872edce0dbc273f3c6d536e67d665b16 Mon Sep 17 00:00:00 2001 From: Stefano Date: Sun, 1 Mar 2026 08:28:56 +0900 Subject: [PATCH 06/11] Update freqtrade/plugins/pairlist/CrossMarketPairList.py Co-authored-by: Matthias --- freqtrade/plugins/pairlist/CrossMarketPairList.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/plugins/pairlist/CrossMarketPairList.py b/freqtrade/plugins/pairlist/CrossMarketPairList.py index 340ce4757..8071f666e 100644 --- a/freqtrade/plugins/pairlist/CrossMarketPairList.py +++ b/freqtrade/plugins/pairlist/CrossMarketPairList.py @@ -61,7 +61,7 @@ class CrossMarketPairList(IPairList): **IPairList.refresh_period_parameter(), } - def get_base_list(self): + def get_base_list(self) -> list[str]: target_mode = self._target_mode spot_only = True if target_mode == "spot" else False futures_only = True if target_mode == "futures" else False From 5788a75accd4d026bf8abf244a9fd9bca0a1a0dd Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 2 Mar 2026 10:24:45 +0900 Subject: [PATCH 07/11] add tests to satisfy codecov --- .../plugins/pairlist/CrossMarketPairList.py | 7 +- tests/plugins/test_pairlist.py | 96 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/freqtrade/plugins/pairlist/CrossMarketPairList.py b/freqtrade/plugins/pairlist/CrossMarketPairList.py index 340ce4757..f7853940f 100644 --- a/freqtrade/plugins/pairlist/CrossMarketPairList.py +++ b/freqtrade/plugins/pairlist/CrossMarketPairList.py @@ -14,15 +14,15 @@ logger = logging.getLogger(__name__) class CrossMarketPairList(IPairList): + is_pairlist_generator = True supports_backtesting = SupportsBacktesting.BIASED def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._mode: str = self._pairlistconfig.get("mode", "whitelist") - self._trading_mode: str = self._config["trading_mode"] self._stake_currency: str = self._config["stake_currency"] - self._target_mode = "futures" if self._trading_mode == "spot" else "spot" + self._target_mode = "spot" if self._config["trading_mode"] == "futures" else "futures" self._refresh_period = self._pairlistconfig.get("refresh_period", 1800) self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period) @@ -115,6 +115,9 @@ class CrossMarketPairList(IPairList): for pair in pairlist: base = self._exchange.get_pair_base_currency(pair) if not base: + self.log_once( + f"Unable to get base currency for pair {pair}, skipping it.", logger.warning + ) filtered_pairlist.remove(pair) continue found_in_bases = base in bases diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 60598ee24..9e244d734 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -2685,6 +2685,102 @@ def test_CrossMarketPairlist_filter( assert pm.whitelist == result +def test_CrossMarketPairlist_gen_pairlist_uses_cache(mocker, default_conf_usdt, markets): + default_conf_usdt["trading_mode"] = "spot" + default_conf_usdt["pairlists"] = [{"method": "CrossMarketPairList", "mode": "whitelist"}] + + mocker.patch.multiple( + EXMS, + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + ) + + exchange = get_patched_exchange(mocker, default_conf_usdt) + pm = PairListManager(exchange, default_conf_usdt) + pl = pm._pairlist_handlers[0] + + pl._pair_cache["pairlist"] = ["ETH/USDT", "ADA/USDT"] + pl._exchange.get_markets = MagicMock( + side_effect=AssertionError("get_markets should not be called") + ) + + result = pl.gen_pairlist({}) + + assert result == ["ETH/USDT", "ADA/USDT"] + # Make sure the returned list is a copy, not the cached one + assert result is not pl._pair_cache["pairlist"] + result.append("BTC/USDT") + # Make sure the cache is not modified + assert pl._pair_cache["pairlist"] == ["ETH/USDT", "ADA/USDT"] + + +def test_CrossMarketPairList_breaks_prefix_loop_on_match(mocker, default_conf_usdt, markets): + default_conf_usdt["trading_mode"] = "spot" + default_conf_usdt["pairlists"] = [{"method": "CrossMarketPairList", "mode": "whitelist"}] + + mocker.patch.multiple( + EXMS, + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + ) + + exchange = get_patched_exchange(mocker, default_conf_usdt) + pm = PairListManager(exchange, default_conf_usdt) + pl = pm._pairlist_handlers[0] + + # Force base lookup path + mocker.patch.object(pl, "get_base_list", return_value=["1000PEPE"]) + mocker.patch.object(pl._exchange, "get_pair_base_currency", return_value="PEPE") + + class PrefixesWithGuard: + def __iter__(self): + yield "1000" # first prefix => match via "1000PEPE" + raise AssertionError("Prefix loop did not break after match") + + mocker.patch( + "freqtrade.plugins.pairlist.CrossMarketPairList.PairPrefixes", + new=PrefixesWithGuard(), + ) + + result = pl.filter_pairlist(["PEPE/USDT"], {}) + assert result == ["PEPE/USDT"] + + +def test_CrossMarketPairList_breaks_prefix_loop_on_delayed_match( + mocker, default_conf_usdt, markets +): + default_conf_usdt["trading_mode"] = "spot" + default_conf_usdt["pairlists"] = [{"method": "CrossMarketPairList", "mode": "whitelist"}] + + mocker.patch.multiple( + EXMS, + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + ) + + exchange = get_patched_exchange(mocker, default_conf_usdt) + pm = PairListManager(exchange, default_conf_usdt) + pl = pm._pairlist_handlers[0] + + # Force second matching path: base startswith prefix and removeprefix() matches bases. + mocker.patch.object(pl, "get_base_list", return_value=["PEPE"]) + mocker.patch.object(pl._exchange, "get_pair_base_currency", return_value="1000PEPE") + + class PrefixesWithGuard: + def __iter__(self): + yield "X" # no match, loop should continue + yield "1000" # second path should match via removeprefix -> PEPE and break + raise AssertionError("Prefix loop did not break after delayed match") + + mocker.patch( + "freqtrade.plugins.pairlist.CrossMarketPairList.PairPrefixes", + new=PrefixesWithGuard(), + ) + + result = pl.filter_pairlist(["1000PEPE/USDT"], {}) + assert result == ["1000PEPE/USDT"] + + @pytest.mark.parametrize( "pairlists,expected_error,expected_warning", [ From bbba9579dc55df6284f2424ddc56ab7bcf076928 Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 4 Mar 2026 09:57:11 +0900 Subject: [PATCH 08/11] switch "mode" into "pairs_exist_on" --- .../plugins/pairlist/CrossMarketPairList.py | 22 +++++----- tests/plugins/test_pairlist.py | 44 +++++++++++-------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/freqtrade/plugins/pairlist/CrossMarketPairList.py b/freqtrade/plugins/pairlist/CrossMarketPairList.py index 2b235b15b..768b179cc 100644 --- a/freqtrade/plugins/pairlist/CrossMarketPairList.py +++ b/freqtrade/plugins/pairlist/CrossMarketPairList.py @@ -20,7 +20,7 @@ class CrossMarketPairList(IPairList): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self._mode: str = self._pairlistconfig.get("mode", "whitelist") + self._pairs_exist_on: str = self._pairlistconfig.get("pairs_exist_on", "both_markets") self._stake_currency: str = self._config["stake_currency"] self._target_mode = "spot" if self._config["trading_mode"] == "futures" else "futures" self._refresh_period = self._pairlistconfig.get("refresh_period", 1800) @@ -39,24 +39,23 @@ class CrossMarketPairList(IPairList): """ Short whitelist method description - used for startup-messages """ - mode = self._mode - target_mode = self._target_mode - msg = f"{self.name} - {mode.capitalize()} pairs that exists on {target_mode} market." + pairs_exist_on = self._pairs_exist_on + msg = f"{self.name} - Pairs that exists on {pairs_exist_on.capitalize()}." return msg @staticmethod def description() -> str: - return "Filter pairs if they exist on another market." + return "Filter pairs if they exist or not on another market." @staticmethod def available_parameters() -> dict[str, PairlistParameter]: return { - "mode": { + "pairs_exist_on": { "type": "option", - "default": "whitelist", - "options": ["whitelist", "blacklist"], + "default": "both_markets", + "options": ["current_market_only", "both_markets"], "description": "Mode of operation", - "help": "Mode of operation (whitelist/blacklist)", + "help": "Mode of operation (current_market_only/both_markets)", }, **IPairList.refresh_period_parameter(), } @@ -67,7 +66,7 @@ class CrossMarketPairList(IPairList): futures_only = True if target_mode == "futures" else False bases = [ v.get("base", "") - for k, v in self._exchange.get_markets( + for _, v in self._exchange.get_markets( quote_currencies=[self._stake_currency], tradable_only=False, active_only=True, @@ -108,7 +107,8 @@ class CrossMarketPairList(IPairList): def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: bases = self.get_base_list() - is_whitelist_mode = self._mode == "whitelist" + pairs_exist_on = self._pairs_exist_on + is_whitelist_mode = pairs_exist_on == "both_markets" whitelisted_pairlist: list[str] = [] filtered_pairlist = pairlist.copy() diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 9e244d734..c36a0435a 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -2589,68 +2589,68 @@ def test_MarketCapPairList_exceptions(mocker, default_conf_usdt, caplog): [ ( [ - # Whitelist mode on spot + # Spot pairs that exist on both markets {"method": "StaticPairList", "allow_inactive": True}, - {"method": "CrossMarketPairList", "mode": "whitelist"}, + {"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"}, ], "spot", ["ETH/USDT"], ), ( [ - # Blacklist mode on spot + # Spot pairs that exist only on spot market {"method": "StaticPairList", "allow_inactive": True}, - {"method": "CrossMarketPairList", "mode": "blacklist"}, + {"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"}, ], "spot", ["LTC/USDT", "XRP/USDT", "NEO/USDT", "TKN/USDT", "BTC/USDT"], ), ( [ - # Whitelist mode on futures + # Futures pairs that exist on both markets {"method": "StaticPairList", "allow_inactive": True}, - {"method": "CrossMarketPairList", "mode": "whitelist"}, + {"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"}, ], "futures", ["ETH/USDT:USDT"], ), ( [ - # Blacklist mode on futures + # Futures pairs that exist only on futures market {"method": "StaticPairList", "allow_inactive": True}, - {"method": "CrossMarketPairList", "mode": "blacklist"}, + {"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"}, ], "futures", ["ADA/USDT:USDT"], ), ( [ - # CrossMarketPairList as generator, whitelist mode, spot market - {"method": "CrossMarketPairList", "mode": "whitelist"}, + # CrossMarketPairList as generator, spot market, pairs that exist on both markets + {"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"}, ], "spot", ["ETH/USDT"], ), ( [ - # CrossMarketPairList as generator, blacklist mode, spot market - {"method": "CrossMarketPairList", "mode": "blacklist"}, + # CrossMarketPairList as generator, spot pairs that exist only on spot market + {"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"}, ], "spot", ["BTC/USDT", "XRP/USDT", "NEO/USDT", "TKN/USDT"], ), ( [ - # CrossMarketPairList as generator, whitelist mode, futures market - {"method": "CrossMarketPairList", "mode": "whitelist"}, + # CrossMarketPairList as generator, futures pairs that exist on both markets + {"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"}, ], "futures", ["ETH/USDT:USDT"], ), ( [ - # CrossMarketPairList as generator, blacklist mode, futures market - {"method": "CrossMarketPairList", "mode": "blacklist"}, + # CrossMarketPairList as generator, futures pairs that exist only on futures market + {"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"}, ], "futures", ["ADA/USDT:USDT"], @@ -2687,7 +2687,9 @@ def test_CrossMarketPairlist_filter( def test_CrossMarketPairlist_gen_pairlist_uses_cache(mocker, default_conf_usdt, markets): default_conf_usdt["trading_mode"] = "spot" - default_conf_usdt["pairlists"] = [{"method": "CrossMarketPairList", "mode": "whitelist"}] + default_conf_usdt["pairlists"] = [ + {"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"} + ] mocker.patch.multiple( EXMS, @@ -2716,7 +2718,9 @@ def test_CrossMarketPairlist_gen_pairlist_uses_cache(mocker, default_conf_usdt, def test_CrossMarketPairList_breaks_prefix_loop_on_match(mocker, default_conf_usdt, markets): default_conf_usdt["trading_mode"] = "spot" - default_conf_usdt["pairlists"] = [{"method": "CrossMarketPairList", "mode": "whitelist"}] + default_conf_usdt["pairlists"] = [ + {"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"} + ] mocker.patch.multiple( EXMS, @@ -2750,7 +2754,9 @@ def test_CrossMarketPairList_breaks_prefix_loop_on_delayed_match( mocker, default_conf_usdt, markets ): default_conf_usdt["trading_mode"] = "spot" - default_conf_usdt["pairlists"] = [{"method": "CrossMarketPairList", "mode": "whitelist"}] + default_conf_usdt["pairlists"] = [ + {"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"} + ] mocker.patch.multiple( EXMS, From c1c2d24bdc002717b50739ac2655725c00e57f8c Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 4 Mar 2026 10:02:52 +0900 Subject: [PATCH 09/11] fix docs --- docs/includes/pairlists.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index bd1308491..64b97e6a1 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -405,9 +405,9 @@ Coins like 1000PEPE/USDT or KPEPE/USDT:USDT are detected on a best effort basis, #### CrossMarketPairList -Generate or filter pairs based of their availability on the opposite market. So for spot pairs, it will be checked against futures market, and vice versa. +Generate or filter pairs based of their availability on the opposite market. -The `mode` setting defines whether the plugin will filters in (whitelist `mode`) or filters out (blacklist `mode`) pairs if they are active on opposite market. By default, the plugin will be in whitelist mode. +The `pairs_exis_on` setting defines whether the pairs should exists on both spot and futures market (`both_markets`) or only exist on the specified trading mode (`current_market_only`). By default, the plugin will be in `both_markets` setting, which means whitelisted pairs have to exists on both spot and futures markets. #### AgeFilter From 9cee5cc4d04de13186f7c5ca53fc13d707ab4220 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 5 Mar 2026 06:29:51 +0100 Subject: [PATCH 10/11] chore: fix pairlist docstring --- freqtrade/plugins/pairlist/CrossMarketPairList.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/freqtrade/plugins/pairlist/CrossMarketPairList.py b/freqtrade/plugins/pairlist/CrossMarketPairList.py index 768b179cc..8f19e7b91 100644 --- a/freqtrade/plugins/pairlist/CrossMarketPairList.py +++ b/freqtrade/plugins/pairlist/CrossMarketPairList.py @@ -1,6 +1,4 @@ -""" -Price pair list filter -""" +"""Cross Market pair list filter""" import logging From aabda8d4f17255a48dd9478874cdf430d96a22e7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 5 Mar 2026 06:40:04 +0100 Subject: [PATCH 11/11] test: simplify test --- tests/plugins/test_pairlist.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index c36a0435a..5d43e912f 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -2736,14 +2736,13 @@ def test_CrossMarketPairList_breaks_prefix_loop_on_match(mocker, default_conf_us mocker.patch.object(pl, "get_base_list", return_value=["1000PEPE"]) mocker.patch.object(pl._exchange, "get_pair_base_currency", return_value="PEPE") - class PrefixesWithGuard: - def __iter__(self): - yield "1000" # first prefix => match via "1000PEPE" - raise AssertionError("Prefix loop did not break after match") + def prefix_generator(): + yield "1000" # first prefix => match via "1000PEPE" + raise AssertionError("Prefix loop did not break after match") mocker.patch( "freqtrade.plugins.pairlist.CrossMarketPairList.PairPrefixes", - new=PrefixesWithGuard(), + new=prefix_generator(), ) result = pl.filter_pairlist(["PEPE/USDT"], {}) @@ -2772,15 +2771,14 @@ def test_CrossMarketPairList_breaks_prefix_loop_on_delayed_match( mocker.patch.object(pl, "get_base_list", return_value=["PEPE"]) mocker.patch.object(pl._exchange, "get_pair_base_currency", return_value="1000PEPE") - class PrefixesWithGuard: - def __iter__(self): - yield "X" # no match, loop should continue - yield "1000" # second path should match via removeprefix -> PEPE and break - raise AssertionError("Prefix loop did not break after delayed match") + def prefix_generator(): + yield "X" # no match, loop should continue + yield "1000" # second path should match via removeprefix -> PEPE and break + raise AssertionError("Prefix loop did not break after delayed match") mocker.patch( "freqtrade.plugins.pairlist.CrossMarketPairList.PairPrefixes", - new=PrefixesWithGuard(), + new=prefix_generator(), ) result = pl.filter_pairlist(["1000PEPE/USDT"], {})