diff --git a/freqtrade/exchange/bitget.py b/freqtrade/exchange/bitget.py index a1d6db255..3351cda88 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.exceptions import ( 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 @@ class Bitget(Exchange): 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/tests/exchange/test_bitget.py b/tests/exchange/test_bitget.py index ca32ba536..b4dafb0ae 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)