mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-12-14 11:51:19 +00:00
Merge pull request #12259 from stash86/delist
Implement delisting check on futures market
This commit is contained in:
@@ -49,6 +49,7 @@ AVAILABLE_PAIRLISTS = [
|
||||
"RemotePairList",
|
||||
"MarketCapPairList",
|
||||
"AgeFilter",
|
||||
"DelistFilter",
|
||||
"FullTradesFilter",
|
||||
"OffsetFilter",
|
||||
"PerformanceFilter",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,10 +5,11 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import ccxt
|
||||
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, RunMode, TradingMode
|
||||
from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.exchange.binance_public_data import (
|
||||
@@ -40,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,
|
||||
@@ -68,6 +70,10 @@ 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)
|
||||
|
||||
def get_proxy_coin(self) -> str:
|
||||
"""
|
||||
Get the proxy coin for the given coin
|
||||
@@ -432,3 +438,105 @@ 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_time(self, pair: str) -> datetime | None:
|
||||
"""
|
||||
Check if the pair gonna be delisted.
|
||||
By default, it returns None.
|
||||
:param pair: Market symbol
|
||||
:return: Datetime if the pair gonna be delisted, None otherwise
|
||||
"""
|
||||
if self._config["runmode"] not in TRADE_MODES:
|
||||
return None
|
||||
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
return self._check_delisting_futures(pair)
|
||||
return self._get_spot_pair_delist_time(pair, refresh=False)
|
||||
|
||||
def _get_spot_delist_schedule(self):
|
||||
"""
|
||||
Get the delisting schedule for spot pairs
|
||||
Only works in live mode as it requires API keys,
|
||||
Return sample:
|
||||
[{
|
||||
"delistTime": "1759114800000",
|
||||
"symbols": [
|
||||
"OMNIBTC",
|
||||
"OMNIFDUSD",
|
||||
"OMNITRY",
|
||||
"OMNIUSDC",
|
||||
"OMNIUSDT"
|
||||
]
|
||||
}]
|
||||
"""
|
||||
try:
|
||||
delist_schedule = self._api.sapi_get_spot_delist_schedule()
|
||||
return delist_schedule
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
except (ccxt.NetworkError, ccxt.OperationFailed, ccxt.ExchangeError) as e:
|
||||
raise TemporaryError(
|
||||
f"Could not get delist schedule {e.__class__.__name__}. Message: {e}"
|
||||
) from e
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def _get_spot_pair_delist_time(self, pair: str, refresh: bool = False) -> datetime | None:
|
||||
"""
|
||||
Get the delisting time for a pair if it will be delisted
|
||||
:param pair: Pair to get the delisting time for
|
||||
:param refresh: true if you need fresh data
|
||||
:return: int: delisting time None if not delisting
|
||||
"""
|
||||
|
||||
if not pair or not self._config["runmode"] == RunMode.LIVE:
|
||||
# Endpoint only works in live mode as it requires API keys
|
||||
return None
|
||||
|
||||
cache = self._spot_delist_schedule_cache
|
||||
|
||||
if not refresh:
|
||||
if delist_time := cache.get(pair, None):
|
||||
return delist_time
|
||||
|
||||
delist_schedule = self._get_spot_delist_schedule()
|
||||
|
||||
if delist_schedule is None:
|
||||
return None
|
||||
|
||||
for schedule in delist_schedule:
|
||||
delist_dt = dt_from_ts(int(schedule["delistTime"]))
|
||||
for symbol in schedule["symbols"]:
|
||||
ft_symbol = next(
|
||||
(
|
||||
pair
|
||||
for pair, market in self.markets.items()
|
||||
if market.get("id", None) == symbol
|
||||
),
|
||||
None,
|
||||
)
|
||||
if ft_symbol is None:
|
||||
continue
|
||||
|
||||
cache[ft_symbol] = delist_dt
|
||||
|
||||
return cache.get(pair, None)
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -3912,3 +3913,14 @@ 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_delisting_time(self, pair: str) -> datetime | None:
|
||||
"""
|
||||
Check if the pair gonna be delisted.
|
||||
This function should be overridden by the exchange class if the exchange
|
||||
provides such information.
|
||||
By default, it returns None.
|
||||
:param pair: Market symbol
|
||||
:return: Datetime if the pair gonna be delisted, None otherwise
|
||||
"""
|
||||
return None
|
||||
|
||||
@@ -63,6 +63,9 @@ class FtHas(TypedDict, total=False):
|
||||
# Websocket control
|
||||
ws_enabled: bool
|
||||
|
||||
# Delisting check
|
||||
has_delisting: bool
|
||||
|
||||
|
||||
class Ticker(TypedDict):
|
||||
symbol: str
|
||||
|
||||
95
freqtrade/plugins/pairlist/DelistFilter.py
Normal file
95
freqtrade/plugins/pairlist/DelistFilter.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Delist pair list filter
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from freqtrade.exceptions import ConfigurationError
|
||||
from freqtrade.exchange.exchange_types import Ticker
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||
from freqtrade.util import format_date
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DelistFilter(IPairList):
|
||||
supports_backtesting = SupportsBacktesting.NO
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._max_days_from_now = self._pairlistconfig.get("max_days_from_now", 0)
|
||||
if self._max_days_from_now < 0:
|
||||
raise ConfigurationError("DelistFilter requires max_days_from_now to be >= 0")
|
||||
if not self._exchange._ft_has["has_delisting"]:
|
||||
raise ConfigurationError(
|
||||
"DelistFilter doesn't support this exchange and trading mode combination.",
|
||||
)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
Boolean property defining if tickers are necessary.
|
||||
If no Pairlist requires tickers, an empty Dict is passed
|
||||
as tickers argument to filter_pairlist
|
||||
"""
|
||||
return False
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
"""
|
||||
return (
|
||||
f"{self.name} - Filtering pairs that will be delisted"
|
||||
+ (
|
||||
f" in the next {self._max_days_from_now} days"
|
||||
if self._max_days_from_now > 0
|
||||
else ""
|
||||
)
|
||||
+ "."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def description() -> str:
|
||||
return "Filter pairs that will be delisted on exchange."
|
||||
|
||||
@staticmethod
|
||||
def available_parameters() -> dict[str, PairlistParameter]:
|
||||
return {
|
||||
"max_days_from_now": {
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"description": "Max days from now",
|
||||
"help": (
|
||||
"Remove pairs that will be delisted in the next X days. Set to 0 to remove all."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def _validate_pair(self, pair: str, ticker: Ticker | None) -> bool:
|
||||
"""
|
||||
Check if pair will be delisted.
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_ticker
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
delist_date = self._exchange.check_delisting_time(pair)
|
||||
|
||||
if delist_date is not None:
|
||||
remove_pair = self._max_days_from_now == 0
|
||||
if self._max_days_from_now > 0:
|
||||
current_datetime = datetime.now(UTC)
|
||||
max_delist_date = current_datetime + timedelta(days=self._max_days_from_now)
|
||||
remove_pair = delist_date <= max_delist_date
|
||||
|
||||
if remove_pair:
|
||||
self.log_once(
|
||||
f"Removed {pair} from whitelist, because it will be delisted on "
|
||||
f"{format_date(delist_date)}.",
|
||||
logger.info,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
Reference in New Issue
Block a user