diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index c19e779e381..2ee7533f2a7 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -417,7 +417,7 @@ 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: !!! 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). + Delist filter is available on Bybit Futures, Bitget Futures and 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. diff --git a/freqtrade/exchange/bitget.py b/freqtrade/exchange/bitget.py index a1d6db25572..3351cda8857 100644 --- a/freqtrade/exchange/bitget.py +++ b/freqtrade/exchange/bitget.py @@ -1,10 +1,10 @@ import logging -from datetime import timedelta +from datetime import datetime, timedelta import ccxt from freqtrade.constants import BuySell -from freqtrade.enums import CandleType, MarginMode, TradingMode +from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode from freqtrade.exceptions import ( DDosProtection, OperationalException, @@ -14,7 +14,7 @@ from freqtrade.exchange import Exchange from freqtrade.exchange.common import API_RETRY_COUNT, retrier from freqtrade.exchange.exchange_types import CcxtOrder, FtHas -from freqtrade.util.datetime_helpers import dt_now, dt_ts +from freqtrade.util import dt_from_ts, dt_now, dt_ts logger = logging.getLogger(__name__) @@ -37,6 +37,7 @@ class Bitget(Exchange): _ft_has_futures: FtHas = { "mark_ohlcv_timeframe": "4h", "funding_fee_candle_limit": 100, + "has_delisting": True, } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ @@ -236,3 +237,35 @@ def dry_run_liquidation_price( raise OperationalException( "Freqtrade currently only supports isolated futures for bitget" ) + + 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"] in OPTIMIZE_MODES: + return None + + if self.trading_mode == TradingMode.FUTURES: + return self._check_delisting_futures(pair) + return None + + def _check_delisting_futures(self, pair: str) -> datetime | None: + delivery_time = self.markets.get(pair, {}).get("info", {}).get("limitOpenTime", None) + if delivery_time: + if isinstance(delivery_time, str) and (delivery_time != ""): + delivery_time = int(delivery_time) + + if not isinstance(delivery_time, int) or delivery_time <= 0: + return None + + max_delivery = dt_ts() + ( + 14 * 24 * 60 * 60 * 1000 + ) # Assume exchange don't announce delisting more than 14 days in advance + + if delivery_time < max_delivery: + return dt_from_ts(delivery_time) + + return None diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index 7db300acc28..300344e1952 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -4,12 +4,13 @@ import ccxt from freqtrade.constants import BuySell -from freqtrade.enums import MarginMode, PriceType, TradingMode +from freqtrade.enums import OPTIMIZE_MODES, MarginMode, PriceType, TradingMode from freqtrade.exceptions import DDosProtection, ExchangeError, OperationalException, TemporaryError from freqtrade.exchange import Exchange from freqtrade.exchange.common import retrier from freqtrade.exchange.exchange_types import CcxtOrder, FtHas from freqtrade.misc import deep_merge_dicts +from freqtrade.util import dt_from_ts, dt_ts logger = logging.getLogger(__name__) @@ -54,6 +55,7 @@ class Bybit(Exchange): "exchange_has_overrides": { "fetchOrder": True, }, + "has_delisting": True, } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ @@ -294,3 +296,35 @@ def get_leverage_tiers(self) -> dict[str, list[dict]]: self.cache_leverage_tiers(tiers, self._config["stake_currency"]) return tiers + + 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"] in OPTIMIZE_MODES: + return None + + if self.trading_mode == TradingMode.FUTURES: + return self._check_delisting_futures(pair) + return None + + def _check_delisting_futures(self, pair: str) -> datetime | None: + delivery_time = self.markets.get(pair, {}).get("info", {}).get("deliveryTime", 0) + if delivery_time: + if isinstance(delivery_time, str) and (delivery_time != ""): + delivery_time = int(delivery_time) + + if not isinstance(delivery_time, int) or delivery_time <= 0: + return None + + max_delivery = dt_ts() + ( + 14 * 24 * 60 * 60 * 1000 + ) # Assume exchange don't announce delisting more than 14 days in advance + + if delivery_time < max_delivery: + return dt_from_ts(delivery_time) + + return None diff --git a/tests/exchange/test_bitget.py b/tests/exchange/test_bitget.py index ca32ba53699..b4dafb0aed6 100644 --- a/tests/exchange/test_bitget.py +++ b/tests/exchange/test_bitget.py @@ -1,12 +1,13 @@ +from copy import deepcopy from datetime import timedelta from unittest.mock import MagicMock, PropertyMock import pytest -from freqtrade.enums import CandleType, MarginMode, TradingMode +from freqtrade.enums import CandleType, MarginMode, RunMode, TradingMode from freqtrade.exceptions import OperationalException, RetryableOrderError from freqtrade.exchange.common import API_RETRY_COUNT -from freqtrade.util import dt_now, dt_ts +from freqtrade.util import dt_now, dt_ts, dt_utc from tests.conftest import EXMS, get_patched_exchange from tests.exchange.test_exchange import ccxt_exceptionhandlers @@ -193,3 +194,43 @@ def test__lev_prep_bitget(default_conf, mocker): assert api_mock.set_margin_mode.call_count == 0 assert api_mock.set_leverage.call_count == 1 api_mock.set_leverage.assert_called_with(symbol="BTC/USDC:USDC", leverage=19.99) + + +def test_check_delisting_time_bitget(default_conf_usdt, mocker): + exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bitget") + exchange._config["runmode"] = RunMode.BACKTEST + delist_fut_mock = MagicMock(return_value=None) + 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_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_fut_mock.call_count == 0 + + # Delist futures called + exchange.trading_mode = TradingMode.FUTURES + resp1 = exchange.check_delisting_time("BTC/USDT:USDT") + assert resp1 is None + assert delist_fut_mock.call_count == 1 + + +def test__check_delisting_futures_bitget(default_conf_usdt, mocker, markets): + markets["BTC/USDT:USDT"] = deepcopy(markets["SOL/BUSD:BUSD"]) + markets["BTC/USDT:USDT"]["info"]["limitOpenTime"] = "-1" + markets["SOL/BUSD:BUSD"]["info"]["limitOpenTime"] = "-1" + markets["ADA/USDT:USDT"]["info"]["limitOpenTime"] = "1760745600000" # 2025-10-18 + exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bitget") + mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets)) + + resp_sol = exchange._check_delisting_futures("SOL/BUSD:BUSD") + # No delisting date + assert resp_sol is None + # Has a delisting date + resp_ada = exchange._check_delisting_futures("ADA/USDT:USDT") + assert resp_ada == dt_utc(2025, 10, 18) diff --git a/tests/exchange/test_bybit.py b/tests/exchange/test_bybit.py index 511d8bab4d6..fa5f29dfe30 100644 --- a/tests/exchange/test_bybit.py +++ b/tests/exchange/test_bybit.py @@ -1,10 +1,11 @@ +from copy import deepcopy from datetime import UTC, datetime, timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, PropertyMock import pytest -from freqtrade.enums.marginmode import MarginMode -from freqtrade.enums.tradingmode import TradingMode +from freqtrade.enums import MarginMode, RunMode, TradingMode +from freqtrade.util import dt_utc from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has from tests.exchange.test_exchange import ccxt_exceptionhandlers @@ -214,3 +215,43 @@ def test_bybit__order_needs_price( exchange.unified_account = uta assert exchange._order_needs_price(side, order_type) == expected + + +def test_check_delisting_time_bybit(default_conf_usdt, mocker): + exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bybit") + exchange._config["runmode"] = RunMode.BACKTEST + delist_fut_mock = MagicMock(return_value=None) + mocker.patch.object(exchange, "_check_delisting_futures", delist_fut_mock) + + # Invalid run mode + resp = exchange.check_delisting_time("BTC/USDT:USDT") + assert resp is None + 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_fut_mock.call_count == 0 + + # Delist futures called + exchange.trading_mode = TradingMode.FUTURES + resp1 = exchange.check_delisting_time("BTC/USDT:USDT") + assert resp1 is None + assert delist_fut_mock.call_count == 1 + + +def test__check_delisting_futures_bybit(default_conf_usdt, mocker, markets): + markets["BTC/USDT:USDT"] = deepcopy(markets["SOL/BUSD:BUSD"]) + markets["BTC/USDT:USDT"]["info"]["deliveryTime"] = "0" + markets["SOL/BUSD:BUSD"]["info"]["deliveryTime"] = "0" + markets["ADA/USDT:USDT"]["info"]["deliveryTime"] = "1760745600000" # 2025-10-18 + exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bybit") + mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets)) + + resp_sol = exchange._check_delisting_futures("SOL/BUSD:BUSD") + # SOL has no delisting date + 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)