Merge pull request #12627 from freqtrade/unify/algo_stop_orders

refactor stoploss methods for exchanges with algo orders
This commit is contained in:
Matthias
2025-12-19 06:27:12 +01:00
committed by GitHub
8 changed files with 46 additions and 74 deletions

View File

@@ -17,7 +17,7 @@ from freqtrade.exchange.binance_public_data import (
download_archive_trades, download_archive_trades,
) )
from freqtrade.exchange.common import retrier from freqtrade.exchange.common import retrier
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas, Tickers from freqtrade.exchange.exchange_types import FtHas, Tickers
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs
from freqtrade.misc import deep_merge_dicts, json_load from freqtrade.misc import deep_merge_dicts, json_load
from freqtrade.util import FtTTLCache from freqtrade.util import FtTTLCache
@@ -51,6 +51,8 @@ class Binance(Exchange):
"funding_fee_candle_limit": 1000, "funding_fee_candle_limit": 1000,
"stoploss_order_types": {"limit": "stop", "market": "stop_market"}, "stoploss_order_types": {"limit": "stop", "market": "stop_market"},
"stoploss_blocks_assets": False, # Stoploss orders do not block assets "stoploss_blocks_assets": False, # Stoploss orders do not block assets
"stoploss_query_requires_stop_flag": True,
"stoploss_algo_order_info_id": "actualOrderId",
"tickers_have_price": False, "tickers_have_price": False,
"floor_leverage": True, "floor_leverage": True,
"fetch_orders_limit_minutes": 7 * 1440, # "fetch_orders" is limited to 7 days "fetch_orders_limit_minutes": 7 * 1440, # "fetch_orders" is limited to 7 days
@@ -145,34 +147,6 @@ class Binance(Exchange):
except ccxt.BaseError as e: except ccxt.BaseError as e:
raise OperationalException(e) from e raise OperationalException(e) from e
def fetch_stoploss_order(
self, order_id: str, pair: str, params: dict | None = None
) -> CcxtOrder:
if self.trading_mode == TradingMode.FUTURES:
params = params or {}
params.update({"stop": True})
order = self.fetch_order(order_id, pair, params)
if self.trading_mode == TradingMode.FUTURES and order.get("status", "open") == "closed":
# Places a real order - which we need to fetch explicitly.
if new_orderid := order.get("info", {}).get("actualOrderId"):
order1 = self.fetch_order(order_id=new_orderid, pair=pair, params={})
order1["id_stop"] = order1["id"]
order1["id"] = order_id
order1["type"] = "stoploss"
order1["stopPrice"] = order.get("stopPrice")
order1["status_stop"] = "triggered"
return order1
return order
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
if self.trading_mode == TradingMode.FUTURES:
params = params or {}
params.update({"stop": True})
return self.cancel_order(order_id=order_id, pair=pair, params=params)
def get_historic_ohlcv( def get_historic_ohlcv(
self, self,
pair: str, pair: str,

View File

@@ -31,6 +31,7 @@ class Bitget(Exchange):
"stop_price_prop": "stopPrice", "stop_price_prop": "stopPrice",
"stoploss_blocks_assets": False, # Stoploss orders do not block assets "stoploss_blocks_assets": False, # Stoploss orders do not block assets
"stoploss_order_types": {"limit": "limit", "market": "market"}, "stoploss_order_types": {"limit": "limit", "market": "market"},
"stoploss_query_requires_stop_flag": True,
"ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones. "ohlcv_candle_limit": 200, # 200 for historical candles, 1000 for recent ones.
"order_time_in_force": ["GTC", "FOK", "IOC", "PO"], "order_time_in_force": ["GTC", "FOK", "IOC", "PO"],
} }
@@ -128,9 +129,6 @@ class Bitget(Exchange):
return self._fetch_stop_order_fallback(order_id, pair) 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})
@retrier @retrier
def additional_exchange_init(self) -> None: def additional_exchange_init(self) -> None:
""" """

View File

