Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/includes/pairlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 36 additions & 3 deletions freqtrade/exchange/bitget.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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__)
Expand All @@ -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]] = [
Expand Down Expand Up @@ -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
36 changes: 35 additions & 1 deletion freqtrade/exchange/bybit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -54,6 +55,7 @@ class Bybit(Exchange):
"exchange_has_overrides": {
"fetchOrder": True,
},
"has_delisting": True,
}

_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
Expand Down Expand Up @@ -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
45 changes: 43 additions & 2 deletions tests/exchange/test_bitget.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
47 changes: 44 additions & 3 deletions tests/exchange/test_bybit.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Loading