From a38ffdbc58007e44e9ce1106e24113d6b7294912 Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Sun, 8 Dec 2024 21:03:21 +0100 Subject: [PATCH 1/2] [Exchanges] support spot stop losses --- Trading/Exchange/binance/binance_exchange.py | 22 +++--- Trading/Exchange/bingx/bingx_exchange.py | 71 +++++++++++++++++++ .../Exchange/coinbase/coinbase_exchange.py | 65 +++++++++++++++++ Trading/Exchange/kucoin/kucoin_exchange.py | 16 ++--- 4 files changed, 154 insertions(+), 20 deletions(-) diff --git a/Trading/Exchange/binance/binance_exchange.py b/Trading/Exchange/binance/binance_exchange.py index bbca73529..60cd8affb 100644 --- a/Trading/Exchange/binance/binance_exchange.py +++ b/Trading/Exchange/binance/binance_exchange.py @@ -59,7 +59,7 @@ class Binance(exchanges.RestExchange): trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ - trading_enums.TraderOrderType.STOP_LOSS, + # trading_enums.TraderOrderType.STOP_LOSS, # supported on spot trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, @@ -209,17 +209,15 @@ async def set_symbol_partial_take_profit_stop_loss(self, symbol: str, inverse: b """ async def _create_market_stop_loss_order(self, symbol, quantity, price, side, current_price, params=None) -> dict: - if self.exchange_manager.is_future: - params = params or {} - params["stopLossPrice"] = price # make ccxt understand that it's a stop loss - order = self.connector.adapter.adapt_order( - await self.connector.client.create_order( - symbol, trading_enums.TradeOrderType.MARKET.value, side, quantity, params=params - ), - symbol=symbol, quantity=quantity - ) - return order - return await super()._create_market_stop_loss_order(symbol, quantity, price, side, current_price, params=params) + params = params or {} + params["stopLossPrice"] = price # make ccxt understand that it's a stop loss + order = self.connector.adapter.adapt_order( + await self.connector.client.create_order( + symbol, trading_enums.TradeOrderType.MARKET.value, side, quantity, params=params + ), + symbol=symbol, quantity=quantity + ) + return order async def get_positions(self, symbols=None, **kwargs: dict) -> list: positions = [] diff --git a/Trading/Exchange/bingx/bingx_exchange.py b/Trading/Exchange/bingx/bingx_exchange.py index 7fe51363e..b755bd92f 100644 --- a/Trading/Exchange/bingx/bingx_exchange.py +++ b/Trading/Exchange/bingx/bingx_exchange.py @@ -40,6 +40,37 @@ class Bingx(exchanges.RestExchange): # Set True when get_open_order() can return outdated orders (cancelled or not yet created) CAN_HAVE_DELAYED_CANCELLED_ORDERS = True + # should be overridden locally to match exchange support + SUPPORTED_ELEMENTS = { + trading_enums.ExchangeTypes.FUTURE.value: { + # order that should be self-managed by OctoBot + trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ + trading_enums.TraderOrderType.STOP_LOSS, + trading_enums.TraderOrderType.STOP_LOSS_LIMIT, + trading_enums.TraderOrderType.TAKE_PROFIT, + trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, + trading_enums.TraderOrderType.TRAILING_STOP, + trading_enums.TraderOrderType.TRAILING_STOP_LIMIT + ], + # order that can be bundled together to create them all in one request + # not supported or need custom mechanics with batch orders + trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, + }, + trading_enums.ExchangeTypes.SPOT.value: { + # order that should be self-managed by OctoBot + trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ + # trading_enums.TraderOrderType.STOP_LOSS, # supported on spot + trading_enums.TraderOrderType.STOP_LOSS_LIMIT, + trading_enums.TraderOrderType.TAKE_PROFIT, + trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, + trading_enums.TraderOrderType.TRAILING_STOP, + trading_enums.TraderOrderType.TRAILING_STOP_LIMIT + ], + # order that can be bundled together to create them all in one request + trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, + } + } + def get_adapter_class(self): return BingxCCXTAdapter @@ -64,10 +95,44 @@ def is_authenticated_request(self, url: str, method: str, headers: dict, body) - and signature_identifier in url ) + async def _create_market_stop_loss_order(self, symbol, quantity, price, side, current_price, params=None) -> dict: + params = params or {} + params["stopLossPrice"] = price # make ccxt understand that it's a stop loss + order = self.connector.adapter.adapt_order( + await self.connector.client.create_order( + symbol, trading_enums.TradeOrderType.MARKET.value, side, quantity, params=params + ), + symbol=symbol, quantity=quantity + ) + return order + class BingxCCXTAdapter(exchanges.CCXTAdapter): + def _update_stop_order_or_trade_type_and_price(self, order_or_trade: dict): + info = order_or_trade.get(ccxt_constants.CCXT_INFO, {}) + if stop_price := order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.STOP_LOSS_PRICE.value): + # from https://bingx-api.github.io/docs/#/en-us/spot/trade-api.html#Current%20Open%20Orders + order_creation_price = float( + info.get("price") or order_or_trade.get( + trading_enums.ExchangeConstantsOrderColumns.PRICE.value + ) + ) + stop_price = float(stop_price) + # use stop price as order price to parse it properly + order_or_trade[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] = stop_price + # type is TAKE_STOP_LIMIT (not unified) + if order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.TYPE.value) not in ( + trading_enums.TradeOrderType.STOP_LOSS.value, trading_enums.TradeOrderType.TAKE_PROFIT.value + ): + if stop_price <= order_creation_price: + order_type = trading_enums.TradeOrderType.STOP_LOSS.value + else: + order_type = trading_enums.TradeOrderType.TAKE_PROFIT.value + order_or_trade[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = order_type + def fix_order(self, raw, **kwargs): fixed = super().fix_order(raw, **kwargs) + self._update_stop_order_or_trade_type_and_price(fixed) try: info = fixed[ccxt_constants.CCXT_INFO] fixed[trading_enums.ExchangeConstantsOrderColumns.EXCHANGE_ID.value] = info["orderId"] @@ -75,6 +140,12 @@ def fix_order(self, raw, **kwargs): pass return fixed + def fix_trades(self, raw, **kwargs): + fixed = super().fix_trades(raw, **kwargs) + for trade in fixed: + self._update_stop_order_or_trade_type_and_price(trade) + return fixed + def fix_market_status(self, raw, remove_price_limits=False, **kwargs): fixed = super().fix_market_status(raw, remove_price_limits=remove_price_limits, **kwargs) if not fixed: diff --git a/Trading/Exchange/coinbase/coinbase_exchange.py b/Trading/Exchange/coinbase/coinbase_exchange.py index da6857750..8f6d0e9bc 100644 --- a/Trading/Exchange/coinbase/coinbase_exchange.py +++ b/Trading/Exchange/coinbase/coinbase_exchange.py @@ -126,6 +126,39 @@ class Coinbase(exchanges.RestExchange): ("insufficient balance in source account", ) ] + # should be overridden locally to match exchange support + SUPPORTED_ELEMENTS = { + trading_enums.ExchangeTypes.FUTURE.value: { + # order that should be self-managed by OctoBot + trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ + trading_enums.TraderOrderType.STOP_LOSS, + trading_enums.TraderOrderType.STOP_LOSS_LIMIT, + trading_enums.TraderOrderType.TAKE_PROFIT, + trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, + trading_enums.TraderOrderType.TRAILING_STOP, + trading_enums.TraderOrderType.TRAILING_STOP_LIMIT + ], + # order that can be bundled together to create them all in one request + # not supported or need custom mechanics with batch orders + trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, + }, + trading_enums.ExchangeTypes.SPOT.value: { + # order that should be self-managed by OctoBot + trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ + # trading_enums.TraderOrderType.STOP_LOSS, # supported on spot (as spot limit) + trading_enums.TraderOrderType.STOP_LOSS_LIMIT, + trading_enums.TraderOrderType.TAKE_PROFIT, + trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, + trading_enums.TraderOrderType.TRAILING_STOP, + trading_enums.TraderOrderType.TRAILING_STOP_LIMIT + ], + # order that can be bundled together to create them all in one request + trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, + } + } + # stop limit price is 2% bellow trigger price to ensure instant fill + STOP_LIMIT_ORDER_INSTANT_FILL_PRICE_RATIO = decimal.Decimal("0.98") + @classmethod def get_name(cls): return 'coinbase' @@ -253,6 +286,21 @@ async def get_order(self, exchange_order_id: str, symbol: str = None, **kwargs: # override for retrier return await super().get_order(exchange_order_id, symbol=symbol, **kwargs) + @_coinbase_retrier + async def _create_market_stop_loss_order(self, symbol, quantity, price, side, current_price, params=None) -> dict: + params = params or {} + # warning coinbase only supports stop limit orders, stop markets are not available + if "stopLossPrice" not in params: + params["stopLossPrice"] = price # make ccxt understand that it's a stop loss + price = float(decimal.Decimal(str(price)) * self.STOP_LIMIT_ORDER_INSTANT_FILL_PRICE_RATIO) + order = self.connector.adapter.adapt_order( + await self.connector.client.create_order( + symbol, trading_enums.TradeOrderType.LIMIT.value, side, quantity, price, params=params + ), + symbol=symbol, quantity=quantity + ) + return order + def _get_ohlcv_params(self, time_frame, input_limit, **kwargs): limit = input_limit if not input_limit or input_limit > self.MAX_PAGINATION_LIMIT: @@ -311,6 +359,21 @@ def _register_exchange_fees(self, order_or_trade): except (KeyError, TypeError): pass + def _update_stop_order_or_trade_type_and_price(self, order_or_trade: dict): + if stop_price := order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.STOP_PRICE.value): + # from https://bingx-api.github.io/docs/#/en-us/spot/trade-api.html#Current%20Open%20Orders + limit_price = order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.PRICE.value) + # use stop price as order price to parse it properly + order_or_trade[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] = stop_price + # type is TAKE_STOP_LIMIT (not unified) + if order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.TYPE.value) not in ( + trading_enums.TradeOrderType.STOP_LOSS.value, trading_enums.TradeOrderType.TAKE_PROFIT.value + ): + # Force stop loss. Add order direction parsing logic to handle take profits if necessary + order_or_trade[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = ( + trading_enums.TradeOrderType.STOP_LOSS.value # simulate market stop loss + ) + def fix_order(self, raw, **kwargs): """ Handle 'order_type': 'UNKNOWN_ORDER_TYPE in coinbase order response (translated into None in ccxt order type) @@ -336,6 +399,7 @@ def fix_order(self, raw, **kwargs): 'takeProfitPrice': None, 'stopLossPrice': None, 'exchange_id': 'd7471b4e-960e-4c92-bdbf-755cb92e176b'} """ fixed = super().fix_order(raw, **kwargs) + self._update_stop_order_or_trade_type_and_price(fixed) if fixed[ccxt_enums.ExchangeOrderCCXTColumns.TYPE.value] is None: if fixed[ccxt_enums.ExchangeOrderCCXTColumns.STOP_PRICE.value] is not None: # stop price set: stop order @@ -361,6 +425,7 @@ def fix_order(self, raw, **kwargs): def fix_trades(self, raw, **kwargs): raw = super().fix_trades(raw, **kwargs) for trade in raw: + self._update_stop_order_or_trade_type_and_price(trade) trade[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = trading_enums.OrderStatus.CLOSED.value try: if trade[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value] is None and \ diff --git a/Trading/Exchange/kucoin/kucoin_exchange.py b/Trading/Exchange/kucoin/kucoin_exchange.py index 5009b2e9b..3d2c828af 100644 --- a/Trading/Exchange/kucoin/kucoin_exchange.py +++ b/Trading/Exchange/kucoin/kucoin_exchange.py @@ -110,7 +110,7 @@ class Kucoin(exchanges.RestExchange): trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ - trading_enums.TraderOrderType.STOP_LOSS, + # trading_enums.TraderOrderType.STOP_LOSS, # supported on spot trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, @@ -333,8 +333,7 @@ async def get_open_orders(self, symbol=None, since=None, limit=None, **kwargs) - limit = 200 regular_orders = await super().get_open_orders(symbol=symbol, since=since, limit=limit, **kwargs) stop_orders = [] - if self.exchange_manager.is_future: - # stop ordes are futures only for now + if "stop" not in kwargs: # add untriggered stop orders (different api endpoint) kwargs["stop"] = True stop_orders = await super().get_open_orders(symbol=symbol, since=since, limit=limit, **kwargs) @@ -494,6 +493,7 @@ def fix_order(self, raw, symbol=None, **kwargs): def fix_trades(self, raw, **kwargs): fixed = super().fix_trades(raw, **kwargs) for trade in fixed: + self._adapt_order_type(trade) self._ensure_fees(trade) return fixed @@ -506,16 +506,16 @@ def _adapt_order_type(self, fixed): down: Triggers when the price reaches or goes below the stopPrice. up: Triggers when the price reaches or goes above the stopPrice. """ - side = fixed[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] + side = fixed.get(trading_enums.ExchangeConstantsOrderColumns.SIDE.value) if side == trading_enums.TradeOrderSide.BUY.value: - if trigger_direction == "up": + if trigger_direction in ("up", "loss"): updated_type = trading_enums.TradeOrderType.STOP_LOSS.value - elif trigger_direction == "down": + elif trigger_direction in ("down", "entry"): updated_type = trading_enums.TradeOrderType.TAKE_PROFIT.value else: - if trigger_direction == "up": + if trigger_direction in ("up", "entry"): updated_type = trading_enums.TradeOrderType.TAKE_PROFIT.value - elif trigger_direction == "down": + elif trigger_direction in ("down", "loss"): updated_type = trading_enums.TradeOrderType.STOP_LOSS.value # stop loss are not tagged as such by ccxt, force it fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = updated_type From 350fd2ac74a7b044f030602f9bb66c90a14521f6 Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Sun, 8 Dec 2024 21:12:34 +0100 Subject: [PATCH 2/2] [Coinbase] explicitly use stop limit orders --- Trading/Exchange/coinbase/coinbase_exchange.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Trading/Exchange/coinbase/coinbase_exchange.py b/Trading/Exchange/coinbase/coinbase_exchange.py index 8f6d0e9bc..05477703b 100644 --- a/Trading/Exchange/coinbase/coinbase_exchange.py +++ b/Trading/Exchange/coinbase/coinbase_exchange.py @@ -286,13 +286,18 @@ async def get_order(self, exchange_order_id: str, symbol: str = None, **kwargs: # override for retrier return await super().get_order(exchange_order_id, symbol=symbol, **kwargs) - @_coinbase_retrier async def _create_market_stop_loss_order(self, symbol, quantity, price, side, current_price, params=None) -> dict: - params = params or {} # warning coinbase only supports stop limit orders, stop markets are not available + stop_price = price + price = float(decimal.Decimal(str(price)) * self.STOP_LIMIT_ORDER_INSTANT_FILL_PRICE_RATIO) + # use limit stop loss with a "normally instantly" filled price + return await self._create_limit_stop_loss_order(symbol, quantity, price, stop_price, side, params=params) + + @_coinbase_retrier + async def _create_limit_stop_loss_order(self, symbol, quantity, price, stop_price, side, params=None) -> dict: + params = params or {} if "stopLossPrice" not in params: - params["stopLossPrice"] = price # make ccxt understand that it's a stop loss - price = float(decimal.Decimal(str(price)) * self.STOP_LIMIT_ORDER_INSTANT_FILL_PRICE_RATIO) + params["stopLossPrice"] = stop_price # make ccxt understand that it's a stop loss order = self.connector.adapter.adapt_order( await self.connector.client.create_order( symbol, trading_enums.TradeOrderType.LIMIT.value, side, quantity, price, params=params