From cf010c3f17d1a7d5ef747ddea45a7ec871e6eb8c Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 16 Sep 2025 08:54:47 +0900 Subject: [PATCH 01/28] initial concept --- freqtrade/exchange/binance.py | 1 + freqtrade/exchange/exchange.py | 23 +++++++++++++++++++++++ freqtrade/freqtradebot.py | 9 +++++++++ 3 files changed, 33 insertions(+) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index e40135055..d18f53e6d 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -59,6 +59,7 @@ class Binance(Exchange): "BNFCR": "USDC", "BFUSD": "USDT", }, + "delivery_column": "deliveryDate", } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 7d086677d..3fa381a03 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -3911,3 +3911,26 @@ class Exchange: # describes the min amt for a tier, and the lowest tier will always go down to 0 else: raise ExchangeError(f"Cannot get maintenance ratio using {self.name}") + + def check_delivery_time(self, pair: str) -> int: + """ + Check if the futures contract is a delivery contract + :param pair: Market symbol + :return: True if the contract is a delivery contract, False otherwise + """ + if self.trading_mode != TradingMode.FUTURES: + return 0 + + column_to_check = self._ft_has.get("delivery_column", "") + + logger.info(f"Checking delivery time at {column_to_check}") + + delivery_time = self.markets.get(pair, {}).get("info", {}).get(column_to_check, None) + if delivery_time is not None: + if isinstance(delivery_time, str) and (delivery_time != ""): + delivery_time = int(delivery_time) + + return delivery_time + # if "delivery" in market and market["delivery"] is not None: + # return True + return 0 diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index fed9a6533..a98a9a372 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -661,6 +661,15 @@ class FreqtradeBot(LoggingMixin): """ logger.debug(f"create_trade for pair {pair}") + delivery_time = self.exchange.check_delivery_time(pair) + if delivery_time: + delivery_date = dt_from_ts(delivery_time) + logger.info( + f"Pair {pair} has a delivery time of " + f"{delivery_date.strftime(constants.DATETIME_PRINT_FORMAT)}. Skipping." + ) + return False + analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe) nowtime = analyzed_df.iloc[-1]["date"] if len(analyzed_df) > 0 else None From 7564b1e6df2c3d1ffc2ec96dbf07c79d42ccdca7 Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 16 Sep 2025 09:23:20 +0900 Subject: [PATCH 02/28] remove extra log, fix delivery time check for binance --- freqtrade/exchange/exchange.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 3fa381a03..21c1a07c9 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -3923,13 +3923,21 @@ class Exchange: column_to_check = self._ft_has.get("delivery_column", "") - logger.info(f"Checking delivery time at {column_to_check}") - delivery_time = self.markets.get(pair, {}).get("info", {}).get(column_to_check, None) if delivery_time is not None: if isinstance(delivery_time, str) and (delivery_time != ""): delivery_time = int(delivery_time) + if self.name == "Binance": + # 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(column_to_check, None) + ) + + if delivery_time == btc_delivery_time: + return 0 + return delivery_time # if "delivery" in market and market["delivery"] is not None: # return True From 01e288297a49b3b33b6cae93e6534186a613910e Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 16 Sep 2025 09:33:48 +0900 Subject: [PATCH 03/28] fix precommit --- freqtrade/exchange/exchange.py | 4 +--- freqtrade/exchange/exchange_types.py | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 21c1a07c9..7e1b8ca7d 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -3924,7 +3924,7 @@ class Exchange: column_to_check = self._ft_has.get("delivery_column", "") delivery_time = self.markets.get(pair, {}).get("info", {}).get(column_to_check, None) - if delivery_time is not None: + if delivery_time: if isinstance(delivery_time, str) and (delivery_time != ""): delivery_time = int(delivery_time) @@ -3939,6 +3939,4 @@ class Exchange: return 0 return delivery_time - # if "delivery" in market and market["delivery"] is not None: - # return True return 0 diff --git a/freqtrade/exchange/exchange_types.py b/freqtrade/exchange/exchange_types.py index cd2bd0059..9038edb13 100644 --- a/freqtrade/exchange/exchange_types.py +++ b/freqtrade/exchange/exchange_types.py @@ -63,6 +63,9 @@ class FtHas(TypedDict, total=False): # Websocket control ws_enabled: bool + # Delisting checks + delivery_column: str + class Ticker(TypedDict): symbol: str From a4a5fbe4a821631d3d7d1793e949cb6859484eff Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 16 Sep 2025 10:53:00 +0900 Subject: [PATCH 04/28] add column name for bybit and okx --- freqtrade/exchange/bybit.py | 1 + freqtrade/exchange/okx.py | 1 + 2 files changed, 2 insertions(+) diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index 93e62d25e..08106e89e 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -59,6 +59,7 @@ class Bybit(Exchange): "exchange_has_overrides": { "fetchOrder": True, }, + "delivery_column": "deliveryTime", } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ diff --git a/freqtrade/exchange/okx.py b/freqtrade/exchange/okx.py index 847f8a284..910f912b9 100644 --- a/freqtrade/exchange/okx.py +++ b/freqtrade/exchange/okx.py @@ -46,6 +46,7 @@ class Okx(Exchange): }, "stoploss_blocks_assets": False, "ws_enabled": True, + "delivery_column": "expTime", } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ From d069f5477e0743a5b4fa8b27802d96db4f4422b8 Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 16 Sep 2025 11:06:34 +0900 Subject: [PATCH 05/28] add delisted pair to blacklist --- freqtrade/freqtradebot.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index a98a9a372..d7a19090a 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -666,8 +666,12 @@ class FreqtradeBot(LoggingMixin): delivery_date = dt_from_ts(delivery_time) logger.info( f"Pair {pair} has a delivery time of " - f"{delivery_date.strftime(constants.DATETIME_PRINT_FORMAT)}. Skipping." + f"{delivery_date.strftime(constants.DATETIME_PRINT_FORMAT)}." ) + + if pair not in self.pairlists.blacklist: + self.pairlists.blacklist.append(pair) + return False analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe) From 03c67d6a3b8dc46f997d8684f18c707252c53d9e Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 16 Sep 2025 14:59:44 +0900 Subject: [PATCH 06/28] switch to use dataprovider to give flexibility to users --- freqtrade/data/dataprovider.py | 16 ++++++++++++++ freqtrade/exchange/binance.py | 29 ++++++++++++++++++++++++- freqtrade/exchange/bybit.py | 1 - freqtrade/exchange/exchange.py | 32 ++++++---------------------- freqtrade/exchange/exchange_types.py | 3 --- freqtrade/exchange/okx.py | 1 - freqtrade/freqtradebot.py | 13 ----------- 7 files changed, 51 insertions(+), 44 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 76e2c5491..dfab29c08 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -604,3 +604,19 @@ class DataProvider: if always_send or message not in self.__msg_cache: self._msg_queue.append(message) 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 diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index d18f53e6d..8f18f4de0 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -59,7 +59,6 @@ class Binance(Exchange): "BNFCR": "USDC", "BFUSD": "USDT", }, - "delivery_column": "deliveryDate", } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ @@ -433,3 +432,31 @@ class Binance(Exchange): return await super()._async_get_trade_history_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_spot(self, pair: str) -> datetime | None: + return None + + def check_delisting_time(self, pair: str) -> datetime | None: + if self.trading_mode == TradingMode.SPOT: + return self.check_delisting_spot(pair) + + return self.check_delisting_futures(pair) diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index 08106e89e..93e62d25e 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -59,7 +59,6 @@ class Bybit(Exchange): "exchange_has_overrides": { "fetchOrder": True, }, - "delivery_column": "deliveryTime", } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 7e1b8ca7d..6ccfdbebb 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -3912,31 +3912,13 @@ class Exchange: else: raise ExchangeError(f"Cannot get maintenance ratio using {self.name}") - def check_delivery_time(self, pair: str) -> int: + def check_delisting_time(self, pair: str) -> datetime | None: """ - Check if the futures contract is a delivery contract + 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: True if the contract is a delivery contract, False otherwise + :return: Datetime if the pair gonna be delisted, None otherwise """ - if self.trading_mode != TradingMode.FUTURES: - return 0 - - column_to_check = self._ft_has.get("delivery_column", "") - - delivery_time = self.markets.get(pair, {}).get("info", {}).get(column_to_check, None) - if delivery_time: - if isinstance(delivery_time, str) and (delivery_time != ""): - delivery_time = int(delivery_time) - - if self.name == "Binance": - # 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(column_to_check, None) - ) - - if delivery_time == btc_delivery_time: - return 0 - - return delivery_time - return 0 + return None diff --git a/freqtrade/exchange/exchange_types.py b/freqtrade/exchange/exchange_types.py index 9038edb13..cd2bd0059 100644 --- a/freqtrade/exchange/exchange_types.py +++ b/freqtrade/exchange/exchange_types.py @@ -63,9 +63,6 @@ class FtHas(TypedDict, total=False): # Websocket control ws_enabled: bool - # Delisting checks - delivery_column: str - class Ticker(TypedDict): symbol: str diff --git a/freqtrade/exchange/okx.py b/freqtrade/exchange/okx.py index 910f912b9..847f8a284 100644 --- a/freqtrade/exchange/okx.py +++ b/freqtrade/exchange/okx.py @@ -46,7 +46,6 @@ class Okx(Exchange): }, "stoploss_blocks_assets": False, "ws_enabled": True, - "delivery_column": "expTime", } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index d7a19090a..fed9a6533 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -661,19 +661,6 @@ class FreqtradeBot(LoggingMixin): """ logger.debug(f"create_trade for pair {pair}") - delivery_time = self.exchange.check_delivery_time(pair) - if delivery_time: - delivery_date = dt_from_ts(delivery_time) - logger.info( - f"Pair {pair} has a delivery time of " - f"{delivery_date.strftime(constants.DATETIME_PRINT_FORMAT)}." - ) - - if pair not in self.pairlists.blacklist: - self.pairlists.blacklist.append(pair) - - return False - analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe) nowtime = analyzed_df.iloc[-1]["date"] if len(analyzed_df) > 0 else None From 72dd0d062b0145cd14ee7d6e7d159cb943978a28 Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 16 Sep 2025 15:52:19 +0900 Subject: [PATCH 07/28] add spot check for binance --- freqtrade/exchange/binance.py | 71 +++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 8f18f4de0..0f761ca8b 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -3,8 +3,10 @@ import logging from datetime import UTC, datetime from pathlib import Path +from threading import Lock import ccxt +from cachetools import TTLCache from pandas import DataFrame from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS @@ -68,6 +70,11 @@ class Binance(Exchange): (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) + self._spot_delist_schedule_cache_lock = Lock() + def get_proxy_coin(self) -> str: """ Get the proxy coin for the given coin @@ -452,11 +459,67 @@ class Binance(Exchange): return delivery_time - def check_delisting_spot(self, pair: str) -> datetime | None: - return None - def check_delisting_time(self, pair: str) -> datetime | None: if self.trading_mode == TradingMode.SPOT: - return self.check_delisting_spot(pair) + return self.get_spot_pair_delist_time(pair, refresh=False) return self.check_delisting_futures(pair) + + def get_spot_delist_schedule(self): + 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.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 = True) -> 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: + return None + + cache = self._spot_delist_schedule_cache + lock = self._spot_delist_schedule_cache_lock + + if not refresh: + with lock: + delist_time = cache.get(pair, None) + + if delist_time: + return delist_time + + delist_schedule = self.get_spot_delist_schedule() + + if delist_schedule is None: + return None + + with lock: + 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 + logger.info(f"{ft_symbol} delisted at {delist_dt}") + + return cache.get(pair, None) From d8d8260d76be6fe014cccdba8f340e6d559e77f1 Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 16 Sep 2025 16:11:23 +0900 Subject: [PATCH 08/28] remove logger --- freqtrade/exchange/binance.py | 1 - 1 file changed, 1 deletion(-) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 0f761ca8b..9b617bbf4 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -520,6 +520,5 @@ class Binance(Exchange): continue cache[ft_symbol] = delist_dt - logger.info(f"{ft_symbol} delisted at {delist_dt}") return cache.get(pair, None) From 8e500d4601e033abf37f8f1faaeb76647b9c4900 Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 17 Sep 2025 09:16:05 +0900 Subject: [PATCH 09/28] add DelistFilter --- build_helpers/schema.json | 1 + config_examples/config_full.example.json | 56 ++++++++----- docs/includes/pairlists.md | 23 ++++-- freqtrade/constants.py | 1 + freqtrade/plugins/pairlist/DelistFilter.py | 93 ++++++++++++++++++++++ 5 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 freqtrade/plugins/pairlist/DelistFilter.py diff --git a/build_helpers/schema.json b/build_helpers/schema.json index 663a6191d..b644bc2f9 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -587,6 +587,7 @@ "RemotePairList", "MarketCapPairList", "AgeFilter", + "DelistFilter", "FullTradesFilter", "OffsetFilter", "PerformanceFilter", diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index fd5b02089..983b9b3a1 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -25,10 +25,10 @@ "trading_mode": "spot", "margin_mode": "", "minimal_roi": { - "40": 0.0, - "30": 0.01, - "20": 0.02, - "0": 0.04 + "40": 0.0, + "30": 0.01, + "20": 0.02, + "0": 0.04 }, "stoploss": -0.10, "unfilledtimeout": { @@ -47,7 +47,7 @@ "bids_to_ask_delta": 1 } }, - "exit_pricing":{ + "exit_pricing": { "price_side": "same", "use_order_book": true, "order_book_top": 1, @@ -70,18 +70,38 @@ "exit": "GTC" }, "pairlists": [ - {"method": "StaticPairList"}, - {"method": "FullTradesFilter"}, + { + "method": "StaticPairList" + }, + { + "method": "DelistFilter", + "max_days_from_now": 0, + }, + { + "method": "FullTradesFilter" + }, { "method": "VolumePairList", "number_assets": 20, "sort_key": "quoteVolume", "refresh_period": 1800 }, - {"method": "AgeFilter", "min_days_listed": 10}, - {"method": "PrecisionFilter"}, - {"method": "PriceFilter", "low_price_ratio": 0.01, "min_price": 0.00000010}, - {"method": "SpreadFilter", "max_spread_ratio": 0.005}, + { + "method": "AgeFilter", + "min_days_listed": 10 + }, + { + "method": "PrecisionFilter" + }, + { + "method": "PriceFilter", + "low_price_ratio": 0.01, + "min_price": 0.00000010 + }, + { + "method": "SpreadFilter", + "max_spread_ratio": 0.005 + }, { "method": "RangeStabilityFilter", "lookback_days": 10, @@ -166,12 +186,12 @@ "external_message_consumer": { "enabled": false, "producers": [ - { - "name": "default", - "host": "127.0.0.2", - "port": 8080, - "ws_token": "secret_ws_t0ken." - } + { + "name": "default", + "host": "127.0.0.2", + "port": 8080, + "ws_token": "secret_ws_t0ken." + } ], "wait_timeout": 300, "ping_timeout": 10, @@ -195,4 +215,4 @@ "reduce_df_footprint": false, "dataformat_ohlcv": "feather", "dataformat_trades": "feather" -} +} \ No newline at end of file diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 32e669065..2984c74c8 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), [`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. @@ -27,6 +27,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged * [`RemotePairList`](#remotepairlist) * [`MarketCapPairList`](#marketcappairlist) * [`AgeFilter`](#agefilter) +* [`DelistFilter`](#delistfilter) * [`FullTradesFilter`](#fulltradesfilter) * [`OffsetFilter`](#offsetfilter) * [`PerformanceFilter`](#performancefilter) @@ -180,7 +181,7 @@ More sophisticated approach can be used, by using `lookback_timeframe` for candl * `refresh_period`: Defines the interval (in seconds) at which the pairlist will be refreshed. The default is 1800 seconds (30 minutes). * `lookback_days`: Number of days to look back. When `lookback_days` is selected, the `lookback_timeframe` is defaulted to 1 day. * `lookback_timeframe`: Timeframe to use for the lookback period. -* `lookback_period`: Number of periods to look back at. +* `lookback_period`: Number of periods to look back at. When PercentChangePairList is used after other Pairlist Handlers, it will operate on the outputs of those handlers. If it is the leading Pairlist Handler, it will select pairs from all available markets with the specified stake currency. @@ -270,7 +271,6 @@ You can limit the length of the pairlist with the optional parameter `number_ass ], ``` - !!! 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. `ProducerPairList` can also be used multiple times in sequence, combining the pairs from multiple producers. @@ -312,7 +312,7 @@ The `pairlist_url` option specifies the URL of the remote server where the pairl The `save_to_file` option, when provided with a valid filename, saves the processed pairlist to that file in JSON format. This option is optional, and by default, the pairlist is not saved to a file. ??? Example "Multi bot with shared pairlist example" - + `save_to_file` can be used to save the pairlist to a file with Bot1: ```json @@ -407,6 +407,13 @@ 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`. +#### 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). + +!!! Warning "Backtesting" + `DelistFilter` does not support backtesting mode. + #### 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). @@ -438,7 +445,7 @@ Example to remove the first 10 pairs from the pairlist, and takes the next 20 (t ``` !!! Warning - When `OffsetFilter` is used to split a larger pairlist among multiple bots in combination with `VolumeFilter` + When `OffsetFilter` is used to split a larger pairlist among multiple bots in combination with `VolumeFilter` it can not be guaranteed that pairs won't overlap due to slightly different refresh intervals for the `VolumeFilter`. @@ -601,7 +608,7 @@ Adding `"sort_direction": "asc"` or `"sort_direction": "desc"` enables sorting m ### 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 "exchange": { @@ -614,6 +621,10 @@ The below example blacklists `BNB/BTC`, uses `VolumePairList` with `20` assets, "number_assets": 20, "sort_key": "quoteVolume" }, + { + "method": "DelistFilter", + "max_days_from_now": 0, + }, {"method": "AgeFilter", "min_days_listed": 10}, {"method": "PrecisionFilter"}, {"method": "PriceFilter", "low_price_ratio": 0.01}, diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 807b0888b..6ce87793a 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -49,6 +49,7 @@ AVAILABLE_PAIRLISTS = [ "RemotePairList", "MarketCapPairList", "AgeFilter", + "DelistFilter", "FullTradesFilter", "OffsetFilter", "PerformanceFilter", diff --git a/freqtrade/plugins/pairlist/DelistFilter.py b/freqtrade/plugins/pairlist/DelistFilter.py new file mode 100644 index 000000000..4baa6d674 --- /dev/null +++ b/freqtrade/plugins/pairlist/DelistFilter.py @@ -0,0 +1,93 @@ +""" +Delist pair list filter +""" + +import logging +from datetime import UTC, datetime, timedelta + +from freqtrade.exceptions import OperationalException +from freqtrade.exchange.exchange_types import Ticker +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting + + +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 OperationalException("DelistFilter requires max_days_from_now to be >= 0") + self._enabled = self._max_days_from_now >= 0 + + @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 bbe 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: + if self._max_days_from_now == 0: + self.log_once( + f"Removed {pair} from whitelist, because it will be delisted on {delist_date}.", + logger.info, + ) + return False + else: + current_datetime = datetime.now(UTC) + max_delist_date = current_datetime + timedelta(days=self._max_days_from_now) + if delist_date <= max_delist_date: + self.log_once( + f"Removed {pair} from whitelist, because it will be delisted on {delist_date}.", + logger.info, + ) + return False + + return True From a300df7ff34d8e33c94cdc0214e1efa860f78268 Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 17 Sep 2025 09:22:21 +0900 Subject: [PATCH 10/28] fix precommit --- freqtrade/plugins/pairlist/DelistFilter.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/freqtrade/plugins/pairlist/DelistFilter.py b/freqtrade/plugins/pairlist/DelistFilter.py index 4baa6d674..3fc1f60ef 100644 --- a/freqtrade/plugins/pairlist/DelistFilter.py +++ b/freqtrade/plugins/pairlist/DelistFilter.py @@ -74,20 +74,17 @@ class DelistFilter(IPairList): delist_date = self._exchange.check_delisting_time(pair) if delist_date is not None: - if self._max_days_from_now == 0: + 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 {delist_date}.", logger.info, ) return False - else: - current_datetime = datetime.now(UTC) - max_delist_date = current_datetime + timedelta(days=self._max_days_from_now) - if delist_date <= max_delist_date: - self.log_once( - f"Removed {pair} from whitelist, because it will be delisted on {delist_date}.", - logger.info, - ) - return False return True From 72abae7b943c9dfb347fd4123e1e7c340d61632c Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 17 Sep 2025 11:38:28 +0900 Subject: [PATCH 11/28] disable delisting check on non-trading mode --- freqtrade/exchange/binance.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 9b617bbf4..cee8638c4 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -10,7 +10,7 @@ from cachetools import TTLCache from pandas import DataFrame from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS -from freqtrade.enums import CandleType, MarginMode, PriceType, TradingMode +from freqtrade.enums import TRADE_MODES, CandleType, MarginMode, PriceType, TradingMode from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exchange import Exchange from freqtrade.exchange.binance_public_data import ( @@ -460,6 +460,9 @@ class Binance(Exchange): return delivery_time def check_delisting_time(self, pair: str) -> datetime | None: + if self._config["runmode"] not in TRADE_MODES: + return None + if self.trading_mode == TradingMode.SPOT: return self.get_spot_pair_delist_time(pair, refresh=False) From aee9b2c749ca8262506123a2543cffc86e993389 Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 17 Sep 2025 13:38:09 +0900 Subject: [PATCH 12/28] remove lock --- freqtrade/exchange/binance.py | 41 +++++++++++++++-------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index cee8638c4..1d45d7c79 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -3,7 +3,6 @@ import logging from datetime import UTC, datetime from pathlib import Path -from threading import Lock import ccxt from cachetools import TTLCache @@ -73,7 +72,6 @@ class Binance(Exchange): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._spot_delist_schedule_cache: TTLCache = TTLCache(maxsize=100, ttl=300) - self._spot_delist_schedule_cache_lock = Lock() def get_proxy_coin(self) -> str: """ @@ -493,35 +491,32 @@ class Binance(Exchange): return None cache = self._spot_delist_schedule_cache - lock = self._spot_delist_schedule_cache_lock if not refresh: - with lock: - delist_time = cache.get(pair, None) + delist_time = cache.get(pair, None) - if delist_time: - return delist_time + if delist_time: + return delist_time delist_schedule = self.get_spot_delist_schedule() if delist_schedule is None: return None - with lock: - 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 + 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 + cache[ft_symbol] = delist_dt - return cache.get(pair, None) + return cache.get(pair, None) From 9c0d6127298d7a44898e2b7d008c05b6a21886b0 Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 22 Sep 2025 15:55:36 +0900 Subject: [PATCH 13/28] add example, make sure to raise error on unsupported exchanges and/or trading mode --- docs/includes/pairlists.md | 4 +++- docs/strategy-customization.md | 23 ++++++++++++++++------ freqtrade/exchange/binance.py | 1 + freqtrade/exchange/exchange.py | 1 + freqtrade/exchange/exchange_types.py | 3 +++ freqtrade/plugins/pairlist/DelistFilter.py | 11 +++++++---- 6 files changed, 32 insertions(+), 11 deletions(-) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index f28809d89..40a5c8925 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -409,7 +409,9 @@ This filter allows freqtrade to ignore pairs until they have been listed for at #### 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). +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: + +* Binance (Spot and Futures) !!! Warning "Backtesting" `DelistFilter` does not support backtesting mode. diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index 38e3bc90f..09897bd7b 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -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.** ## 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) file as reference.** @@ -99,9 +100,9 @@ file as reference.** Some common patterns for this are listed in the [Common Mistakes](#common-mistakes-when-developing-strategies) section of this document. ??? Hint "Lookahead and recursive analysis" - Freqtrade includes two helpful commands to help assess common lookahead (using future data) and - recursive bias (variance in indicator values) issues. Before running a strategy in dry or live more, - you should always use these commands first. Please check the relevant documentation for + Freqtrade includes two helpful commands to help assess common lookahead (using future data) and + recursive bias (variance in indicator values) issues. Before running a strategy in dry or live more, + you should always use these commands first. Please check the relevant documentation for [lookahead](lookahead-analysis.md) and [recursive](recursive-analysis.md) analysis. ### Dataframe @@ -154,7 +155,7 @@ Vectorized operations perform calculations across the whole range of data and ar !!! Warning "Trade order assumptions" In backtesting, signals are generated on candle close. Trades are then initiated immeditely on next candle open. - + In dry and live, this may be delayed due to all pair dataframes needing to be analysed first, then trade processing for each of those pairs happens. This means that in dry/live you need to be mindful of having as low a computation delay as possible, usually by running a low number of pairs and having a CPU with a good clock speed. @@ -284,7 +285,7 @@ It's important to always return the dataframe without removing/modifying the col This method will also define a new column, `"enter_long"` (`"enter_short"` for shorts), which needs to contain `1` for entries, and `0` for "no action". `enter_long` is a mandatory column that must be set even if the strategy is shorting only. -You can name your entry signals by using the `"enter_tag"` column, which can help debug and assess your strategy later. +You can name your entry signals by using the `"enter_tag"` column, which can help debug and assess your strategy later. Sample from `user_data/strategies/sample_strategy.py`: @@ -555,7 +556,7 @@ A full sample can be found [in the DataProvider section](#complete-dataprovider- ??? Note "Alternative candle types" Informative_pairs can also provide a 3rd tuple element defining the candle type explicitly. - Availability of alternative candle-types will depend on the trading-mode and the exchange. + Availability of alternative candle-types will depend on the trading-mode and the exchange. In general, spot pairs cannot be used in futures markets, and futures candles can't be used as informative pairs for spot bots. Details about this may vary, if they do, this can be found in the exchange documentation. @@ -775,6 +776,7 @@ Please always check the mode of operation to select the correct method to get da ### Possible options for DataProvider - [`available_pairs`](#available_pairs) - Property with tuples listing cached pairs with their timeframe (pair, timeframe). +- [`check_delisting(pair)`](#check_delisting) - Return Datetime of the pair delisting schedule if any, otherwise return None - [`current_whitelist()`](#current_whitelist) - Returns a current list of whitelisted pairs. Useful for accessing dynamic whitelists (i.e. VolumePairlist) - [`get_pair_dataframe(pair, timeframe)`](#get_pair_dataframepair-timeframe) - This is a universal method, which returns either historical data (for backtesting) or cached live data (for the Dry-Run and Live-Run modes). - [`get_analyzed_dataframe(pair, timeframe)`](#get_analyzed_dataframepair-timeframe) - Returns the analyzed dataframe (after calling `populate_indicators()`, `populate_buy()`, `populate_sell()`) and the time of the latest analysis. @@ -795,6 +797,15 @@ for pair, timeframe in self.dp.available_pairs: print(f"available {pair}, {timeframe}") ``` +### *check_delisting(pair)* + +```python +def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs): + delisting_dt = self.dp.check_delisting(pair) + if delisting_dt is not None: + return "delist" +``` + ### *current_whitelist()* Imagine you've developed a strategy that trades the `5m` timeframe using signals generated from a `1d` timeframe on the top 10 exchange pairs by volume. diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 1d45d7c79..275bd5b3f 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -41,6 +41,7 @@ class Binance(Exchange): "fetch_orders_limit_minutes": None, "l2_limit_range": [5, 10, 20, 50, 100, 500, 1000], "ws_enabled": True, + "has_delisting": True, } _ft_has_futures: FtHas = { "funding_fee_candle_limit": 1000, diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index de414dbbe..5e76170db 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -166,6 +166,7 @@ class Exchange: "proxy_coin_mapping": {}, # Mapping for proxy coins # Expected to be in the format {"fetchOHLCV": True} or {"fetchOHLCV": False} "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_futures: FtHas = {} diff --git a/freqtrade/exchange/exchange_types.py b/freqtrade/exchange/exchange_types.py index cd2bd0059..d2c2b46e2 100644 --- a/freqtrade/exchange/exchange_types.py +++ b/freqtrade/exchange/exchange_types.py @@ -63,6 +63,9 @@ class FtHas(TypedDict, total=False): # Websocket control ws_enabled: bool + # Delisting check + has_delisting: bool + class Ticker(TypedDict): symbol: str diff --git a/freqtrade/plugins/pairlist/DelistFilter.py b/freqtrade/plugins/pairlist/DelistFilter.py index 3fc1f60ef..62b1e0bad 100644 --- a/freqtrade/plugins/pairlist/DelistFilter.py +++ b/freqtrade/plugins/pairlist/DelistFilter.py @@ -5,7 +5,7 @@ Delist pair list filter import logging from datetime import UTC, datetime, timedelta -from freqtrade.exceptions import OperationalException +from freqtrade.exceptions import ConfigurationError from freqtrade.exchange.exchange_types import Ticker from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting @@ -21,8 +21,11 @@ class DelistFilter(IPairList): self._max_days_from_now = self._pairlistconfig.get("max_days_from_now", 0) if self._max_days_from_now < 0: - raise OperationalException("DelistFilter requires max_days_from_now to be >= 0") - self._enabled = 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: @@ -49,7 +52,7 @@ class DelistFilter(IPairList): @staticmethod def description() -> str: - return "Filter pairs that will bbe delisted on exchange." + return "Filter pairs that will be delisted on exchange." @staticmethod def available_parameters() -> dict[str, PairlistParameter]: From dd2d7bb863635a2acc214457e0aebc5d762f216d Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 22 Sep 2025 19:22:22 +0200 Subject: [PATCH 14/28] docs: slightly restructure documentation --- docs/strategy-customization.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index 09897bd7b..6c4b26dfa 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -776,7 +776,6 @@ Please always check the mode of operation to select the correct method to get da ### Possible options for DataProvider - [`available_pairs`](#available_pairs) - Property with tuples listing cached pairs with their timeframe (pair, timeframe). -- [`check_delisting(pair)`](#check_delisting) - Return Datetime of the pair delisting schedule if any, otherwise return None - [`current_whitelist()`](#current_whitelist) - Returns a current list of whitelisted pairs. Useful for accessing dynamic whitelists (i.e. VolumePairlist) - [`get_pair_dataframe(pair, timeframe)`](#get_pair_dataframepair-timeframe) - This is a universal method, which returns either historical data (for backtesting) or cached live data (for the Dry-Run and Live-Run modes). - [`get_analyzed_dataframe(pair, timeframe)`](#get_analyzed_dataframepair-timeframe) - Returns the analyzed dataframe (after calling `populate_indicators()`, `populate_buy()`, `populate_sell()`) and the time of the latest analysis. @@ -785,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. - [`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. +- [`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. - `runmode` - Property containing the current runmode. @@ -797,15 +797,6 @@ for pair, timeframe in self.dp.available_pairs: print(f"available {pair}, {timeframe}") ``` -### *check_delisting(pair)* - -```python -def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs): - delisting_dt = self.dp.check_delisting(pair) - if delisting_dt is not None: - return "delist" -``` - ### *current_whitelist()* Imagine you've developed a strategy that trades the `5m` timeframe using signals generated from a `1d` timeframe on the top 10 exchange pairs by volume. @@ -917,6 +908,22 @@ if self.dp.runmode.value in ('live', 'dry_run'): !!! 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. +### *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. + +!!! 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)* 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`). From 92c9f443e8b4ab620b16ee4d7d90b9901659034f Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 22 Sep 2025 19:30:19 +0200 Subject: [PATCH 15/28] test: add test for dp.check_delisting --- tests/data/test_dataprovider.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/data/test_dataprovider.py b/tests/data/test_dataprovider.py index 27aa2f17d..afbba3d5d 100644 --- a/tests/data/test_dataprovider.py +++ b/tests/data/test_dataprovider.py @@ -8,6 +8,7 @@ from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import CandleType, RunMode from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.plugins.pairlistmanager import PairListManager +from freqtrade.util import dt_utc 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): 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): 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("1h") == 4360 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 From 567d4331fddd94191c68d56e4d9820b0857681cf Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 22 Sep 2025 19:44:03 +0200 Subject: [PATCH 16/28] test: initial delistFilter tests --- tests/plugins/test_pairlist.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index da4a3a780..cccef2c56 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1833,6 +1833,16 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo None, "PriceFilter requires max_value to be >= 0", ), # 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}, "[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate " From 17202c6d29364e9c4badfb83f3cf8282307d3913 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 22 Sep 2025 19:59:12 +0200 Subject: [PATCH 17/28] test: add test for DelistFilter --- freqtrade/plugins/pairlist/DelistFilter.py | 3 +- tests/plugins/test_pairlist.py | 62 +++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/freqtrade/plugins/pairlist/DelistFilter.py b/freqtrade/plugins/pairlist/DelistFilter.py index 62b1e0bad..d208d7a3e 100644 --- a/freqtrade/plugins/pairlist/DelistFilter.py +++ b/freqtrade/plugins/pairlist/DelistFilter.py @@ -8,6 +8,7 @@ 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__) @@ -85,7 +86,7 @@ class DelistFilter(IPairList): if remove_pair: self.log_once( - f"Removed {pair} from whitelist, because it will be delisted on {delist_date}.", + f"Removed {pair} from whitelist, because it will be delisted on {format_date(delist_date)}.", logger.info, ) return False diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index cccef2c56..ba567ff07 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -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.pairlistmanager import PairListManager 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 ( EXMS, create_mock_trades_usdt, @@ -2611,3 +2611,63 @@ def test_backtesting_modes( if expected_warning: 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, + ) From 8b72e4f23e02cc85f1a7559bcd1a800df34428f4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 22 Sep 2025 20:16:46 +0200 Subject: [PATCH 18/28] chore: fix line-lengths --- freqtrade/plugins/pairlist/DelistFilter.py | 3 ++- tests/plugins/test_pairlist.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/freqtrade/plugins/pairlist/DelistFilter.py b/freqtrade/plugins/pairlist/DelistFilter.py index d208d7a3e..d4a3ce90b 100644 --- a/freqtrade/plugins/pairlist/DelistFilter.py +++ b/freqtrade/plugins/pairlist/DelistFilter.py @@ -86,7 +86,8 @@ class DelistFilter(IPairList): if remove_pair: self.log_once( - f"Removed {pair} from whitelist, because it will be delisted on {format_date(delist_date)}.", + f"Removed {pair} from whitelist, because it will be delisted on " + f"{format_date(delist_date)}.", logger.info, ) return False diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index ba567ff07..5e64a5d03 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1840,7 +1840,8 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo ), # ConfigurationError expected ( {"method": "DelistFilter", "max_days_from_now": 1}, - "[{'DelistFilter': 'DelistFilter - Filtering pairs that will be delisted in the next 1 days.'}]", + "[{'DelistFilter': 'DelistFilter - Filtering pairs that will be delisted in the " + "next 1 days.'}]", None, ), # ConfigurationError expected ( From 7aab3b34d62c971231a23bb49b770a86fce448a4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 23 Sep 2025 19:55:11 +0200 Subject: [PATCH 19/28] test: add more tests for delist exchange code --- tests/exchange/test_binance.py | 62 +++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 02c0f202b..6105e549a 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -1,3 +1,4 @@ +from copy import deepcopy from datetime import datetime, timedelta from random import randint from unittest.mock import MagicMock, PropertyMock @@ -7,7 +8,7 @@ import pandas as pd import pytest 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.exchange.exchange_utils_timeframe import timeframe_to_seconds from freqtrade.persistence import Trade @@ -1108,3 +1109,62 @@ async def test__async_get_trade_history_id_binance_fast( # Clean up event loop to avoid warnings exchange.close() + + +def test_check_delisting_time(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(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(default_conf_usdt, mocker): + api_mock = MagicMock() + ccxt_exceptionhandlers( + mocker, + default_conf_usdt, + api_mock, + "binance", + "get_spot_delist_schedule", + "sapi_get_spot_delist_schedule", + retries=1, + ) From 0a0c2adf7d9b674ddca5d47e31c2f63408177c34 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 23 Sep 2025 19:55:18 +0200 Subject: [PATCH 20/28] chore: capture missing exception --- freqtrade/exchange/binance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 275bd5b3f..91f7b22ad 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -473,7 +473,7 @@ class Binance(Exchange): return delist_schedule except ccxt.DDoSProtection as e: raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as 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 From 7b45844670c1c3ae4a6494ab54c542ef1d74bdb4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 23 Sep 2025 19:57:43 +0200 Subject: [PATCH 21/28] test: ensure non-unified ccxt method ain't going away. --- tests/exchange_online/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 65d11f949..6784500da 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -28,7 +28,11 @@ EXCHANGES = { "leverage_tiers_public": False, "leverage_in_spot_market": False, "trades_lookback_hours": 4, - "private_methods": ["fapiPrivateGetPositionSideDual", "fapiPrivateGetMultiAssetsMargin"], + "private_methods": [ + "fapiPrivateGetPositionSideDual", + "fapiPrivateGetMultiAssetsMargin", + "sapi_get_spot_delist_schedule", + ], "sample_order": [ { "exchange_response": { From 06995f26fd6a52f1c4998e7ccd7a0ab740903a16 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 23 Sep 2025 20:16:49 +0200 Subject: [PATCH 22/28] chore: improve reliability of binance spot delisting --- freqtrade/exchange/binance.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 91f7b22ad..694c90014 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -9,7 +9,7 @@ from cachetools import TTLCache from pandas import DataFrame from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS -from freqtrade.enums import TRADE_MODES, CandleType, MarginMode, PriceType, TradingMode +from freqtrade.enums import TRADE_MODES, CandleType, MarginMode, PriceType, RunMode, TradingMode from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exchange import Exchange from freqtrade.exchange.binance_public_data import ( @@ -462,10 +462,9 @@ class Binance(Exchange): if self._config["runmode"] not in TRADE_MODES: return None - if self.trading_mode == TradingMode.SPOT: - return self.get_spot_pair_delist_time(pair, refresh=False) - - return self.check_delisting_futures(pair) + 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): try: @@ -480,7 +479,7 @@ class Binance(Exchange): except ccxt.BaseError as e: raise OperationalException(e) from e - def get_spot_pair_delist_time(self, pair: str, refresh: bool = True) -> datetime | None: + 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 @@ -488,15 +487,14 @@ class Binance(Exchange): :return: int: delisting time None if not delisting """ - if not pair: + 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: - delist_time = cache.get(pair, None) - - if delist_time: + if delist_time := cache.get(pair, None): return delist_time delist_schedule = self.get_spot_delist_schedule() From 5be035270576d35c71cb110814c4b9708c9dce21 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 23 Sep 2025 20:17:30 +0200 Subject: [PATCH 23/28] test: add binance spot-delist test --- tests/exchange/test_binance.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 6105e549a..142246d3f 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -1158,6 +1158,26 @@ def test_check_delisting_futures(default_conf_usdt, mocker, markets): def test_get_spot_delist_schedule(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, From c6061daec493f376bc376878bc4f4f08273dc8f1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 23 Sep 2025 20:17:36 +0200 Subject: [PATCH 24/28] test: rename tests to match scheme --- tests/exchange/test_binance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 142246d3f..295da72bc 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -1111,7 +1111,7 @@ async def test__async_get_trade_history_id_binance_fast( exchange.close() -def test_check_delisting_time(default_conf_usdt, mocker): +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) @@ -1141,7 +1141,7 @@ def test_check_delisting_time(default_conf_usdt, mocker): assert delist_fut_mock.call_count == 1 -def test_check_delisting_futures(default_conf_usdt, mocker, markets): +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 @@ -1157,7 +1157,7 @@ def test_check_delisting_futures(default_conf_usdt, mocker, markets): assert resp_ada == dt_utc(2025, 10, 18) -def test_get_spot_delist_schedule(default_conf_usdt, mocker): +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) From 55b90917e405fb4dddc11a7c55c575cf20335c53 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 26 Sep 2025 06:38:02 +0200 Subject: [PATCH 25/28] docs: Improve documentation clarity --- docs/includes/pairlists.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 40a5c8925..77811e2b4 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -411,7 +411,8 @@ This filter allows freqtrade to ignore pairs until they have been listed for at 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: -* Binance (Spot and Futures) +!!! 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. From f92caf0f1f4311d27895715d93ae71b3e9a11877 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 26 Sep 2025 06:44:10 +0200 Subject: [PATCH 26/28] chore: add response sample to private endpoint --- freqtrade/exchange/binance.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 694c90014..2ea2eec2b 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -467,6 +467,21 @@ class Binance(Exchange): 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 From b9e9f6f0400f95f081b79942913e44236ac45195 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 26 Sep 2025 06:47:36 +0200 Subject: [PATCH 27/28] refactor: indicate private methods by convention --- freqtrade/exchange/binance.py | 18 ++++++++++++------ tests/exchange/test_binance.py | 28 +++++++++++++++------------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 2ea2eec2b..2974adb0a 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -439,7 +439,7 @@ class Binance(Exchange): pair, until=until, since=since, from_id=from_id ) - def check_delisting_futures(self, pair: str) -> datetime | None: + 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 != ""): @@ -459,14 +459,20 @@ class Binance(Exchange): 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) + return self._check_delisting_futures(pair) + return self._get_spot_pair_delist_time(pair, refresh=False) - def get_spot_delist_schedule(self): + def _get_spot_delist_schedule(self): """ Get the delisting schedule for spot pairs Only works in live mode as it requires API keys, @@ -494,7 +500,7 @@ class Binance(Exchange): except ccxt.BaseError as e: raise OperationalException(e) from e - def get_spot_pair_delist_time(self, pair: str, refresh: bool = False) -> datetime | None: + 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 @@ -512,7 +518,7 @@ class Binance(Exchange): if delist_time := cache.get(pair, None): return delist_time - delist_schedule = self.get_spot_delist_schedule() + delist_schedule = self._get_spot_delist_schedule() if delist_schedule is None: return None diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 295da72bc..2c11fc2f3 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -1116,8 +1116,8 @@ def test_check_delisting_time_binance(default_conf_usdt, mocker): 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) + 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") @@ -1141,7 +1141,7 @@ def test_check_delisting_time_binance(default_conf_usdt, mocker): assert delist_fut_mock.call_count == 1 -def test_check_delisting_futures_binance(default_conf_usdt, mocker, markets): +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 @@ -1149,33 +1149,35 @@ def test_check_delisting_futures_binance(default_conf_usdt, mocker, markets): 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") + 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") + 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): +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) + 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 + 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 + 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") + 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 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() @@ -1184,7 +1186,7 @@ def test_get_spot_delist_schedule_binance(default_conf_usdt, mocker): default_conf_usdt, api_mock, "binance", - "get_spot_delist_schedule", + "_get_spot_delist_schedule", "sapi_get_spot_delist_schedule", retries=1, ) From 4434df19998a0082c22a24bcbbbdb56d9696b676 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 26 Sep 2025 06:59:22 +0200 Subject: [PATCH 28/28] docs: improve doc wording --- docs/strategy-customization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index 6c4b26dfa..7eb63e64e 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -919,7 +919,7 @@ def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_r ``` !!! Note "Availabiity of delisting information" - This method is only available for certain exchanges and will return `None` in cases this is not available. + 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.