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..415d45550 100644 --- a/Trading/Exchange/binance/binance_exchange.py +++ b/Trading/Exchange/binance/binance_exchange.py @@ -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): @@ -237,7 +241,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 +279,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/kucoin/kucoin_exchange.py b/Trading/Exchange/kucoin/kucoin_exchange.py index b3327b039..0fb38cbe4 100644 --- a/Trading/Exchange/kucoin/kucoin_exchange.py +++ b/Trading/Exchange/kucoin/kucoin_exchange.py @@ -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' @@ -288,9 +290,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) @@ -333,8 +342,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 +470,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] @@ -515,13 +538,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/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(