mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-12-15 20:31:43 +00:00
Merge pull request #12259 from stash86/delist
Implement delisting check on futures market
This commit is contained in:
@@ -587,6 +587,7 @@
|
||||
"RemotePairList",
|
||||
"MarketCapPairList",
|
||||
"AgeFilter",
|
||||
"DelistFilter",
|
||||
"FullTradesFilter",
|
||||
"OffsetFilter",
|
||||
"PerformanceFilter",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -407,6 +407,16 @@ 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). 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).
|
||||
|
||||
!!! 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).
|
||||
@@ -601,7 +611,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 +624,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},
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -783,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.
|
||||
|
||||
@@ -906,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 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.
|
||||
|
||||
### *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`).
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,84 @@ async def test__async_get_trade_history_id_binance_fast(
|
||||
|
||||
# Clean up event loop to avoid warnings
|
||||
exchange.close()
|
||||
|
||||
|
||||
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)
|
||||
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_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
|
||||
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_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)
|
||||
|
||||
# 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,
|
||||
default_conf_usdt,
|
||||
api_mock,
|
||||
"binance",
|
||||
"_get_spot_delist_schedule",
|
||||
"sapi_get_spot_delist_schedule",
|
||||
retries=1,
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
@@ -1833,6 +1833,17 @@ 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 "
|
||||
@@ -2601,3 +2612,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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user