diff --git a/docs/exchanges.md b/docs/exchanges.md index 1f3b80fd1..d8008506c 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -344,6 +344,10 @@ Bitget requires a passphrase for each api key, you will therefore need to add th Bitget supports [time_in_force](configuration.md#understand-order_time_in_force). +!!! Tip "Stoploss on Exchange" + Bitget supports `stoploss_on_exchange` and can use both stop-loss-market and stop-loss-limit orders. It provides great advantages, so we recommend to benefit from it. + You can use either `"limit"` or `"market"` in the `order_types.stoploss` configuration setting to decide which type of stoploss shall be used. + ## Hyperliquid !!! Tip "Stoploss on Exchange" diff --git a/docs/stoploss.md b/docs/stoploss.md index bb7a2003f..f1a1e487d 100644 --- a/docs/stoploss.md +++ b/docs/stoploss.md @@ -31,6 +31,7 @@ The Order-type will be ignored if only one mode is available. | Binance | limit | | Binance Futures | market, limit | | Bingx | market, limit | +| Bitget | market, limit | | HTX | limit | | kraken | market, limit | | Gate | limit | diff --git a/freqtrade/exchange/bitget.py b/freqtrade/exchange/bitget.py index 89a549ad1..b6b347c34 100644 --- a/freqtrade/exchange/bitget.py +++ b/freqtrade/exchange/bitget.py @@ -1,9 +1,18 @@ import logging from datetime import timedelta +import ccxt + from freqtrade.enums import CandleType +from freqtrade.exceptions import ( + DDosProtection, + OperationalException, + RetryableOrderError, + TemporaryError, +) from freqtrade.exchange import Exchange -from freqtrade.exchange.exchange_types import FtHas +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 @@ -21,6 +30,10 @@ class Bitget(Exchange): """ _ft_has: FtHas = { + "stoploss_on_exchange": True, + "stop_price_param": "stopPrice", + "stop_price_prop": "stopPrice", + "stoploss_order_types": {"limit": "limit", "market": "market"}, "ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones. "order_time_in_force": ["GTC", "FOK", "IOC", "PO"], } @@ -44,9 +57,72 @@ class Bitget(Exchange): timeframe_map = self._api.options["fetchOHLCV"]["maxRecentDaysPerTimeframe"] days = timeframe_map.get(timeframe, 30) - if candle_type in (CandleType.FUTURES, CandleType.SPOT) and ( + if candle_type in (CandleType.FUTURES, CandleType.SPOT, CandleType.MARK) and ( not since_ms or dt_ts(dt_now() - timedelta(days=days)) < since_ms ): return 1000 return super().ohlcv_candle_limit(timeframe, candle_type, since_ms) + + def _convert_stop_order(self, pair: str, order_id: str, order: CcxtOrder) -> CcxtOrder: + if order.get("status", "open") == "closed": + # Use orderID as cliendOrderId filter to fetch the regular followup order. + # Could be done with "fetch_order" - but clientOid as filter doesn't seem to work + # https://www.bitget.com/api-doc/spot/trade/Get-Order-Info + + for method in ( + self._api.fetch_canceled_and_closed_orders, + self._api.fetch_open_orders, + ): + orders = method(pair) + orders_f = [order for order in orders if order["clientOrderId"] == order_id] + if orders_f: + order_reg = orders_f[0] + self._log_exchange_response("fetch_stoploss_order1", order_reg) + order_reg["id_stop"] = order_reg["id"] + order_reg["id"] = order_id + order_reg["type"] = "stoploss" + order_reg["status_stop"] = "triggered" + return order_reg + order = self._order_contracts_to_amount(order) + order["type"] = "stoploss" + return order + + def _fetch_stop_order_fallback(self, order_id: str, pair: str) -> CcxtOrder: + params2 = { + "stop": True, + } + for method in ( + self._api.fetch_open_orders, + self._api.fetch_canceled_and_closed_orders, + ): + try: + orders = method(pair, params=params2) + orders_f = [order for order in orders if order["id"] == order_id] + if orders_f: + order = orders_f[0] + self._log_exchange_response("get_stop_order_fallback", order) + return self._convert_stop_order(pair, order_id, order) + except (ccxt.OrderNotFound, ccxt.InvalidOrder): + pass + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.OperationFailed, ccxt.ExchangeError) as e: + raise TemporaryError( + f"Could not get order due to {e.__class__.__name__}. Message: {e}" + ) from e + except ccxt.BaseError as e: + raise OperationalException(e) from e + raise RetryableOrderError(f"StoplossOrder not found (pair: {pair} id: {order_id}).") + + @retrier(retries=API_RETRY_COUNT) + def fetch_stoploss_order( + self, order_id: str, pair: str, params: dict | None = None + ) -> CcxtOrder: + if self._config["dry_run"]: + return self.fetch_dry_run_order(order_id) + + return self._fetch_stop_order_fallback(order_id, pair) + + def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict: + return self.cancel_order(order_id=order_id, pair=pair, params={"stop": True}) diff --git a/tests/exchange/test_bitget.py b/tests/exchange/test_bitget.py new file mode 100644 index 000000000..fdfaf7a7e --- /dev/null +++ b/tests/exchange/test_bitget.py @@ -0,0 +1,122 @@ +from datetime import timedelta +from unittest.mock import MagicMock + +import pytest + +from freqtrade.enums import CandleType +from freqtrade.exceptions import RetryableOrderError +from freqtrade.exchange.common import API_RETRY_COUNT +from freqtrade.util import dt_now, dt_ts +from tests.conftest import EXMS, get_patched_exchange +from tests.exchange.test_exchange import ccxt_exceptionhandlers + + +@pytest.mark.usefixtures("init_persistence") +def test_fetch_stoploss_order_bitget(default_conf, mocker): + default_conf["dry_run"] = False + mocker.patch("freqtrade.exchange.common.time.sleep") + api_mock = MagicMock() + + exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange="bitget") + + api_mock.fetch_open_orders = MagicMock(return_value=[]) + api_mock.fetch_canceled_and_closed_orders = MagicMock(return_value=[]) + + with pytest.raises(RetryableOrderError): + exchange.fetch_stoploss_order("1234", "ETH/BTC") + assert api_mock.fetch_open_orders.call_count == API_RETRY_COUNT + 1 + assert api_mock.fetch_canceled_and_closed_orders.call_count == API_RETRY_COUNT + 1 + + api_mock.fetch_open_orders.reset_mock() + api_mock.fetch_canceled_and_closed_orders.reset_mock() + + api_mock.fetch_canceled_and_closed_orders = MagicMock( + return_value=[{"id": "1234", "status": "closed", "clientOrderId": "123455"}] + ) + api_mock.fetch_open_orders = MagicMock(return_value=[{"id": "50110", "clientOrderId": "1234"}]) + + resp = exchange.fetch_stoploss_order("1234", "ETH/BTC") + assert api_mock.fetch_open_orders.call_count == 2 + assert api_mock.fetch_canceled_and_closed_orders.call_count == 2 + + assert resp["id"] == "1234" + assert resp["id_stop"] == "50110" + assert resp["type"] == "stoploss" + + default_conf["dry_run"] = True + exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange="bitget") + dro_mock = mocker.patch(f"{EXMS}.fetch_dry_run_order", MagicMock(return_value={"id": "123455"})) + + api_mock.fetch_open_orders.reset_mock() + api_mock.fetch_canceled_and_closed_orders.reset_mock() + resp = exchange.fetch_stoploss_order("1234", "ETH/BTC") + + assert api_mock.fetch_open_orders.call_count == 0 + assert api_mock.fetch_canceled_and_closed_orders.call_count == 0 + assert dro_mock.call_count == 1 + + +def test_fetch_stoploss_order_bitget_exceptions(default_conf_usdt, mocker): + default_conf_usdt["dry_run"] = False + api_mock = MagicMock() + + # Test emulation of the stoploss getters + api_mock.fetch_canceled_and_closed_orders = MagicMock(return_value=[]) + + ccxt_exceptionhandlers( + mocker, + default_conf_usdt, + api_mock, + "bitget", + "fetch_stoploss_order", + "fetch_open_orders", + retries=API_RETRY_COUNT + 1, + order_id="12345", + pair="ETH/USDT", + ) + + +def test_bitget_ohlcv_candle_limit(mocker, default_conf_usdt): + # This test is also a live test - so we're sure our limits are correct. + api_mock = MagicMock() + api_mock.options = { + "fetchOHLCV": { + "maxRecentDaysPerTimeframe": { + "1m": 30, + "5m": 30, + "15m": 30, + "30m": 30, + "1h": 60, + "4h": 60, + "1d": 60, + } + } + } + + exch = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange="bitget") + timeframes = ("1m", "5m", "1h") + + for timeframe in timeframes: + assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT) == 1000 + assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES) == 1000 + assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK) == 1000 + assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE) == 200 + + start_time = dt_ts(dt_now() - timedelta(days=17)) + assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == 1000 + assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == 1000 + assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 1000 + assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200 + start_time = dt_ts(dt_now() - timedelta(days=48)) + length = 200 if timeframe in ("1m", "5m") else 1000 + assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == length + assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == length + assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == length + assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200 + + start_time = dt_ts(dt_now() - timedelta(days=61)) + length = 200 + assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == length + assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == length + assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == length + assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200 diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index 2e8028ab7..c71e4809b 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -541,24 +541,24 @@ class TestCCXTExchange: for timeframe in timeframes: assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT) == 1000 assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES) == 1000 - assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK) == 200 + assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK) == 1000 assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE) == 200 start_time = dt_ts(dt_now() - timedelta(days=17)) assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == 1000 assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == 1000 - assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 200 + assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 1000 assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200 start_time = dt_ts(dt_now() - timedelta(days=48)) length = 200 if timeframe in ("1m", "5m") else 1000 assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == length assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == length - assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 200 + assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == length assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200 start_time = dt_ts(dt_now() - timedelta(days=61)) length = 200 assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == length assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == length - assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 200 + assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == length assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200