mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-11-29 08:33:07 +00:00
Merge branch 'freqtrade:develop' into add-liq-price
This commit is contained in:
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import ccxt
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import CandleType, MarginMode, TradingMode
|
||||
from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode
|
||||
from freqtrade.exceptions import (
|
||||
DDosProtection,
|
||||
OperationalException,
|
||||
@@ -14,7 +14,7 @@ from freqtrade.exceptions import (
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.exchange.common import API_RETRY_COUNT, retrier
|
||||
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas
|
||||
from freqtrade.util.datetime_helpers import dt_now, dt_ts
|
||||
from freqtrade.util import dt_from_ts, dt_now, dt_ts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -37,6 +37,7 @@ class Bitget(Exchange):
|
||||
_ft_has_futures: FtHas = {
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
"funding_fee_candle_limit": 100,
|
||||
"has_delisting": True,
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
@@ -236,3 +237,35 @@ class Bitget(Exchange):
|
||||
raise OperationalException(
|
||||
"Freqtrade currently only supports isolated futures for bitget"
|
||||
)
|
||||
|
||||
def check_delisting_time(self, pair: str) -> datetime | None:
|
||||
"""
|
||||
Check if the pair gonna be delisted.
|
||||
By default, it returns None.
|
||||
:param pair: Market symbol
|
||||
:return: Datetime if the pair gonna be delisted, None otherwise
|
||||
"""
|
||||
if self._config["runmode"] in OPTIMIZE_MODES:
|
||||
return None
|
||||
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
return self._check_delisting_futures(pair)
|
||||
return None
|
||||
|
||||
def _check_delisting_futures(self, pair: str) -> datetime | None:
|
||||
delivery_time = self.markets.get(pair, {}).get("info", {}).get("limitOpenTime", None)
|
||||
if delivery_time:
|
||||
if isinstance(delivery_time, str) and (delivery_time != ""):
|
||||
delivery_time = int(delivery_time)
|
||||
|
||||
if not isinstance(delivery_time, int) or delivery_time <= 0:
|
||||
return None
|
||||
|
||||
max_delivery = dt_ts() + (
|
||||
14 * 24 * 60 * 60 * 1000
|
||||
) # Assume exchange don't announce delisting more than 14 days in advance
|
||||
|
||||
if delivery_time < max_delivery:
|
||||
return dt_from_ts(delivery_time)
|
||||
|
||||
return None
|
||||
|
||||
@@ -4,12 +4,13 @@ from datetime import datetime, timedelta
|
||||
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__)
|
||||
@@ -54,6 +55,7 @@ class Bybit(Exchange):
|
||||
"exchange_has_overrides": {
|
||||
"fetchOrder": True,
|
||||
},
|
||||
"has_delisting": True,
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||
@@ -294,3 +296,35 @@ class Bybit(Exchange):
|
||||
|
||||
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
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
import pytest
|
||||
|
||||
from freqtrade.enums import CandleType, MarginMode, TradingMode
|
||||
from freqtrade.enums import CandleType, MarginMode, RunMode, TradingMode
|
||||
from freqtrade.exceptions import OperationalException, RetryableOrderError
|
||||
from freqtrade.exchange.common import API_RETRY_COUNT
|
||||
from freqtrade.util import dt_now, dt_ts
|
||||
from freqtrade.util import dt_now, dt_ts, dt_utc
|
||||
from tests.conftest import EXMS, get_patched_exchange
|
||||
from tests.exchange.test_exchange import ccxt_exceptionhandlers
|
||||
|
||||
@@ -193,3 +194,43 @@ def test__lev_prep_bitget(default_conf, mocker):
|
||||
assert api_mock.set_margin_mode.call_count == 0
|
||||
assert api_mock.set_leverage.call_count == 1
|
||||
api_mock.set_leverage.assert_called_with(symbol="BTC/USDC:USDC", leverage=19.99)
|
||||
|
||||
|
||||
def test_check_delisting_time_bitget(default_conf_usdt, mocker):
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bitget")
|
||||
exchange._config["runmode"] = RunMode.BACKTEST
|
||||
delist_fut_mock = MagicMock(return_value=None)
|
||||
mocker.patch.object(exchange, "_check_delisting_futures", delist_fut_mock)
|
||||
|
||||
# Invalid run mode
|
||||
resp = exchange.check_delisting_time("BTC/USDT")
|
||||
assert resp is None
|
||||
assert delist_fut_mock.call_count == 0
|
||||
|
||||
# Delist spot called
|
||||
exchange._config["runmode"] = RunMode.DRY_RUN
|
||||
resp1 = exchange.check_delisting_time("BTC/USDT")
|
||||
assert resp1 is None
|
||||
assert delist_fut_mock.call_count == 0
|
||||
|
||||
# Delist futures called
|
||||
exchange.trading_mode = TradingMode.FUTURES
|
||||
resp1 = exchange.check_delisting_time("BTC/USDT:USDT")
|
||||
assert resp1 is None
|
||||
assert delist_fut_mock.call_count == 1
|
||||
|
||||
|
||||
def test__check_delisting_futures_bitget(default_conf_usdt, mocker, markets):
|
||||
markets["BTC/USDT:USDT"] = deepcopy(markets["SOL/BUSD:BUSD"])
|
||||
markets["BTC/USDT:USDT"]["info"]["limitOpenTime"] = "-1"
|
||||
markets["SOL/BUSD:BUSD"]["info"]["limitOpenTime"] = "-1"
|
||||
markets["ADA/USDT:USDT"]["info"]["limitOpenTime"] = "1760745600000" # 2025-10-18
|
||||
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bitget")
|
||||
mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets))
|
||||
|
||||
resp_sol = exchange._check_delisting_futures("SOL/BUSD:BUSD")
|
||||
# No delisting date
|
||||
assert resp_sol is None
|
||||
# Has a delisting date
|
||||
resp_ada = exchange._check_delisting_futures("ADA/USDT:USDT")
|
||||
assert resp_ada == dt_utc(2025, 10, 18)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1110,6 +1110,116 @@ def test_create_dry_run_order_fees(
|
||||
assert order1["fee"]["rate"] == fee
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side,limit,offset,expected",
|
||||
[
|
||||
("buy", 46.0, 0.0, True),
|
||||
("buy", 26.0, 0.0, True),
|
||||
("buy", 25.55, 0.0, False),
|
||||
("buy", 1, 0.0, False), # Very far away
|
||||
("sell", 25.5, 0.0, True),
|
||||
("sell", 50, 0.0, False), # Very far away
|
||||
("sell", 25.58, 0.0, False),
|
||||
("sell", 25.563, 0.01, False),
|
||||
("sell", 5.563, 0.01, True),
|
||||
],
|
||||
)
|
||||
def test__dry_is_price_crossed_with_orderbook(
|
||||
default_conf, mocker, order_book_l2_usd, side, limit, offset, expected
|
||||
):
|
||||
# Best bid 25.563
|
||||
# Best ask 25.566
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
|
||||
exchange.fetch_l2_order_book = order_book_l2_usd
|
||||
orderbook = order_book_l2_usd.return_value
|
||||
result = exchange._dry_is_price_crossed(
|
||||
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset
|
||||
)
|
||||
assert result is expected
|
||||
assert order_book_l2_usd.call_count == 0
|
||||
|
||||
# Test without passing orderbook
|
||||
order_book_l2_usd.reset_mock()
|
||||
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset)
|
||||
assert result is expected
|
||||
|
||||
|
||||
def test__dry_is_price_crossed_empty_orderbook(default_conf, mocker):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
|
||||
empty_book = {"asks": [], "bids": []}
|
||||
assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 100.0, orderbook=empty_book)
|
||||
|
||||
|
||||
def test__dry_is_price_crossed_fetches_orderbook(default_conf, mocker, order_book_l2_usd):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
|
||||
exchange.fetch_l2_order_book = order_book_l2_usd
|
||||
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 26.0)
|
||||
assert order_book_l2_usd.call_count == 1
|
||||
|
||||
|
||||
def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
exchange.fetch_l2_order_book = MagicMock()
|
||||
mocker.patch(f"{EXMS}.exchange_has", return_value=False)
|
||||
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0)
|
||||
assert exchange.fetch_l2_order_book.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"crossed,immediate,side,amount,expected_status,expected_fee_rate,expected_calls,taker_or_maker",
|
||||
[
|
||||
(True, True, "buy", 2.0, "closed", 0.005, 1, "taker"),
|
||||
(True, False, "sell", 1.5, "closed", 0.005, 1, "maker"),
|
||||
(False, False, "sell", 1.0, "open", None, 0, None),
|
||||
],
|
||||
)
|
||||
def test_check_dry_limit_order_filled_parametrized(
|
||||
default_conf,
|
||||
mocker,
|
||||
crossed,
|
||||
immediate,
|
||||
side,
|
||||
amount,
|
||||
expected_status,
|
||||
expected_fee_rate,
|
||||
expected_calls,
|
||||
taker_or_maker,
|
||||
):
|
||||
exchange = get_patched_exchange(mocker, default_conf)
|
||||
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=crossed)
|
||||
fee_mock = mocker.patch(f"{EXMS}.get_fee", return_value=0.005)
|
||||
|
||||
order = {
|
||||
"symbol": "LTC/USDT",
|
||||
"status": "open",
|
||||
"type": "limit",
|
||||
"side": side,
|
||||
"price": 25.0,
|
||||
"amount": amount,
|
||||
"filled": 0.0,
|
||||
"remaining": amount,
|
||||
"cost": 25.0 * amount,
|
||||
"fee": None,
|
||||
}
|
||||
|
||||
result = exchange.check_dry_limit_order_filled(order, immediate=immediate)
|
||||
|
||||
assert result["status"] == expected_status
|
||||
if crossed:
|
||||
assert result["filled"] == amount
|
||||
assert result["remaining"] == 0.0
|
||||
assert result["fee"]["rate"] == expected_fee_rate
|
||||
fee_mock.assert_called_once_with("LTC/USDT", taker_or_maker=taker_or_maker)
|
||||
else:
|
||||
assert result["filled"] == 0.0
|
||||
assert result["remaining"] == amount
|
||||
assert result["fee"] is None
|
||||
assert fee_mock.call_count == expected_calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side,price,filled,converted",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user