@@ -132,6 +132,7 @@ class Exchange:
"stop_price_prop": "stopLossPrice", # Used for stoploss_on_exchange response parsing "stop_price_prop": "stopLossPrice", # Used for stoploss_on_exchange response parsing
"stoploss_order_types": {}, "stoploss_order_types": {},
"stoploss_blocks_assets": True, # By default stoploss orders block assets "stoploss_blocks_assets": True, # By default stoploss orders block assets
"stoploss_query_requires_stop_flag": False, # Require "stop": True" to fetch stop orders
"order_time_in_force": ["GTC"], "order_time_in_force": ["GTC"],
"ohlcv_params": {}, "ohlcv_params": {},
"ohlcv_has_history": True, # Some exchanges (Kraken) don't provide history via ohlcv "ohlcv_has_history": True, # Some exchanges (Kraken) don't provide history via ohlcv
@@ -1687,7 +1688,24 @@ class Exchange:
def fetch_stoploss_order( def fetch_stoploss_order(
self, order_id: str, pair: str, params: dict | None = None self, order_id: str, pair: str, params: dict | None = None
) -> CcxtOrder: ) -> CcxtOrder:
return self.fetch_order(order_id, pair, params) if self.get_option("stoploss_query_requires_stop_flag"):
params = params or {}
params["stop"] = True
order = self.fetch_order(order_id, pair, params)
val = self.get_option("stoploss_algo_order_info_id")
if val and order.get("status", "open") == "closed":
if new_orderid := order.get("info", {}).get(val):
# Fetch real order, which was placed by the algo order.
actual_order = self.fetch_order(order_id=new_orderid, pair=pair, params=None)
actual_order["id_stop"] = actual_order["id"]
actual_order["id"] = order_id
actual_order["type"] = "stoploss"
actual_order["stopPrice"] = order.get("stopPrice")
actual_order["status_stop"] = "triggered"
return actual_order
return order
def fetch_order_or_stoploss_order( def fetch_order_or_stoploss_order(
self, order_id: str, pair: str, stoploss_order: bool = False self, order_id: str, pair: str, stoploss_order: bool = False
@@ -1741,6 +1759,9 @@ class Exchange:
raise OperationalException(e) from e raise OperationalException(e) from e
def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict: def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict:
if self.get_option("stoploss_query_requires_stop_flag"):
params = params or {}
params["stop"] = True
return self.cancel_order(order_id, pair, params) return self.cancel_order(order_id, pair, params)
def is_cancel_order_result_suitable(self, corder) -> TypeGuard[CcxtOrder]: def is_cancel_order_result_suitable(self, corder) -> TypeGuard[CcxtOrder]:

View File

@@ -19,6 +19,8 @@ class FtHas(TypedDict, total=False):
stop_price_type_value_mapping: dict stop_price_type_value_mapping: dict
stoploss_order_types: dict[str, str] stoploss_order_types: dict[str, str]
stoploss_blocks_assets: bool stoploss_blocks_assets: bool
stoploss_query_requires_stop_flag: bool
stoploss_algo_order_info_id: str
# ohlcv # ohlcv
ohlcv_params: dict ohlcv_params: dict
ohlcv_candle_limit: int ohlcv_candle_limit: int

View File

@@ -30,6 +30,8 @@ class Gate(Exchange):
"stoploss_order_types": {"limit": "limit"}, "stoploss_order_types": {"limit": "limit"},
"stop_price_param": "stopPrice", "stop_price_param": "stopPrice",
"stop_price_prop": "stopPrice", "stop_price_prop": "stopPrice",
"stoploss_query_requires_stop_flag": True,
"stoploss_algo_order_info_id": "fired_order_id",
"l2_limit_upper": 1000, "l2_limit_upper": 1000,
"marketOrderRequiresPrice": True, "marketOrderRequiresPrice": True,
"trades_has_history": False, # Endpoint would support this - but ccxt doesn't. "trades_has_history": False, # Endpoint would support this - but ccxt doesn't.
@@ -42,6 +44,7 @@ class Gate(Exchange):
"stop_price_type_field": "price_type", "stop_price_type_field": "price_type",
"l2_limit_upper": 300, "l2_limit_upper": 300,
"stoploss_blocks_assets": False, "stoploss_blocks_assets": False,
"stoploss_algo_order_info_id": "trade_id",
"stop_price_type_value_mapping": { "stop_price_type_value_mapping": {
PriceType.LAST: 0, PriceType.LAST: 0,
PriceType.MARK: 1, PriceType.MARK: 1,
@@ -132,25 +135,3 @@ class Gate(Exchange):
def get_order_id_conditional(self, order: CcxtOrder) -> str: def get_order_id_conditional(self, order: CcxtOrder) -> str:
return safe_value_fallback2(order, order, "id_stop", "id") return safe_value_fallback2(order, order, "id_stop", "id")
def fetch_stoploss_order(
self, order_id: str, pair: str, params: dict | None = None
) -> CcxtOrder:
order = self.fetch_order(order_id=order_id, pair=pair, params={"stop": True})
if order.get("status", "open") == "closed":
# Places a real order - which we need to fetch explicitly.
val = "trade_id" if self.trading_mode == TradingMode.FUTURES else "fired_order_id"
if new_orderid := order.get("info", {}).get(val):
order1 = self.fetch_order(order_id=new_orderid, pair=pair, params=params)
order1["id_stop"] = order1["id"]
order1["id"] = order_id
order1["type"] = "stoploss"
order1["stopPrice"] = order.get("stopPrice")
order1["status_stop"] = "triggered"
return order1
return order
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})

View File

@@ -31,6 +31,7 @@ class Okx(Exchange):
"ohlcv_candle_limit": 100, # Warning, special case with data prior to X months "ohlcv_candle_limit": 100, # Warning, special case with data prior to X months
"stoploss_order_types": {"limit": "limit"}, "stoploss_order_types": {"limit": "limit"},
"stoploss_on_exchange": True, "stoploss_on_exchange": True,
"stoploss_query_requires_stop_flag": True,
"trades_has_history": False, # Endpoint doesn't have a "since" parameter "trades_has_history": False, # Endpoint doesn't have a "since" parameter
"ws_enabled": True, "ws_enabled": True,
} }
@@ -263,9 +264,6 @@ class Okx(Exchange):
return safe_value_fallback2(order, order, "id_stop", "id") return safe_value_fallback2(order, order, "id_stop", "id")
return order["id"] return order["id"]
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})
def _fetch_orders_emulate(self, pair: str, since_ms: int) -> list[CcxtOrder]: def _fetch_orders_emulate(self, pair: str, since_ms: int) -> list[CcxtOrder]:
orders = [] orders = []

