Merge pull request #12874 from stash86/main-stash

Add CrossMarketPairList
This commit is contained in:
Matthias
2026-03-05 06:57:31 +01:00
committed by GitHub
5 changed files with 354 additions and 2 deletions

View File

@@ -649,6 +649,7 @@
"ProducerPairList",
"RemotePairList",
"MarketCapPairList",
"CrossMarketPairList",
"AgeFilter",
"DelistFilter",
"FullTradesFilter",

View File

@@ -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), [`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,6 +26,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged
* [`ProducerPairList`](#producerpairlist)
* [`RemotePairList`](#remotepairlist)
* [`MarketCapPairList`](#marketcappairlist)
* [`CrossMarketPairList`](#crossmarketpairlist)
* [`AgeFilter`](#agefilter)
* [`DelistFilter`](#delistfilter)
* [`FullTradesFilter`](#fulltradesfilter)
@@ -402,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.
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
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).

View File

@@ -61,6 +61,7 @@ AVAILABLE_PAIRLISTS = [
"ProducerPairList",
"RemotePairList",
"MarketCapPairList",
"CrossMarketPairList",
"AgeFilter",
"DelistFilter",
"FullTradesFilter",

View File

@@ -0,0 +1,142 @@
"""Cross Market 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
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._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)
self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
@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
"""
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 or not on another market."
@staticmethod
def available_parameters() -> dict[str, PairlistParameter]:
return {
"pairs_exist_on": {
"type": "option",
"default": "both_markets",
"options": ["current_market_only", "both_markets"],
"description": "Mode of operation",
"help": "Mode of operation (current_market_only/both_markets)",
},
**IPairList.refresh_period_parameter(),
}
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
bases = [
v.get("base", "")
for _, 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
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()
pairs_exist_on = self._pairs_exist_on
is_whitelist_mode = pairs_exist_on == "both_markets"
whitelisted_pairlist: list[str] = []
filtered_pairlist = pairlist.copy()
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
if not found_in_bases:
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
# 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)
return whitelisted_pairlist if is_whitelist_mode else filtered_pairlist

View File

@@ -2584,6 +2584,207 @@ def test_MarketCapPairList_exceptions(mocker, default_conf_usdt, caplog):
PairListManager(exchange, default_conf_usdt)
@pytest.mark.parametrize(
"pairlists,trade_mode,result",
[
(
[
# Spot pairs that exist on both markets
{"method": "StaticPairList", "allow_inactive": True},
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"},
],
"spot",
["ETH/USDT"],
),
(
[
# Spot pairs that exist only on spot market
{"method": "StaticPairList", "allow_inactive": True},
{"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"},
],
"spot",
["LTC/USDT", "XRP/USDT", "NEO/USDT", "TKN/USDT", "BTC/USDT"],
),
(
[
# Futures pairs that exist on both markets
{"method": "StaticPairList", "allow_inactive": True},
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"},
],
"futures",
["ETH/USDT:USDT"],
),
(
[
# Futures pairs that exist only on futures market
{"method": "StaticPairList", "allow_inactive": True},
{"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"},
],
"futures",
["ADA/USDT:USDT"],
),
(
[
# CrossMarketPairList as generator, spot market, pairs that exist on both markets
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"},
],
"spot",
["ETH/USDT"],
),
(
[
# 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, futures pairs that exist on both markets
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"},
],
"futures",
["ETH/USDT:USDT"],
),
(
[
# CrossMarketPairList as generator, futures pairs that exist only on futures market
{"method": "CrossMarketPairList", "pairs_exist_on": "current_market_only"},
],
"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
def test_CrossMarketPairlist_gen_pairlist_uses_cache(mocker, default_conf_usdt, markets):
default_conf_usdt["trading_mode"] = "spot"
default_conf_usdt["pairlists"] = [
{"method": "CrossMarketPairList", "pairs_exist_on": "both_markets"}
]
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", "pairs_exist_on": "both_markets"}
]
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")
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=prefix_generator(),
)
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", "pairs_exist_on": "both_markets"}
]
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")
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=prefix_generator(),
)
result = pl.filter_pairlist(["1000PEPE/USDT"], {})
assert result == ["1000PEPE/USDT"]
@pytest.mark.parametrize(
"pairlists,expected_error,expected_warning",
[