mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-12-16 12:51:14 +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",
|
"RemotePairList",
|
||||||
"MarketCapPairList",
|
"MarketCapPairList",
|
||||||
"AgeFilter",
|
"AgeFilter",
|
||||||
|
"DelistFilter",
|
||||||
"FullTradesFilter",
|
"FullTradesFilter",
|
||||||
"OffsetFilter",
|
"OffsetFilter",
|
||||||
"PerformanceFilter",
|
"PerformanceFilter",
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
"trading_mode": "spot",
|
"trading_mode": "spot",
|
||||||
"margin_mode": "",
|
"margin_mode": "",
|
||||||
"minimal_roi": {
|
"minimal_roi": {
|
||||||
"40": 0.0,
|
"40": 0.0,
|
||||||
"30": 0.01,
|
"30": 0.01,
|
||||||
"20": 0.02,
|
"20": 0.02,
|
||||||
"0": 0.04
|
"0": 0.04
|
||||||
},
|
},
|
||||||
"stoploss": -0.10,
|
"stoploss": -0.10,
|
||||||
"unfilledtimeout": {
|
"unfilledtimeout": {
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"bids_to_ask_delta": 1
|
"bids_to_ask_delta": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exit_pricing":{
|
"exit_pricing": {
|
||||||
"price_side": "same",
|
"price_side": "same",
|
||||||
"use_order_book": true,
|
"use_order_book": true,
|
||||||
"order_book_top": 1,
|
"order_book_top": 1,
|
||||||
@@ -70,18 +70,38 @@
|
|||||||
"exit": "GTC"
|
"exit": "GTC"
|
||||||
},
|
},
|
||||||
"pairlists": [
|
"pairlists": [
|
||||||
{"method": "StaticPairList"},
|
{
|
||||||
{"method": "FullTradesFilter"},
|
"method": "StaticPairList"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DelistFilter",
|
||||||
|
"max_days_from_now": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "FullTradesFilter"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "VolumePairList",
|
"method": "VolumePairList",
|
||||||
"number_assets": 20,
|
"number_assets": 20,
|
||||||
"sort_key": "quoteVolume",
|
"sort_key": "quoteVolume",
|
||||||
"refresh_period": 1800
|
"refresh_period": 1800
|
||||||
},
|
},
|
||||||
{"method": "AgeFilter", "min_days_listed": 10},
|
{
|
||||||
{"method": "PrecisionFilter"},
|
"method": "AgeFilter",
|
||||||
{"method": "PriceFilter", "low_price_ratio": 0.01, "min_price": 0.00000010},
|
"min_days_listed": 10
|
||||||
{"method": "SpreadFilter", "max_spread_ratio": 0.005},
|
},
|
||||||
|
{
|
||||||
|
"method": "PrecisionFilter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PriceFilter",
|
||||||
|
"low_price_ratio": 0.01,
|
||||||
|
"min_price": 0.00000010
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "SpreadFilter",
|
||||||
|
"max_spread_ratio": 0.005
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "RangeStabilityFilter",
|
"method": "RangeStabilityFilter",
|
||||||
"lookback_days": 10,
|
"lookback_days": 10,
|
||||||
@@ -166,12 +186,12 @@
|
|||||||
"external_message_consumer": {
|
"external_message_consumer": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"producers": [
|
"producers": [
|
||||||
{
|
{
|
||||||
"name": "default",
|
"name": "default",
|
||||||
"host": "127.0.0.2",
|
"host": "127.0.0.2",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"ws_token": "secret_ws_t0ken."
|
"ws_token": "secret_ws_t0ken."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"wait_timeout": 300,
|
"wait_timeout": 300,
|
||||||
"ping_timeout": 10,
|
"ping_timeout": 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).
|
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.
|
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)
|
* [`RemotePairList`](#remotepairlist)
|
||||||
* [`MarketCapPairList`](#marketcappairlist)
|
* [`MarketCapPairList`](#marketcappairlist)
|
||||||
* [`AgeFilter`](#agefilter)
|
* [`AgeFilter`](#agefilter)
|
||||||
|
* [`DelistFilter`](#delistfilter)
|
||||||
* [`FullTradesFilter`](#fulltradesfilter)
|
* [`FullTradesFilter`](#fulltradesfilter)
|
||||||
* [`OffsetFilter`](#offsetfilter)
|
* [`OffsetFilter`](#offsetfilter)
|
||||||
* [`PerformanceFilter`](#performancefilter)
|
* [`PerformanceFilter`](#performancefilter)
|
||||||
@@ -270,7 +271,6 @@ You can limit the length of the pairlist with the optional parameter `number_ass
|
|||||||
],
|
],
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
!!! Tip "Combining pairlists"
|
!!! 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.
|
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.
|
`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`.
|
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
|
#### 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).
|
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
|
### 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
|
```json
|
||||||
"exchange": {
|
"exchange": {
|
||||||
@@ -614,6 +624,10 @@ The below example blacklists `BNB/BTC`, uses `VolumePairList` with `20` assets,
|
|||||||
"number_assets": 20,
|
"number_assets": 20,
|
||||||
"sort_key": "quoteVolume"
|
"sort_key": "quoteVolume"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "DelistFilter",
|
||||||
|
"max_days_from_now": 0,
|
||||||
|
},
|
||||||
{"method": "AgeFilter", "min_days_listed": 10},
|
{"method": "AgeFilter", "min_days_listed": 10},
|
||||||
{"method": "PrecisionFilter"},
|
{"method": "PrecisionFilter"},
|
||||||
{"method": "PriceFilter", "low_price_ratio": 0.01},
|
{"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.**
|
**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
|
## 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)
|
**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.**
|
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.
|
- `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.
|
- [`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.
|
- [`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.
|
- [`funding_rate(pair)`](#funding_ratepair) - Returns current funding rate data for the pair.
|
||||||
- `runmode` - Property containing the current runmode.
|
- `runmode` - Property containing the current runmode.
|
||||||
|
|
||||||
@@ -906,6 +908,22 @@ if self.dp.runmode.value in ('live', 'dry_run'):
|
|||||||
!!! Warning "Warning about backtesting"
|
!!! 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.
|
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)*
|
### *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`).
|
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",
|
"RemotePairList",
|
||||||
"MarketCapPairList",
|
"MarketCapPairList",
|
||||||
"AgeFilter",
|
"AgeFilter",
|
||||||
|
"DelistFilter",
|
||||||
"FullTradesFilter",
|
"FullTradesFilter",
|
||||||
"OffsetFilter",
|
"OffsetFilter",
|
||||||
"PerformanceFilter",
|
"PerformanceFilter",
|
||||||
|
|||||||
@@ -604,3 +604,19 @@ class DataProvider:
|
|||||||
if always_send or message not in self.__msg_cache:
|
if always_send or message not in self.__msg_cache:
|
||||||
self._msg_queue.append(message)
|
self._msg_queue.append(message)
|
||||||
self.__msg_cache[message] = True
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import ccxt
|
import ccxt
|
||||||
|
from cachetools import TTLCache
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
|
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.exceptions import DDosProtection, OperationalException, TemporaryError
|
||||||
from freqtrade.exchange import Exchange
|
from freqtrade.exchange import Exchange
|
||||||
from freqtrade.exchange.binance_public_data import (
|
from freqtrade.exchange.binance_public_data import (
|
||||||
@@ -40,6 +41,7 @@ class Binance(Exchange):
|
|||||||
"fetch_orders_limit_minutes": None,
|
"fetch_orders_limit_minutes": None,
|
||||||
"l2_limit_range": [5, 10, 20, 50, 100, 500, 1000],
|
"l2_limit_range": [5, 10, 20, 50, 100, 500, 1000],
|
||||||
"ws_enabled": True,
|
"ws_enabled": True,
|
||||||
|
"has_delisting": True,
|
||||||
}
|
}
|
||||||
_ft_has_futures: FtHas = {
|
_ft_has_futures: FtHas = {
|
||||||
"funding_fee_candle_limit": 1000,
|
"funding_fee_candle_limit": 1000,
|
||||||
@@ -68,6 +70,10 @@ class Binance(Exchange):
|
|||||||
(TradingMode.FUTURES, MarginMode.ISOLATED),
|
(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:
|
def get_proxy_coin(self) -> str:
|
||||||
"""
|
"""
|
||||||
Get the proxy coin for the given coin
|
Get the proxy coin for the given coin
|
||||||
@@ -432,3 +438,105 @@ class Binance(Exchange):
|
|||||||
return await super()._async_get_trade_history_id(
|
return await super()._async_get_trade_history_id(
|
||||||
pair, until=until, since=since, from_id=from_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
|
"proxy_coin_mapping": {}, # Mapping for proxy coins
|
||||||
# Expected to be in the format {"fetchOHLCV": True} or {"fetchOHLCV": False}
|
# Expected to be in the format {"fetchOHLCV": True} or {"fetchOHLCV": False}
|
||||||
"ws_enabled": False, # Set to true for exchanges with tested websocket support
|
"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: FtHas = {}
|
||||||
_ft_has_futures: 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
|
# describes the min amt for a tier, and the lowest tier will always go down to 0
|
||||||
else:
|
else:
|
||||||
raise ExchangeError(f"Cannot get maintenance ratio using {self.name}")
|
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
|
# Websocket control
|
||||||
ws_enabled: bool
|
ws_enabled: bool
|
||||||
|
|
||||||
|
# Delisting check
|
||||||
|
has_delisting: bool
|
||||||
|
|
||||||
|
|
||||||
class Ticker(TypedDict):
|
class Ticker(TypedDict):
|
||||||
symbol: str
|
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.enums import CandleType, RunMode
|
||||||
from freqtrade.exceptions import ExchangeError, OperationalException
|
from freqtrade.exceptions import ExchangeError, OperationalException
|
||||||
from freqtrade.plugins.pairlistmanager import PairListManager
|
from freqtrade.plugins.pairlistmanager import PairListManager
|
||||||
|
from freqtrade.util import dt_utc
|
||||||
from tests.conftest import EXMS, generate_test_data, get_patched_exchange
|
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):
|
with pytest.raises(OperationalException, match=message):
|
||||||
dp.available_pairs()
|
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):
|
def test_dp_send_msg(default_conf):
|
||||||
default_conf["runmode"] = RunMode.DRY_RUN
|
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("5m") == 51880
|
||||||
assert dp.get_required_startup("1h") == 4360
|
assert dp.get_required_startup("1h") == 4360
|
||||||
assert dp.get_required_startup("1d") == 220
|
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 datetime import datetime, timedelta
|
||||||
from random import randint
|
from random import randint
|
||||||
from unittest.mock import MagicMock, PropertyMock
|
from unittest.mock import MagicMock, PropertyMock
|
||||||
@@ -7,7 +8,7 @@ import pandas as pd
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from freqtrade.data.converter.trade_converter import trades_dict_to_list
|
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.exceptions import DependencyException, InvalidOrderException, OperationalException
|
||||||
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_seconds
|
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_seconds
|
||||||
from freqtrade.persistence import Trade
|
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
|
# Clean up event loop to avoid warnings
|
||||||
exchange.close()
|
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_tiers_public": False,
|
||||||
"leverage_in_spot_market": False,
|
"leverage_in_spot_market": False,
|
||||||
"trades_lookback_hours": 4,
|
"trades_lookback_hours": 4,
|
||||||
"private_methods": ["fapiPrivateGetPositionSideDual", "fapiPrivateGetMultiAssetsMargin"],
|
"private_methods": [
|
||||||
|
"fapiPrivateGetPositionSideDual",
|
||||||
|
"fapiPrivateGetMultiAssetsMargin",
|
||||||
|
"sapi_get_spot_delist_schedule",
|
||||||
|
],
|
||||||
"sample_order": [
|
"sample_order": [
|
||||||
{
|
{
|
||||||
"exchange_response": {
|
"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.pairlist.pairlist_helpers import dynamic_expand_pairlist, expand_pairlist
|
||||||
from freqtrade.plugins.pairlistmanager import PairListManager
|
from freqtrade.plugins.pairlistmanager import PairListManager
|
||||||
from freqtrade.resolvers import PairListResolver
|
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 (
|
from tests.conftest import (
|
||||||
EXMS,
|
EXMS,
|
||||||
create_mock_trades_usdt,
|
create_mock_trades_usdt,
|
||||||
@@ -1833,6 +1833,17 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo
|
|||||||
None,
|
None,
|
||||||
"PriceFilter requires max_value to be >= 0",
|
"PriceFilter requires max_value to be >= 0",
|
||||||
), # OperationalException expected
|
), # 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},
|
{"method": "RangeStabilityFilter", "lookback_days": 10, "min_rate_of_change": 0.01},
|
||||||
"[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate "
|
"[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate "
|
||||||
@@ -2601,3 +2612,63 @@ def test_backtesting_modes(
|
|||||||
|
|
||||||
if expected_warning:
|
if expected_warning:
|
||||||
assert log_has_re(f"Pairlist Handlers {expected_warning}", caplog)
|
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