View File

@@ -16,9 +16,9 @@ def test_fetch_stoploss_order_gate(default_conf, mocker):
exchange.fetch_stoploss_order("1234", "ETH/BTC") exchange.fetch_stoploss_order("1234", "ETH/BTC")
assert fetch_order_mock.call_count == 1 assert fetch_order_mock.call_count == 1
assert fetch_order_mock.call_args_list[0][1]["order_id"] == "1234" assert fetch_order_mock.call_args_list[0][0][0] == "1234"
assert fetch_order_mock.call_args_list[0][1]["pair"] == "ETH/BTC" assert fetch_order_mock.call_args_list[0][0][1] == "ETH/BTC"
assert fetch_order_mock.call_args_list[0][1]["params"] == {"stop": True} assert fetch_order_mock.call_args_list[0][0][2] == {"stop": True}
default_conf["trading_mode"] = "futures" default_conf["trading_mode"] = "futures"
default_conf["margin_mode"] = "isolated" default_conf["margin_mode"] = "isolated"
@@ -36,21 +36,19 @@ def test_fetch_stoploss_order_gate(default_conf, mocker):
exchange.fetch_stoploss_order("1234", "ETH/BTC") exchange.fetch_stoploss_order("1234", "ETH/BTC")
assert exchange.fetch_order.call_count == 2 assert exchange.fetch_order.call_count == 2
assert exchange.fetch_order.call_args_list[0][1]["order_id"] == "1234" assert exchange.fetch_order.call_args_list[0][0][0] == "1234"
assert exchange.fetch_order.call_args_list[1][1]["order_id"] == "222555" assert exchange.fetch_order.call_args_list[1][1]["order_id"] == "222555"
def test_cancel_stoploss_order_gate(default_conf, mocker): def test_cancel_stoploss_order_gate(default_conf, mocker):
exchange = get_patched_exchange(mocker, default_conf, exchange="gate") exchange = get_patched_exchange(mocker, default_conf, exchange="gate")
cancel_order_mock = mocker.patch.object(exchange, "cancel_order", autospec=True)
cancel_order_mock = MagicMock()
exchange.cancel_order = cancel_order_mock
exchange.cancel_stoploss_order("1234", "ETH/BTC") exchange.cancel_stoploss_order("1234", "ETH/BTC")
assert cancel_order_mock.call_count == 1 assert cancel_order_mock.call_count == 1
assert cancel_order_mock.call_args_list[0][1]["order_id"] == "1234" assert cancel_order_mock.call_args_list[0][0][0] == "1234"
assert cancel_order_mock.call_args_list[0][1]["pair"] == "ETH/BTC" assert cancel_order_mock.call_args_list[0][0][1] == "ETH/BTC"
assert cancel_order_mock.call_args_list[0][1]["params"] == {"stop": True} assert cancel_order_mock.call_args_list[0][0][2] == {"stop": True}
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@@ -661,14 +661,14 @@ def test_stoploss_adjust_okx(mocker, default_conf, sl1, sl2, sl3, side):
def test_stoploss_cancel_okx(mocker, default_conf): def test_stoploss_cancel_okx(mocker, default_conf):
exchange = get_patched_exchange(mocker, default_conf, exchange="okx") exchange = get_patched_exchange(mocker, default_conf, exchange="okx")
co_mock = mocker.patch.object(exchange, "cancel_order", autospec=True)
exchange.cancel_order = MagicMock()
exchange.cancel_stoploss_order("1234", "ETH/USDT") exchange.cancel_stoploss_order("1234", "ETH/USDT")
assert exchange.cancel_order.call_count == 1 assert co_mock.call_count == 1
assert exchange.cancel_order.call_args_list[0][1]["order_id"] == "1234" args, _ = co_mock.call_args
assert exchange.cancel_order.call_args_list[0][1]["pair"] == "ETH/USDT" assert args[0] == "1234"
assert exchange.cancel_order.call_args_list[0][1]["params"] == {"stop": True} assert args[1] == "ETH/USDT"
assert args[2] == {"stop": True}
def test__get_stop_params_okx(mocker, default_conf): def test__get_stop_params_okx(mocker, default_conf):