Merge pull request #12259 from stash86/delist

Implement delisting check on futures market
This commit is contained in:
Matthias
2025-09-26 07:14:01 +02:00
committed by GitHub
14 changed files with 503 additions and 34 deletions

View File

@@ -587,6 +587,7 @@
"RemotePairList", "RemotePairList",
"MarketCapPairList", "MarketCapPairList",
"AgeFilter", "AgeFilter",
"DelistFilter",
"FullTradesFilter", "FullTradesFilter",
"OffsetFilter", "OffsetFilter",
"PerformanceFilter", "PerformanceFilter",

View File

@@ -25,10 +25,10 @@
"trading_mode": "spot", "trading_mode": "spot",
"margin_mode": "", "margin_mode": "",
"minimal_roi": { "minimal_roi": {
"40": 0.0, "40": 0.0,
"30": 0.01, "30": 0.01,
"20": 0.02, "20": 0.02,
"0": 0.04 "0": 0.04
}, },
"stoploss": -0.10, "stoploss": -0.10,
"unfilledtimeout": { "unfilledtimeout": {
@@ -47,7 +47,7 @@
"bids_to_ask_delta": 1 "bids_to_ask_delta": 1
} }
}, },
"exit_pricing":{ "exit_pricing": {
"price_side": "same", "price_side": "same",
"use_order_book": true, "use_order_book": true,
"order_book_top": 1, "order_book_top": 1,
@@ -70,18 +70,38 @@
"exit": "GTC" "exit": "GTC"
}, },
"pairlists": [ "pairlists": [
{"method": "StaticPairList"}, {
{"method": "FullTradesFilter"}, "method": "StaticPairList"
},
{
"method": "DelistFilter",
"max_days_from_now": 0,
},
{
"method": "FullTradesFilter"
},
{ {
"method": "VolumePairList", "method": "VolumePairList",
"number_assets": 20, "number_assets": 20,
"sort_key": "quoteVolume", "sort_key": "quoteVolume",
"refresh_period": 1800 "refresh_period": 1800
}, },
{"method": "AgeFilter", "min_days_listed": 10}, {
{"method": "PrecisionFilter"}, "method": "AgeFilter",
{"method": "PriceFilter", "low_price_ratio": 0.01, "min_price": 0.00000010}, "min_days_listed": 10
{"method": "SpreadFilter", "max_spread_ratio": 0.005}, },
{
"method": "PrecisionFilter"
},
{
"method": "PriceFilter",
"low_price_ratio": 0.01,
"min_price": 0.00000010
},
{
"method": "SpreadFilter",
"max_spread_ratio": 0.005
},
{ {
"method": "RangeStabilityFilter", "method": "RangeStabilityFilter",
"lookback_days": 10, "lookback_days": 10,
@@ -166,12 +186,12 @@
"external_message_consumer": { "external_message_consumer": {
"enabled": false, "enabled": false,
"producers": [ "producers": [
{ {
"name": "default", "name": "default",
"host": "127.0.0.2", "host": "127.0.0.2",
"port": 8080, "port": 8080,
"ws_token": "secret_ws_t0ken." "ws_token": "secret_ws_t0ken."
} }
], ],
"wait_timeout": 300, "wait_timeout": 300,
"ping_timeout": 10, "ping_timeout": 10,

View File

@@ -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). 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), [`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` 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) * [`RemotePairList`](#remotepairlist)
* [`MarketCapPairList`](#marketcappairlist) * [`MarketCapPairList`](#marketcappairlist)
* [`AgeFilter`](#agefilter) * [`AgeFilter`](#agefilter)
* [`DelistFilter`](#delistfilter)
* [`FullTradesFilter`](#fulltradesfilter) * [`FullTradesFilter`](#fulltradesfilter)
* [`OffsetFilter`](#offsetfilter) * [`OffsetFilter`](#offsetfilter)
* [`PerformanceFilter`](#performancefilter) * [`PerformanceFilter`](#performancefilter)
@@ -270,7 +271,6 @@ You can limit the length of the pairlist with the optional parameter `number_ass
], ],
``` ```
!!! Tip "Combining pairlists" !!! Tip "Combining pairlists"
This pairlist can be combined with all other pairlists and filters for further pairlist reduction, and can also act as an "additional" pairlist, on top of already defined pairs. This pairlist can be combined with all other pairlists and filters for further pairlist reduction, and can also act as an "additional" pairlist, on top of already defined pairs.
`ProducerPairList` can also be used multiple times in sequence, combining the pairs from multiple producers. `ProducerPairList` can also be used multiple times in sequence, combining the pairs from multiple producers.
@@ -407,6 +407,16 @@ 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`. 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`.
#### 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:
!!! Note "Available exchanges"
Delist filter is only available on Binance, where Binance Futures will work for both dry and live modes, while Binance Spot is limited to live mode (for technical reasons).
!!! Warning "Backtesting"
`DelistFilter` does not support backtesting mode.
#### FullTradesFilter #### FullTradesFilter
Shrink whitelist to consist only in-trade pairs when the trade slots are full (when `max_open_trades` isn't being set to `-1` in the config). Shrink whitelist to consist only in-trade pairs when the trade slots are full (when `max_open_trades` isn't being set to `-1` in the config).
@@ -601,7 +611,7 @@ Adding `"sort_direction": "asc"` or `"sort_direction": "desc"` enables sorting m
### Full example of Pairlist Handlers ### 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. The below example blacklists `BNB/BTC`, uses `VolumePairList` with `20` assets, sorting pairs by `quoteVolume`, then filter future delisted pairs using [`DelistFilter`](#delistfilter) and [`AgeFilter`](#agefilter) to remove pairs that are listed less than 10 days ago. After that [`PrecisionFilter`](#precisionfilter) and [`PriceFilter`](#pricefilter) are applied, filtering all assets where 1 price unit is > 1%. Then the [`SpreadFilter`](#spreadfilter) and [`VolatilityFilter`](#volatilityfilter) are applied and pairs are finally shuffled with the random seed set to some predefined value.
```json ```json
"exchange": { "exchange": {
@@ -614,6 +624,10 @@ The below example blacklists `BNB/BTC`, uses `VolumePairList` with `20` assets,
"number_assets": 20, "number_assets": 20,
"sort_key": "quoteVolume" "sort_key": "quoteVolume"
}, },
{
"method": "DelistFilter",
"max_days_from_now": 0,
},
{"method": "AgeFilter", "min_days_listed": 10}, {"method": "AgeFilter", "min_days_listed": 10},
{"method": "PrecisionFilter"}, {"method": "PrecisionFilter"},
{"method": "PriceFilter", "low_price_ratio": 0.01}, {"method": "PriceFilter", "low_price_ratio": 0.01},

View File

@@ -84,6 +84,7 @@ Check the [configuration documentation](configuration.md) about how to set the b
**Always use dry mode when testing as this gives you an idea of how your strategy will work in reality without risking capital.** **Always use dry mode when testing as this gives you an idea of how your strategy will work in reality without risking capital.**
## Diving in deeper ## Diving in deeper
**For the following section we will use the [user_data/strategies/sample_strategy.py](https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/templates/sample_strategy.py) **For the following section we will use the [user_data/strategies/sample_strategy.py](https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/templates/sample_strategy.py)
file as reference.** file as reference.**
@@ -783,6 +784,7 @@ Please always check the mode of operation to select the correct method to get da
- `ohlcv(pair, timeframe)` - Currently cached candle (OHLCV) data for the pair, returns DataFrame or empty DataFrame. - `ohlcv(pair, timeframe)` - Currently cached candle (OHLCV) data for the pair, returns DataFrame or empty DataFrame.
- [`orderbook(pair, maximum)`](#orderbookpair-maximum) - Returns latest orderbook data for the pair, a dict with bids/asks with a total of `maximum` entries. - [`orderbook(pair, maximum)`](#orderbookpair-maximum) - Returns latest orderbook data for the pair, a dict with bids/asks with a total of `maximum` entries.
- [`ticker(pair)`](#tickerpair) - Returns current ticker data for the pair. See [ccxt documentation](https://github.com/ccxt/ccxt/wiki/Manual#price-tickers) for more details on the Ticker data structure. - [`ticker(pair)`](#tickerpair) - Returns current ticker data for the pair. See [ccxt documentation](https://github.com/ccxt/ccxt/wiki/Manual#price-tickers) for more details on the Ticker data structure.
- [`check_delisting(pair)`](#check_delistingpair) - Return Datetime of the pair delisting schedule if any, otherwise return None
- [`funding_rate(pair)`](#funding_ratepair) - Returns current funding rate data for the pair. - [`funding_rate(pair)`](#funding_ratepair) - Returns current funding rate data for the pair.
- `runmode` - Property containing the current runmode. - `runmode` - Property containing the current runmode.
@@ -906,6 +908,22 @@ if self.dp.runmode.value in ('live', 'dry_run'):
!!! Warning "Warning about backtesting" !!! Warning "Warning about backtesting"
This method will always return up-to-date / real-time values. As such, usage during backtesting / hyperopt without runmode checks will lead to wrong results, e.g. your whole dataframe will contain the same single value in all rows. This method will always return up-to-date / real-time values. As such, usage during backtesting / hyperopt without runmode checks will lead to wrong results, e.g. your whole dataframe will contain the same single value in all rows.
### *check_delisting(pair)*
```python
def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs):
if self.dp.runmode.value in ('live', 'dry_run'):
delisting_dt = self.dp.check_delisting(pair)
if delisting_dt is not None:
return "delist"
```
!!! Note "Availabiity of delisting information"
This method is only available for certain exchanges and will return `None` in cases this is not available or if the pair is not scheduled for delisting.
!!! Warning "Warning about backtesting"
This method will always return up-to-date / real-time values. As such, usage during backtesting / hyperopt without runmode checks will lead to wrong results, e.g. your whole dataframe will contain the same single value in all rows.
### *funding_rate(pair)* ### *funding_rate(pair)*
Retrieves the current funding rate for the pair and only works for futures pairs in the format of `base/quote:settle` (e.g. `ETH/USDT:USDT`). Retrieves the current funding rate for the pair and only works for futures pairs in the format of `base/quote:settle` (e.g. `ETH/USDT:USDT`).

View File

@@ -49,6 +49,7 @@ AVAILABLE_PAIRLISTS = [
"RemotePairList", "RemotePairList",
"MarketCapPairList", "MarketCapPairList",
"AgeFilter", "AgeFilter",
"DelistFilter",
"FullTradesFilter", "FullTradesFilter",
"OffsetFilter", "OffsetFilter",
"PerformanceFilter", "PerformanceFilter",

View File

@@ -604,3 +604,19 @@ class DataProvider:
if always_send or message not in self.__msg_cache: if always_send or message not in self.__msg_cache:
self._msg_queue.append(message) self._msg_queue.append(message)
self.__msg_cache[message] = True self.__msg_cache[message] = True
def check_delisting(self, pair: str) -> datetime | None:
"""
Check if a pair gonna be delisted on the exchange.
Will only return datetime if the pair is gonna be delisted.
:param pair: Pair to check
:return: Datetime of the pair's delisting, None otherwise
"""
if self._exchange is None:
raise OperationalException(NO_EXCHANGE_EXCEPTION)
try:
return self._exchange.check_delisting_time(pair)
except ExchangeError:
logger.warning(f"Could not fetch market data for {pair}. Assuming no delisting.")
return None

View File

@@ -5,10 +5,11 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import ccxt import ccxt
from cachetools import TTLCache
from pandas import DataFrame from pandas import DataFrame
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
from freqtrade.enums import CandleType, MarginMode, PriceType, TradingMode from freqtrade.enums import TRADE_MODES, CandleType, MarginMode, PriceType, RunMode, TradingMode
from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
from freqtrade.exchange.binance_public_data import ( from freqtrade.exchange.binance_public_data import (
@@ -40,6 +41,7 @@ class Binance(Exchange):
"fetch_orders_limit_minutes": None, "fetch_orders_limit_minutes": None,
"l2_limit_range": [5, 10, 20, 50, 100, 500, 1000], "l2_limit_range": [5, 10, 20, 50, 100, 500, 1000],
"ws_enabled": True, "ws_enabled": True,
"has_delisting": True,
} }
_ft_has_futures: FtHas = { _ft_has_futures: FtHas = {
"funding_fee_candle_limit": 1000, "funding_fee_candle_limit": 1000,
@@ -68,6 +70,10 @@ class Binance(Exchange):
(TradingMode.FUTURES, MarginMode.ISOLATED), (TradingMode.FUTURES, MarginMode.ISOLATED),
] ]
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._spot_delist_schedule_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
def get_proxy_coin(self) -> str: def get_proxy_coin(self) -> str:
""" """
Get the proxy coin for the given coin Get the proxy coin for the given coin
@@ -432,3 +438,105 @@ class Binance(Exchange):
return await super()._async_get_trade_history_id( return await super()._async_get_trade_history_id(
pair, until=until, since=since, from_id=from_id pair, until=until, since=since, from_id=from_id
) )
def _check_delisting_futures(self, pair: str) -> datetime | None:
delivery_time = self.markets.get(pair, {}).get("info", {}).get("deliveryDate", None)
if delivery_time:
if isinstance(delivery_time, str) and (delivery_time != ""):
delivery_time = int(delivery_time)
# Binance set a very high delivery time for all perpetuals.
# We compare with delivery time of BTC/USDT:USDT which assumed to never be delisted
btc_delivery_time = (
self.markets.get("BTC/USDT:USDT", {}).get("info", {}).get("deliveryDate", None)
)
if delivery_time == btc_delivery_time:
return None
delivery_time = dt_from_ts(delivery_time)
return delivery_time
def check_delisting_time(self, pair: str) -> datetime | None:
"""
Check if the pair gonna be delisted.
By default, it returns None.
:param pair: Market symbol
:return: Datetime if the pair gonna be delisted, None otherwise
"""
if self._config["runmode"] not in TRADE_MODES:
return None
if self.trading_mode == TradingMode.FUTURES:
return self._check_delisting_futures(pair)
return self._get_spot_pair_delist_time(pair, refresh=False)
def _get_spot_delist_schedule(self):
"""
Get the delisting schedule for spot pairs
Only works in live mode as it requires API keys,
Return sample:
[{
"delistTime": "1759114800000",
"symbols": [
"OMNIBTC",
"OMNIFDUSD",
"OMNITRY",
"OMNIUSDC",
"OMNIUSDT"
]
}]
"""
try:
delist_schedule = self._api.sapi_get_spot_delist_schedule()
return delist_schedule
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.OperationFailed, ccxt.ExchangeError) as e:
raise TemporaryError(
f"Could not get delist schedule {e.__class__.__name__}. Message: {e}"
) from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
def _get_spot_pair_delist_time(self, pair: str, refresh: bool = False) -> datetime | None:
"""
Get the delisting time for a pair if it will be delisted
:param pair: Pair to get the delisting time for
:param refresh: true if you need fresh data
:return: int: delisting time None if not delisting
"""
if not pair or not self._config["runmode"] == RunMode.LIVE:
# Endpoint only works in live mode as it requires API keys
return None
cache = self._spot_delist_schedule_cache
if not refresh:
if delist_time := cache.get(pair, None):
return delist_time
delist_schedule = self._get_spot_delist_schedule()
if delist_schedule is None:
return None
for schedule in delist_schedule:
delist_dt = dt_from_ts(int(schedule["delistTime"]))
for symbol in schedule["symbols"]:
ft_symbol = next(
(
pair
for pair, market in self.markets.items()
if market.get("id", None) == symbol
),
None,
)
if ft_symbol is None:
continue
cache[ft_symbol] = delist_dt
return cache.get(pair, None)

View File

@@ -166,6 +166,7 @@ class Exchange:
"proxy_coin_mapping": {}, # Mapping for proxy coins "proxy_coin_mapping": {}, # Mapping for proxy coins
# Expected to be in the format {"fetchOHLCV": True} or {"fetchOHLCV": False} # Expected to be in the format {"fetchOHLCV": True} or {"fetchOHLCV": False}
"ws_enabled": False, # Set to true for exchanges with tested websocket support "ws_enabled": False, # Set to true for exchanges with tested websocket support
"has_delisting": False, # Set to true for exchanges that have delisting pair checks
} }
_ft_has: FtHas = {} _ft_has: FtHas = {}
_ft_has_futures: FtHas = {} _ft_has_futures: FtHas = {}
@@ -3912,3 +3913,14 @@ class Exchange:
# describes the min amt for a tier, and the lowest tier will always go down to 0 # describes the min amt for a tier, and the lowest tier will always go down to 0
else: else:
raise ExchangeError(f"Cannot get maintenance ratio using {self.name}") raise ExchangeError(f"Cannot get maintenance ratio using {self.name}")
def check_delisting_time(self, pair: str) -> datetime | None:
"""
Check if the pair gonna be delisted.
This function should be overridden by the exchange class if the exchange
provides such information.
By default, it returns None.
:param pair: Market symbol
:return: Datetime if the pair gonna be delisted, None otherwise
"""
return None

View File

@@ -63,6 +63,9 @@ class FtHas(TypedDict, total=False):
# Websocket control # Websocket control
ws_enabled: bool ws_enabled: bool
# Delisting check
has_delisting: bool
class Ticker(TypedDict): class Ticker(TypedDict):
symbol: str symbol: str

View File

@@ -0,0 +1,95 @@
"""
Delist pair list filter
"""
import logging
from datetime import UTC, datetime, timedelta
from freqtrade.exceptions import ConfigurationError
from freqtrade.exchange.exchange_types import Ticker
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
from freqtrade.util import format_date
logger = logging.getLogger(__name__)
class DelistFilter(IPairList):
supports_backtesting = SupportsBacktesting.NO
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._max_days_from_now = self._pairlistconfig.get("max_days_from_now", 0)
if self._max_days_from_now < 0:
raise ConfigurationError("DelistFilter requires max_days_from_now to be >= 0")
if not self._exchange._ft_has["has_delisting"]:
raise ConfigurationError(
"DelistFilter doesn't support this exchange and trading mode combination.",
)
@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
"""
return (
f"{self.name} - Filtering pairs that will be delisted"
+ (
f" in the next {self._max_days_from_now} days"
if self._max_days_from_now > 0
else ""
)
+ "."
)
@staticmethod
def description() -> str:
return "Filter pairs that will be delisted on exchange."
@staticmethod
def available_parameters() -> dict[str, PairlistParameter]:
return {
"max_days_from_now": {
"type": "number",
"default": 0,
"description": "Max days from now",
"help": (
"Remove pairs that will be delisted in the next X days. Set to 0 to remove all."
),
},
}
def _validate_pair(self, pair: str, ticker: Ticker | None) -> bool:
"""
Check if pair will be delisted.
:param pair: Pair that's currently validated
:param ticker: ticker dict as returned from ccxt.fetch_ticker
:return: True if the pair can stay, false if it should be removed
"""
delist_date = self._exchange.check_delisting_time(pair)
if delist_date is not None:
remove_pair = self._max_days_from_now == 0
if self._max_days_from_now > 0:
current_datetime = datetime.now(UTC)
max_delist_date = current_datetime + timedelta(days=self._max_days_from_now)
remove_pair = delist_date <= max_delist_date
if remove_pair:
self.log_once(
f"Removed {pair} from whitelist, because it will be delisted on "
f"{format_date(delist_date)}.",
logger.info,
)
return False
return True

View File

@@ -8,6 +8,7 @@ from freqtrade.data.dataprovider import DataProvider
from freqtrade.enums import CandleType, RunMode from freqtrade.enums import CandleType, RunMode
from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exceptions import ExchangeError, OperationalException
from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.plugins.pairlistmanager import PairListManager
from freqtrade.util import dt_utc
from tests.conftest import EXMS, generate_test_data, get_patched_exchange from tests.conftest import EXMS, generate_test_data, get_patched_exchange
@@ -449,6 +450,12 @@ def test_no_exchange_mode(default_conf):
with pytest.raises(OperationalException, match=message): with pytest.raises(OperationalException, match=message):
dp.available_pairs() dp.available_pairs()
with pytest.raises(OperationalException, match=message):
dp.funding_rate("XRP/USDT:USDT")
with pytest.raises(OperationalException, match=message):
dp.check_delisting("XRP/USDT")
def test_dp_send_msg(default_conf): def test_dp_send_msg(default_conf):
default_conf["runmode"] = RunMode.DRY_RUN default_conf["runmode"] = RunMode.DRY_RUN
@@ -612,3 +619,20 @@ def test_dp_get_required_startup(default_conf_usdt):
assert dp.get_required_startup("5m") == 51880 assert dp.get_required_startup("5m") == 51880
assert dp.get_required_startup("1h") == 4360 assert dp.get_required_startup("1h") == 4360
assert dp.get_required_startup("1d") == 220 assert dp.get_required_startup("1d") == 220
def test_check_delisting(mocker, default_conf_usdt):
delist_mock = MagicMock(return_value=None)
exchange = get_patched_exchange(mocker, default_conf_usdt)
mocker.patch.object(exchange, "check_delisting_time", delist_mock)
dp = DataProvider(default_conf_usdt, exchange)
res = dp.check_delisting("ETH/USDT")
assert res is None
assert delist_mock.call_count == 1
delist_mock2 = MagicMock(return_value=dt_utc(2025, 10, 2))
mocker.patch.object(exchange, "check_delisting_time", delist_mock2)
res = dp.check_delisting("XRP/USDT")
assert res == dt_utc(2025, 10, 2)
assert delist_mock2.call_count == 1

View File

@@ -1,3 +1,4 @@
from copy import deepcopy
from datetime import datetime, timedelta from datetime import datetime, timedelta
from random import randint from random import randint
from unittest.mock import MagicMock, PropertyMock from unittest.mock import MagicMock, PropertyMock
@@ -7,7 +8,7 @@ import pandas as pd
import pytest import pytest
from freqtrade.data.converter.trade_converter import trades_dict_to_list from freqtrade.data.converter.trade_converter import trades_dict_to_list
from freqtrade.enums import CandleType, MarginMode, TradingMode from freqtrade.enums import CandleType, MarginMode, RunMode, TradingMode
from freqtrade.exceptions import DependencyException, InvalidOrderException, OperationalException from freqtrade.exceptions import DependencyException, InvalidOrderException, OperationalException
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_seconds from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_seconds
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
@@ -1108,3 +1109,84 @@ async def test__async_get_trade_history_id_binance_fast(
# Clean up event loop to avoid warnings # Clean up event loop to avoid warnings
exchange.close() exchange.close()
def test_check_delisting_time_binance(default_conf_usdt, mocker):
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="binance")
exchange._config["runmode"] = RunMode.BACKTEST
delist_mock = MagicMock(return_value=None)
delist_fut_mock = MagicMock(return_value=None)
mocker.patch.object(exchange, "_get_spot_pair_delist_time", delist_mock)
mocker.patch.object(exchange, "_check_delisting_futures", delist_fut_mock)
# Invalid run mode
resp = exchange.check_delisting_time("BTC/USDT")
assert resp is None
assert delist_mock.call_count == 0
assert delist_fut_mock.call_count == 0
# Delist spot called
exchange._config["runmode"] = RunMode.DRY_RUN
resp1 = exchange.check_delisting_time("BTC/USDT")
assert resp1 is None
assert delist_mock.call_count == 1
assert delist_fut_mock.call_count == 0
delist_mock.reset_mock()
# Delist futures called
exchange.trading_mode = TradingMode.FUTURES
resp1 = exchange.check_delisting_time("BTC/USDT:USDT")
assert resp1 is None
assert delist_mock.call_count == 0
assert delist_fut_mock.call_count == 1
def test__check_delisting_futures_binance(default_conf_usdt, mocker, markets):
markets["BTC/USDT:USDT"] = deepcopy(markets["SOL/BUSD:BUSD"])
markets["BTC/USDT:USDT"]["info"]["deliveryDate"] = 4133404800000
markets["SOL/BUSD:BUSD"]["info"]["deliveryDate"] = 4133404800000
markets["ADA/USDT:USDT"]["info"]["deliveryDate"] = 1760745600000 # 2025-10-18
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="binance")
mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets))
resp_sol = exchange._check_delisting_futures("SOL/BUSD:BUSD")
# Delisting is equal to BTC
assert resp_sol is None
# Actually has a delisting date
resp_ada = exchange._check_delisting_futures("ADA/USDT:USDT")
assert resp_ada == dt_utc(2025, 10, 18)
def test__get_spot_delist_schedule_binance(default_conf_usdt, mocker):
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="binance")
ret_value = [{"delistTime": 1759114800000, "symbols": ["ETCBTC"]}]
schedule_mock = mocker.patch.object(exchange, "_get_spot_delist_schedule", return_value=None)
# None - mode is DRY
assert exchange._get_spot_pair_delist_time("ETC/BTC") is None
# Switch to live
exchange._config["runmode"] = RunMode.LIVE
assert exchange._get_spot_pair_delist_time("ETC/BTC") is None
mocker.patch.object(exchange, "_get_spot_delist_schedule", return_value=ret_value)
resp = exchange._get_spot_pair_delist_time("ETC/BTC")
assert resp == dt_utc(2025, 9, 29, 3, 0)
assert schedule_mock.call_count == 1
schedule_mock.reset_mock()
# Caching - don't refresh.
assert exchange._get_spot_pair_delist_time("ETC/BTC", refresh=False) == dt_utc(
2025, 9, 29, 3, 0
)
assert schedule_mock.call_count == 0
api_mock = MagicMock()
ccxt_exceptionhandlers(
mocker,
default_conf_usdt,
api_mock,
"binance",
"_get_spot_delist_schedule",
"sapi_get_spot_delist_schedule",
retries=1,
)

View File

@@ -28,7 +28,11 @@ EXCHANGES = {
"leverage_tiers_public": False, "leverage_tiers_public": False,
"leverage_in_spot_market": False, "leverage_in_spot_market": False,
"trades_lookback_hours": 4, "trades_lookback_hours": 4,
"private_methods": ["fapiPrivateGetPositionSideDual", "fapiPrivateGetMultiAssetsMargin"], "private_methods": [
"fapiPrivateGetPositionSideDual",
"fapiPrivateGetMultiAssetsMargin",
"sapi_get_spot_delist_schedule",
],
"sample_order": [ "sample_order": [
{ {
"exchange_response": { "exchange_response": {

View File

@@ -18,7 +18,7 @@ from freqtrade.persistence import LocalTrade, Trade
from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist, expand_pairlist from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist, expand_pairlist
from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.plugins.pairlistmanager import PairListManager
from freqtrade.resolvers import PairListResolver from freqtrade.resolvers import PairListResolver
from freqtrade.util.datetime_helpers import dt_now from freqtrade.util import dt_now, dt_utc
from tests.conftest import ( from tests.conftest import (
EXMS, EXMS,
create_mock_trades_usdt, create_mock_trades_usdt,
@@ -1833,6 +1833,17 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo
None, None,
"PriceFilter requires max_value to be >= 0", "PriceFilter requires max_value to be >= 0",
), # OperationalException expected ), # OperationalException expected
(
{"method": "DelistFilter", "max_days_from_now": -1},
None,
"DelistFilter requires max_days_from_now to be >= 0",
), # ConfigurationError expected
(
{"method": "DelistFilter", "max_days_from_now": 1},
"[{'DelistFilter': 'DelistFilter - Filtering pairs that will be delisted in the "
"next 1 days.'}]",
None,
), # ConfigurationError expected
( (
{"method": "RangeStabilityFilter", "lookback_days": 10, "min_rate_of_change": 0.01}, {"method": "RangeStabilityFilter", "lookback_days": 10, "min_rate_of_change": 0.01},
"[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate " "[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate "
@@ -2601,3 +2612,63 @@ def test_backtesting_modes(
if expected_warning: if expected_warning:
assert log_has_re(f"Pairlist Handlers {expected_warning}", caplog) assert log_has_re(f"Pairlist Handlers {expected_warning}", caplog)
def test_DelistFilter_error(whitelist_conf) -> None:
whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, {"method": "DelistFilter"}]
exchange_mock = MagicMock()
exchange_mock._ft_has = {"has_delisting": False}
with pytest.raises(
OperationalException,
match=r"DelistFilter doesn't support this exchange and trading mode combination\.",
):
PairListManager(exchange_mock, whitelist_conf, MagicMock())
@pytest.mark.usefixtures("init_persistence")
def test_DelistFilter(mocker, default_conf_usdt, time_machine, caplog) -> None:
default_conf_usdt["exchange"]["pair_whitelist"] = [
"ETH/USDT",
"XRP/USDT",
"BTC/USDT",
"NEO/USDT",
]
default_conf_usdt["pairlists"] = [
{"method": "StaticPairList"},
{"method": "DelistFilter", "max_days_from_now": 3},
]
default_conf_usdt["max_open_trades"] = -1
exchange = get_patched_exchange(mocker, default_conf_usdt)
def delist_mock(pair: str):
mock_delist = {
"XRP/USDT": dt_utc(2025, 9, 1) + timedelta(days=1), # Delisting in 1 day
"NEO/USDT": dt_utc(2025, 9, 1) + timedelta(days=5, hours=2), # Delisting in 5 days
}
return mock_delist.get(pair, None)
time_machine.move_to("2025-09-01 01:00:00 +00:00", tick=False)
mocker.patch.object(exchange, "check_delisting_time", delist_mock)
pm = PairListManager(exchange, default_conf_usdt)
pm.refresh_pairlist()
assert pm.whitelist == ["ETH/USDT", "BTC/USDT", "NEO/USDT"]
assert log_has(
"Removed XRP/USDT from whitelist, because it will be delisted on 2025-09-02 00:00:00.",
caplog,
)
# NEO is kept initially as delisting is in 5 days, but config is 3 days
time_machine.move_to("2025-09-03 01:00:00 +00:00", tick=False)
pm.refresh_pairlist()
assert pm.whitelist == ["ETH/USDT", "BTC/USDT", "NEO/USDT"]
# NEO not removed yet, expiry falls into the window 1 hour later
time_machine.move_to("2025-09-03 02:00:00 +00:00", tick=False)
pm.refresh_pairlist()
assert pm.whitelist == ["ETH/USDT", "BTC/USDT"]
assert log_has(
"Removed NEO/USDT from whitelist, because it will be delisted on 2025-09-06 02:00:00.",
caplog,
)