mirror of
https://github.com/freqtrade/freqtrade.git
synced 2026-03-05 13:24:20 +00:00
Merge pull request #12874 from stash86/main-stash
Add CrossMarketPairList
This commit is contained in:
@@ -649,6 +649,7 @@
|
||||
"ProducerPairList",
|
||||
"RemotePairList",
|
||||
"MarketCapPairList",
|
||||
"CrossMarketPairList",
|
||||
"AgeFilter",
|
||||
"DelistFilter",
|
||||
"FullTradesFilter",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -61,6 +61,7 @@ AVAILABLE_PAIRLISTS = [
|
||||
"ProducerPairList",
|
||||
"RemotePairList",
|
||||
"MarketCapPairList",
|
||||
"CrossMarketPairList",
|
||||
"AgeFilter",
|
||||
"DelistFilter",
|
||||
"FullTradesFilter",
|
||||
|
||||
142
freqtrade/plugins/pairlist/CrossMarketPairList.py
Normal file
142
freqtrade/plugins/pairlist/CrossMarketPairList.py
Normal 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
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user