diff --git a/Meta/Keywords/scripting_library/orders/position_size/amount.py b/Meta/Keywords/scripting_library/orders/position_size/amount.py index 7e6046113..6ed78d3f6 100644 --- a/Meta/Keywords/scripting_library/orders/position_size/amount.py +++ b/Meta/Keywords/scripting_library/orders/position_size/amount.py @@ -31,28 +31,15 @@ async def get_amount( unknown_portfolio_on_creation=False, target_price=None ): - try: - amount_value = await script_keywords.get_amount_from_input_amount( - context=context, - input_amount=input_amount, - side=side, - reduce_only=reduce_only, - is_stop_order=is_stop_order, - use_total_holding=use_total_holding, - target_price=target_price - ) - except NotImplementedError: - amount_type, amount_value = script_keywords.parse_quantity(input_amount) - if amount_type is script_keywords.QuantityType.POSITION_PERCENT: # todo handle existing open short position - amount_value = \ - exchange_private_data.open_position_size(context, side, - amount_type=commons_constants.PORTFOLIO_AVAILABLE) \ - * amount_value / 100 - else: - raise trading_errors.InvalidArgumentError("make sure to use a supported syntax for amount") - return await script_keywords.adapt_amount_to_holdings(context, amount_value, side, - use_total_holding, reduce_only, is_stop_order, - target_price=target_price) + amount_value = await script_keywords.get_amount_from_input_amount( + context=context, + input_amount=input_amount, + side=side, + reduce_only=reduce_only, + is_stop_order=is_stop_order, + use_total_holding=use_total_holding, + target_price=target_price + ) if unknown_portfolio_on_creation: # no way to check if the amount is valid when creating order _, amount_value = script_keywords.parse_quantity(input_amount) diff --git a/Meta/Keywords/scripting_library/tests/orders/order_types/test_multiple_orders_creation.py b/Meta/Keywords/scripting_library/tests/orders/order_types/test_multiple_orders_creation.py index 33f7199d2..e26422622 100644 --- a/Meta/Keywords/scripting_library/tests/orders/order_types/test_multiple_orders_creation.py +++ b/Meta/Keywords/scripting_library/tests/orders/order_types/test_multiple_orders_creation.py @@ -24,6 +24,7 @@ import octobot_trading.personal_data.orders.order_util as order_util import octobot_trading.api as api import octobot_trading.errors as errors +import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import tentacles.Meta.Keywords.scripting_library as scripting_library @@ -102,6 +103,13 @@ async def test_orders_with_invalid_values(mock_context, skip_if_octobot_trading_ @pytest.mark.parametrize("backtesting_config", ["USDT"], indirect=["backtesting_config"]) async def test_orders_amount_then_position_sequence(mock_context): initial_usdt_holdings, btc_price = await _usdt_trading_context(mock_context) + mock_context.exchange_manager.is_future = True + api.load_pair_contract( + mock_context.exchange_manager, + api.create_default_future_contract( + mock_context.symbol, decimal.Decimal(1), trading_enums.FutureContractType.LINEAR_PERPETUAL + ).to_dict() + ) if os.getenv('CYTHON_IGNORE'): return @@ -193,7 +201,7 @@ async def test_concurrent_orders(mock_context): # create 3 sell orders (at price = 500 + 10 = 510) # that would end up selling more than what we have if not executed sequentially - # 1st order is 80% of position, second is 80% of the remaining 20% and so on + # 1st order is 80% of available btc, second is 80% of the remaining 20% and so on orders = [] async def create_order(amount): @@ -207,13 +215,13 @@ async def create_order(amount): ) await asyncio.gather( *( - create_order("80%p") + create_order("80%a") for _ in range(3) ) ) initial_btc_holdings = btc_val - btc_val = initial_btc_holdings * decimal.Decimal("0.2") ** 3 # 0.16 + btc_val = initial_btc_holdings * (decimal.Decimal("0.2") ** 3) usdt_val = usdt_val + (initial_btc_holdings - btc_val) * (btc_price + 10) # 50118.40 await _fill_and_check(mock_context, btc_val, usdt_val, orders, orders_count=3) diff --git a/Meta/Keywords/scripting_library/tests/orders/position_size/test_amount.py b/Meta/Keywords/scripting_library/tests/orders/position_size/test_amount.py deleted file mode 100644 index f79a4fc4e..000000000 --- a/Meta/Keywords/scripting_library/tests/orders/position_size/test_amount.py +++ /dev/null @@ -1,77 +0,0 @@ -# Drakkar-Software OctoBot-Trading -# Copyright (c) Drakkar-Software, All rights reserved. -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 3.0 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library. -import pytest -import mock -import decimal - -import octobot_trading.constants as constants -import octobot_trading.errors as errors -import octobot_trading.modes.script_keywords as script_keywords -import octobot_trading.modes.script_keywords.dsl as dsl -import octobot_trading.modes.script_keywords.basic_keywords.account_balance as account_balance -import tentacles.Meta.Keywords.scripting_library.orders.position_size.amount as amount -import tentacles.Meta.Keywords.scripting_library.data.reading.exchange_private_data as exchange_private_data -import octobot_commons.constants as commons_constants - -from tentacles.Meta.Keywords.scripting_library.tests import event_loop, null_context - -# All test coroutines will be treated as marked. -pytestmark = pytest.mark.asyncio - - -async def test_get_amount(null_context): - with pytest.raises(errors.InvalidArgumentError): - await amount.get_amount(null_context, "-1") - - with pytest.raises(errors.InvalidArgumentError): - await amount.get_amount(null_context, "1sdsqdq") - - with mock.patch.object(account_balance, "adapt_amount_to_holdings", - mock.AsyncMock(return_value=decimal.Decimal(1))) as adapt_amount_to_holdings_mock, \ - mock.patch.object(script_keywords, "adapt_amount_to_holdings", - mock.AsyncMock(return_value=decimal.Decimal(1))) \ - as script_keywords_adapt_amount_to_holdings_mock: - with mock.patch.object(dsl, "parse_quantity", - mock.Mock(return_value=(script_keywords.QuantityType.DELTA_BASE, decimal.Decimal(2)))) \ - as parse_quantity_mock: - assert await amount.get_amount(null_context, "1", "buy", target_price=constants.ONE) == decimal.Decimal(1) - adapt_amount_to_holdings_mock.assert_called_once_with(null_context, decimal.Decimal(2), "buy", - False, True, False, target_price=constants.ONE, - orders_to_be_ignored=None) - parse_quantity_mock.assert_called_once_with("1") - adapt_amount_to_holdings_mock.reset_mock() - - with mock.patch.object(dsl, "parse_quantity", - mock.Mock( - return_value=(script_keywords.QuantityType.POSITION_PERCENT, decimal.Decimal(75)))) \ - as dsl_parse_quantity_mock, \ - mock.patch.object(script_keywords, "parse_quantity", - mock.Mock( - return_value=( - script_keywords.QuantityType.POSITION_PERCENT, decimal.Decimal(75)))) \ - as parse_quantity_mock, \ - mock.patch.object(exchange_private_data, "open_position_size", - mock.Mock(return_value=decimal.Decimal(2))) \ - as open_position_size_mock: - assert await amount.get_amount(null_context, "50", "buy") == decimal.Decimal(1) - script_keywords_adapt_amount_to_holdings_mock.assert_called_once_with( - null_context, decimal.Decimal("1.5"), "buy", False, True, False, target_price=None - ) - dsl_parse_quantity_mock.assert_called_once_with("50") - parse_quantity_mock.assert_called_once_with("50") - open_position_size_mock.assert_called_once_with(null_context, "buy", - amount_type=commons_constants.PORTFOLIO_AVAILABLE) - script_keywords_adapt_amount_to_holdings_mock.reset_mock() diff --git a/Trading/Exchange/binance/binance_exchange.py b/Trading/Exchange/binance/binance_exchange.py index ea581b5ae..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, @@ -102,13 +102,17 @@ def get_adapter_class(self): return BinanceCCXTAdapter async def get_account_id(self, **kwargs: dict) -> str: - raw_balance = await self.connector.client.fetch_balance() try: - return raw_balance[ccxt_constants.CCXT_INFO]["uid"] - except KeyError: if self.exchange_manager.is_future: - raise NotImplementedError("get_account_id is not implemented on binance futures account") - # should not happen in spot + raw_binance_balance = await self.connector.client.fapiPrivateV3GetBalance() + # accountAlias = unique account code + # from https://binance-docs.github.io/apidocs/futures/en/#futures-account-balance-v3-user_data + return raw_binance_balance[0]["accountAlias"] + else: + raw_balance = await self.connector.client.fetch_balance() + return raw_balance[ccxt_constants.CCXT_INFO]["uid"] + except (KeyError, IndexError): + # should not happen raise def _infer_account_types(self, exchange_manager): @@ -157,6 +161,18 @@ def get_additional_connector_config(self): } return config + def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: + signature_identifier = "signature=" + return bool( + ( + url + and signature_identifier in url # for GET & DELETE requests + ) or ( + body + and signature_identifier in body # for other requests + ) + ) + async def get_balance(self, **kwargs: dict): if self.exchange_manager.is_future: balance = [] @@ -193,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 = [] @@ -237,7 +251,7 @@ async def set_symbol_margin_type(self, symbol: str, isolated: bool, **kwargs: di :return: the update result """ try: - return await super(). set_symbol_margin_type(symbol, isolated, **kwargs) + return await super().set_symbol_margin_type(symbol, isolated, **kwargs) except ccxt.ExchangeError as err: raise errors.NotSupported() from err @@ -275,19 +289,7 @@ def fix_trades(self, raw, **kwargs): def parse_position(self, fixed, force_empty=False, **kwargs): try: - parsed = super().parse_position(fixed, force_empty=force_empty, **kwargs) - parsed[trading_enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value] = \ - trading_enums.MarginType( - fixed.get(ccxt_enums.ExchangePositionCCXTColumns.MARGIN_MODE.value) - ) - # use one way by default. - if parsed[trading_enums.ExchangeConstantsPositionColumns.POSITION_MODE.value] is None: - parsed[trading_enums.ExchangeConstantsPositionColumns.POSITION_MODE.value] = ( - trading_enums.PositionMode.HEDGE if fixed.get(ccxt_enums.ExchangePositionCCXTColumns.HEDGED.value, - True) - else trading_enums.PositionMode.ONE_WAY - ) - return parsed + return super().parse_position(fixed, force_empty=force_empty, **kwargs) except decimal.InvalidOperation: # on binance, positions might be invalid (ex: LUNAUSD_PERP as None contact size) return None diff --git a/Trading/Exchange/bingx/bingx_exchange.py b/Trading/Exchange/bingx/bingx_exchange.py index 946af2261..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 @@ -57,11 +88,51 @@ async def get_my_recent_trades(self, symbol=None, since=None, limit=None, **kwar return await super().get_my_recent_trades(symbol=symbol, since=since, limit=limit, **kwargs) return await super().get_closed_orders(symbol=symbol, since=since, limit=limit, **kwargs) + def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: + signature_identifier = "signature=" + return bool( + url + 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"] @@ -69,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 ea17848b4..05477703b 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' @@ -169,7 +202,7 @@ async def get_account_id(self, **kwargs: dict) -> str: self.logger.exception( err, True, f"Error when fetching {self.get_name()} account id: {err} ({err.__class__.__name__}). " - f"This is not normal, endpoint might be deprecated, see" + f"This is not normal, endpoint might be deprecated, see " f"https://docs.cloud.coinbase.com/sign-in-with-coinbase/docs/api-users. " f"Using generated account id instead" ) @@ -253,6 +286,26 @@ 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) + async def _create_market_stop_loss_order(self, symbol, quantity, price, side, current_price, params=None) -> dict: + # 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"] = 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 + ), + 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: @@ -264,6 +317,17 @@ def _get_ohlcv_params(self, time_frame, input_limit, **kwargs): kwargs["limit"] = limit return kwargs + def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: + signature_identifier = "CB-ACCESS-SIGN" + oauth_identifier = "Authorization" + return bool( + headers + and ( + signature_identifier in headers + or oauth_identifier in headers + ) + ) + def is_market_open_for_order_type(self, symbol: str, order_type: trading_enums.TraderOrderType) -> bool: """ Override if necessary @@ -300,6 +364,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) @@ -325,6 +404,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 @@ -350,6 +430,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 b3327b039..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, @@ -128,6 +128,8 @@ class Kucoin(exchanges.RestExchange): ("order does not exist",), ] + DEFAULT_BALANCE_CURRENCIES_TO_FETCH = ["USDT"] + @classmethod def get_name(cls): return 'kucoin' @@ -259,6 +261,13 @@ def should_log_on_ddos_exception(self, exception) -> bool: """ return Kucoin.INSTANT_RETRY_ERROR_CODE not in str(exception) + def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: + signature_identifier = "KC-API-SIGN" + return bool( + headers + and signature_identifier in headers + ) + def get_order_additional_params(self, order) -> dict: params = {} if self.exchange_manager.is_future: @@ -288,9 +297,16 @@ async def get_balance(self, **kwargs: dict): if self.exchange_manager.is_future: # on futures, balance has to be fetched per currency # use gather to fetch everything at once (and not allow other requests to get in between) + currencies = self.exchange_manager.exchange_config.get_all_traded_currencies() + if not currencies: + currencies = self.DEFAULT_BALANCE_CURRENCIES_TO_FETCH + self.logger.warning( + f"Can't fetch balance on {self.exchange_manager.exchange_name} futures when no traded currencies " + f"are set, fetching {currencies[0]} balance instead" + ) await asyncio.gather(*( self._update_balance(balance, currency, **kwargs) - for currency in self.exchange_manager.exchange_config.get_all_traded_currencies() + for currency in currencies )) return balance return await super().get_balance(**kwargs) @@ -317,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) @@ -333,8 +348,21 @@ async def create_order(self, order_type: trading_enums.TraderOrderType, symbol: side: trading_enums.TradeOrderSide = None, current_price: decimal.Decimal = None, reduce_only: bool = False, params: dict = None) -> typing.Optional[dict]: if self.exchange_manager.is_future: + params = params or {} # on futures exchange expects, quantity in contracts: convert quantity into contracts quantity = quantity / self.get_contract_size(symbol) + try: + # "marginMode": "ISOLATED" // Added field for margin mode: ISOLATED, CROSS, default: ISOLATED + # from https://www.kucoin.com/docs/rest/futures-trading/orders/place-order + if ( + KucoinCCXTAdapter.KUCOIN_MARGIN_MODE not in params and + self.exchange_manager.exchange_personal_data.positions_manager.get_symbol_position_margin_type( + symbol + ) is trading_enums.MarginType.CROSS + ): + params[KucoinCCXTAdapter.KUCOIN_MARGIN_MODE] = "CROSS" + except ValueError as err: + self.logger.error(f"Impossible to add {KucoinCCXTAdapter.KUCOIN_MARGIN_MODE} to order: {err}") return await super().create_order(order_type, symbol, quantity, price=price, stop_price=stop_price, side=side, current_price=current_price, @@ -448,6 +476,7 @@ class KucoinCCXTAdapter(exchanges.CCXTAdapter): # ORDER KUCOIN_LEVERAGE = "leverage" + KUCOIN_MARGIN_MODE = "marginMode" def fix_order(self, raw, symbol=None, **kwargs): raw_order_info = raw[ccxt_enums.ExchangePositionCCXTColumns.INFO.value] @@ -464,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 @@ -476,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 @@ -515,13 +545,9 @@ def parse_funding_rate(self, fixed, from_ticker=False, **kwargs): def parse_position(self, fixed, **kwargs): raw_position_info = fixed[ccxt_enums.ExchangePositionCCXTColumns.INFO.value] parsed = super().parse_position(fixed, **kwargs) - parsed[trading_enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value] = \ - trading_enums.MarginType( - fixed.get(ccxt_enums.ExchangePositionCCXTColumns.MARGIN_MODE.value) - ) - parsed[trading_enums.ExchangeConstantsPositionColumns.POSITION_MODE.value] = \ - trading_enums.PositionMode.HEDGE if raw_position_info[self.KUCOIN_AUTO_DEPOSIT] \ - else trading_enums.PositionMode.ONE_WAY + parsed[trading_enums.ExchangeConstantsPositionColumns.AUTO_DEPOSIT_MARGIN.value] = ( + raw_position_info.get(self.KUCOIN_AUTO_DEPOSIT, False) # unset for cross positions + ) parsed_leverage = self.safe_decimal( parsed, trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value, constants.ZERO ) diff --git a/Trading/Exchange/okx/okx_exchange.py b/Trading/Exchange/okx/okx_exchange.py index 27194e590..53c720b27 100644 --- a/Trading/Exchange/okx/okx_exchange.py +++ b/Trading/Exchange/okx/okx_exchange.py @@ -181,6 +181,13 @@ def get_supported_exchange_types(cls) -> list: trading_enums.ExchangeTypes.FUTURE, ] + def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: + signature_identifier = "OK-ACCESS-SIGN" + return bool( + headers + and signature_identifier in headers + ) + def _fix_limit(self, limit: int) -> int: return min(self.MAX_PAGINATION_LIMIT, limit) if limit else limit diff --git a/Trading/Mode/trading_view_signals_trading_mode/tests/test_trading_view_signals_trading.py b/Trading/Mode/trading_view_signals_trading_mode/tests/test_trading_view_signals_trading.py index c6fb0d99c..542dd28b6 100644 --- a/Trading/Mode/trading_view_signals_trading_mode/tests/test_trading_view_signals_trading.py +++ b/Trading/Mode/trading_view_signals_trading_mode/tests/test_trading_view_signals_trading.py @@ -195,19 +195,20 @@ async def test_trading_view_signal_callback(tools): context = script_keywords.get_base_context(producer.trading_mode) with mock.patch.object(script_keywords, "get_base_context", mock.Mock(return_value=context)) \ as get_base_context_mock: - # ensure exception is caught - with mock.patch.object( - producer, "signal_callback", mock.AsyncMock(side_effect=errors.MissingFunds) - ) as signal_callback_mock: - signal = f""" - EXCHANGE={exchange_manager.exchange_name} - SYMBOL={symbol} - SIGNAL=BUY - """ - await mode._trading_view_signal_callback({"metadata": signal}) - signal_callback_mock.assert_awaited_once() - get_base_context_mock.assert_called_once() - get_base_context_mock.reset_mock() + for exception in (errors.MissingFunds, errors.InvalidArgumentError): + # ensure exception is caught + with mock.patch.object( + producer, "signal_callback", mock.AsyncMock(side_effect=exception) + ) as signal_callback_mock: + signal = f""" + EXCHANGE={exchange_manager.exchange_name} + SYMBOL={symbol} + SIGNAL=BUY + """ + await mode._trading_view_signal_callback({"metadata": signal}) + signal_callback_mock.assert_awaited_once() + get_base_context_mock.assert_called_once() + get_base_context_mock.reset_mock() with mock.patch.object(producer, "signal_callback", mock.AsyncMock()) as signal_callback_mock: # invalid data @@ -427,6 +428,23 @@ async def test_signal_callback(tools): }, context) _set_state_mock.assert_not_called() + with pytest.raises(errors.InvalidArgumentError): + await producer.signal_callback({ + mode.EXCHANGE_KEY: exchange_manager.exchange_name, + mode.SYMBOL_KEY: "unused", + mode.SIGNAL_KEY: "DSDSDDSS", + mode.PRICE_KEY: "123000q", # price = 123 + mode.VOLUME_KEY: "11111b", # base amount: not enough funds + mode.REDUCE_ONLY_KEY: True, + mode.ORDER_TYPE_SIGNAL: "LiMiT", + mode.STOP_PRICE_KEY: "-10%", # price - 10% + mode.TAKE_PROFIT_PRICE_KEY: "120.333333333333333d", # price + 120.333333333333333 + mode.EXCHANGE_ORDER_IDS: ["ab1", "aaaaa"], + "PARAM_TAG_1": "ttt", + "PARAM_Plop": False, + }, context) + _set_state_mock.assert_not_called() + def compare_dict_with_nan(d_1, d_2): try: diff --git a/Trading/Mode/trading_view_signals_trading_mode/trading_view_signals_trading.py b/Trading/Mode/trading_view_signals_trading_mode/trading_view_signals_trading.py index 0a2265f22..372c914e0 100644 --- a/Trading/Mode/trading_view_signals_trading_mode/trading_view_signals_trading.py +++ b/Trading/Mode/trading_view_signals_trading_mode/trading_view_signals_trading.py @@ -176,6 +176,8 @@ async def _trading_view_signal_callback(self, data): (parsed_data[self.SYMBOL_KEY] == self.merged_simple_symbol or parsed_data[self.SYMBOL_KEY] == self.str_symbol): await self.producers[0].signal_callback(parsed_data, script_keywords.get_base_context(self)) + except trading_errors.InvalidArgumentError as e: + self.logger.error(f"Error when handling trading view signal: {e}") except trading_errors.MissingFunds as e: self.logger.error(f"Error when handling trading view signal: not enough funds: {e}") except KeyError as e: @@ -261,10 +263,9 @@ async def _parse_order_details(self, ctx, parsed_data): elif side == TradingViewSignalsTradingMode.CANCEL_SIGNAL: state = trading_enums.EvaluatorStates.NEUTRAL else: - self.logger.error( + raise trading_errors.InvalidArgumentError( f"Unknown signal: {parsed_data[TradingViewSignalsTradingMode.SIGNAL_KEY]}, full data= {parsed_data}" ) - state = trading_enums.EvaluatorStates.NEUTRAL target_price = 0 if order_type == TradingViewSignalsTradingMode.MARKET_SIGNAL else ( await self._parse_price(ctx, parsed_data, TradingViewSignalsTradingMode.PRICE_KEY, 0)) stop_price = await self._parse_price(