Skip to content

Commit 0dc665e

Browse files
authored
Merge pull request freqtrade#12098 from freqtrade/feat/bitget_stoploss
add bitget stoploss support
2 parents bc6ea16 + b8883b7 commit 0dc665e

File tree

5 files changed

+209
-6
lines changed

5 files changed

+209
-6
lines changed

docs/exchanges.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,10 @@ Bitget requires a passphrase for each api key, you will therefore need to add th
344344

345345
Bitget supports [time_in_force](configuration.md#understand-order_time_in_force).
346346

347+
!!! Tip "Stoploss on Exchange"
348+
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.
349+
You can use either `"limit"` or `"market"` in the `order_types.stoploss` configuration setting to decide which type of stoploss shall be used.
350+
347351
## Hyperliquid
348352

349353
!!! Tip "Stoploss on Exchange"

docs/stoploss.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The Order-type will be ignored if only one mode is available.
3131
| Binance | limit |
3232
| Binance Futures | market, limit |
3333
| Bingx | market, limit |
34+
| Bitget | market, limit |
3435
| HTX | limit |
3536
| kraken | market, limit |
3637
| Gate | limit |

freqtrade/exchange/bitget.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import logging
22
from datetime import timedelta
33

4+
import ccxt
5+
46
from freqtrade.enums import CandleType
7+
from freqtrade.exceptions import (
8+
DDosProtection,
9+
OperationalException,
10+
RetryableOrderError,
11+
TemporaryError,
12+
)
513
from freqtrade.exchange import Exchange
6-
from freqtrade.exchange.exchange_types import FtHas
14+
from freqtrade.exchange.common import API_RETRY_COUNT, retrier
15+
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas
716
from freqtrade.util.datetime_helpers import dt_now, dt_ts
817

918

@@ -21,6 +30,10 @@ class Bitget(Exchange):
2130
"""
2231

2332
_ft_has: FtHas = {
33+
"stoploss_on_exchange": True,
34+
"stop_price_param": "stopPrice",
35+
"stop_price_prop": "stopPrice",
36+
"stoploss_order_types": {"limit": "limit", "market": "market"},
2437
"ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones.
2538
"order_time_in_force": ["GTC", "FOK", "IOC", "PO"],
2639
}
@@ -44,9 +57,72 @@ def ohlcv_candle_limit(
4457
timeframe_map = self._api.options["fetchOHLCV"]["maxRecentDaysPerTimeframe"]
4558
days = timeframe_map.get(timeframe, 30)
4659

47-
if candle_type in (CandleType.FUTURES, CandleType.SPOT) and (
60+
if candle_type in (CandleType.FUTURES, CandleType.SPOT, CandleType.MARK) and (
4861
not since_ms or dt_ts(dt_now() - timedelta(days=days)) < since_ms
4962
):
5063
return 1000
5164

5265
return super().ohlcv_candle_limit(timeframe, candle_type, since_ms)
66+
67+
def _convert_stop_order(self, pair: str, order_id: str, order: CcxtOrder) -> CcxtOrder:
68+
if order.get("status", "open") == "closed":
69+
# Use orderID as cliendOrderId filter to fetch the regular followup order.
70+
# Could be done with "fetch_order" - but clientOid as filter doesn't seem to work
71+
# https://www.bitget.com/api-doc/spot/trade/Get-Order-Info
72+
73+
for method in (
74+
self._api.fetch_canceled_and_closed_orders,
75+
self._api.fetch_open_orders,
76+
):
77+
orders = method(pair)
78+
orders_f = [order for order in orders if order["clientOrderId"] == order_id]
79+
if orders_f:
80+
order_reg = orders_f[0]
81+
self._log_exchange_response("fetch_stoploss_order1", order_reg)
82+
order_reg["id_stop"] = order_reg["id"]
83+
order_reg["id"] = order_id
84+
order_reg["type"] = "stoploss"
85+
order_reg["status_stop"] = "triggered"
86+
return order_reg
87+
order = self._order_contracts_to_amount(order)
88+
order["type"] = "stoploss"
89+
return order
90+
91+
def _fetch_stop_order_fallback(self, order_id: str, pair: str) -> CcxtOrder:
92+
params2 = {
93+
"stop": True,
94+
}
95+
for method in (
96+
self._api.fetch_open_orders,
97+
self._api.fetch_canceled_and_closed_orders,
98+
):
99+
try:
100+
orders = method(pair, params=params2)
101+
orders_f = [order for order in orders if order["id"] == order_id]
102+
if orders_f:
103+
order = orders_f[0]
104+
self._log_exchange_response("get_stop_order_fallback", order)
105+
return self._convert_stop_order(pair, order_id, order)
106+
except (ccxt.OrderNotFound, ccxt.InvalidOrder):
107+
pass
108+
except ccxt.DDoSProtection as e:
109+
raise DDosProtection(e) from e
110+
except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
111+
raise TemporaryError(
112+
f"Could not get order due to {e.__class__.__name__}. Message: {e}"
113+
) from e
114+
except ccxt.BaseError as e:
115+
raise OperationalException(e) from e
116+
raise RetryableOrderError(f"StoplossOrder not found (pair: {pair} id: {order_id}).")
117+
118+
@retrier(retries=API_RETRY_COUNT)
119+
def fetch_stoploss_order(
120+
self, order_id: str, pair: str, params: dict | None = None
121+
) -> CcxtOrder:
122+
if self._config["dry_run"]:
123+
return self.fetch_dry_run_order(order_id)
124+
125+
return self._fetch_stop_order_fallback(order_id, pair)
126+
127+
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
128+
return self.cancel_order(order_id=order_id, pair=pair, params={"stop": True})

tests/exchange/test_bitget.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from datetime import timedelta
2+
from unittest.mock import MagicMock
3+
4+
import pytest
5+
6+
from freqtrade.enums import CandleType
7+
from freqtrade.exceptions import RetryableOrderError
8+
from freqtrade.exchange.common import API_RETRY_COUNT
9+
from freqtrade.util import dt_now, dt_ts
10+
from tests.conftest import EXMS, get_patched_exchange
11+
from tests.exchange.test_exchange import ccxt_exceptionhandlers
12+
13+
14+
@pytest.mark.usefixtures("init_persistence")
15+
def test_fetch_stoploss_order_bitget(default_conf, mocker):
16+
default_conf["dry_run"] = False
17+
mocker.patch("freqtrade.exchange.common.time.sleep")
18+
api_mock = MagicMock()
19+
20+
exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange="bitget")
21+
22+
api_mock.fetch_open_orders = MagicMock(return_value=[])
23+
api_mock.fetch_canceled_and_closed_orders = MagicMock(return_value=[])
24+
25+
with pytest.raises(RetryableOrderError):
26+
exchange.fetch_stoploss_order("1234", "ETH/BTC")
27+
assert api_mock.fetch_open_orders.call_count == API_RETRY_COUNT + 1
28+
assert api_mock.fetch_canceled_and_closed_orders.call_count == API_RETRY_COUNT + 1
29+
30+
api_mock.fetch_open_orders.reset_mock()
31+
api_mock.fetch_canceled_and_closed_orders.reset_mock()
32+
33+
api_mock.fetch_canceled_and_closed_orders = MagicMock(
34+
return_value=[{"id": "1234", "status": "closed", "clientOrderId": "123455"}]
35+
)
36+
api_mock.fetch_open_orders = MagicMock(return_value=[{"id": "50110", "clientOrderId": "1234"}])
37+
38+
resp = exchange.fetch_stoploss_order("1234", "ETH/BTC")
39+
assert api_mock.fetch_open_orders.call_count == 2
40+
assert api_mock.fetch_canceled_and_closed_orders.call_count == 2
41+
42+
assert resp["id"] == "1234"
43+
assert resp["id_stop"] == "50110"
44+
assert resp["type"] == "stoploss"
45+
46+
default_conf["dry_run"] = True
47+
exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange="bitget")
48+
dro_mock = mocker.patch(f"{EXMS}.fetch_dry_run_order", MagicMock(return_value={"id": "123455"}))
49+
50+
api_mock.fetch_open_orders.reset_mock()
51+
api_mock.fetch_canceled_and_closed_orders.reset_mock()
52+
resp = exchange.fetch_stoploss_order("1234", "ETH/BTC")
53+
54+
assert api_mock.fetch_open_orders.call_count == 0
55+
assert api_mock.fetch_canceled_and_closed_orders.call_count == 0
56+
assert dro_mock.call_count == 1
57+
58+
59+
def test_fetch_stoploss_order_bitget_exceptions(default_conf_usdt, mocker):
60+
default_conf_usdt["dry_run"] = False
61+
api_mock = MagicMock()
62+
63+
# Test emulation of the stoploss getters
64+
api_mock.fetch_canceled_and_closed_orders = MagicMock(return_value=[])
65+
66+
ccxt_exceptionhandlers(
67+
mocker,
68+
default_conf_usdt,
69+
api_mock,
70+
"bitget",
71+
"fetch_stoploss_order",
72+
"fetch_open_orders",
73+
retries=API_RETRY_COUNT + 1,
74+
order_id="12345",
75+
pair="ETH/USDT",
76+
)
77+
78+
79+
def test_bitget_ohlcv_candle_limit(mocker, default_conf_usdt):
80+
# This test is also a live test - so we're sure our limits are correct.
81+
api_mock = MagicMock()
82+
api_mock.options = {
83+
"fetchOHLCV": {
84+
"maxRecentDaysPerTimeframe": {
85+
"1m": 30,
86+
"5m": 30,
87+
"15m": 30,
88+
"30m": 30,
89+
"1h": 60,
90+
"4h": 60,
91+
"1d": 60,
92+
}
93+
}
94+
}
95+
96+
exch = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange="bitget")
97+
timeframes = ("1m", "5m", "1h")
98+
99+
for timeframe in timeframes:
100+
assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT) == 1000
101+
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES) == 1000
102+
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK) == 1000
103+
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE) == 200
104+
105+
start_time = dt_ts(dt_now() - timedelta(days=17))
106+
assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == 1000
107+
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == 1000
108+
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 1000
109+
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200
110+
start_time = dt_ts(dt_now() - timedelta(days=48))
111+
length = 200 if timeframe in ("1m", "5m") else 1000
112+
assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == length
113+
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == length
114+
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == length
115+
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200
116+
117+
start_time = dt_ts(dt_now() - timedelta(days=61))
118+
length = 200
119+
assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == length
120+
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == length
121+
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == length
122+
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200

tests/exchange_online/test_ccxt_compat.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -541,24 +541,24 @@ def test_ccxt_bitget_ohlcv_candle_limit(self, exchange: EXCHANGE_FIXTURE_TYPE):
541541
for timeframe in timeframes:
542542
assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT) == 1000
543543
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES) == 1000
544-
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK) == 200
544+
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK) == 1000
545545
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE) == 200
546546

547547
start_time = dt_ts(dt_now() - timedelta(days=17))
548548
assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == 1000
549549
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == 1000
550-
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 200
550+
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 1000
551551
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200
552552
start_time = dt_ts(dt_now() - timedelta(days=48))
553553
length = 200 if timeframe in ("1m", "5m") else 1000
554554
assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == length
555555
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == length
556-
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 200
556+
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == length
557557
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200
558558

559559
start_time = dt_ts(dt_now() - timedelta(days=61))
560560
length = 200
561561
assert exch.ohlcv_candle_limit(timeframe, CandleType.SPOT, start_time) == length
562562
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUTURES, start_time) == length
563-
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == 200
563+
assert exch.ohlcv_candle_limit(timeframe, CandleType.MARK, start_time) == length
564564
assert exch.ohlcv_candle_limit(timeframe, CandleType.FUNDING_RATE, start_time) == 200

0 commit comments

Comments
 (0)