diff --git a/additional_tests/exchanges_tests/abstract_authenticated_exchange_tester.py b/additional_tests/exchanges_tests/abstract_authenticated_exchange_tester.py index d318ad943..0f83ef8c0 100644 --- a/additional_tests/exchanges_tests/abstract_authenticated_exchange_tester.py +++ b/additional_tests/exchanges_tests/abstract_authenticated_exchange_tester.py @@ -16,6 +16,7 @@ import asyncio import contextlib import decimal +import random import time import typing import ccxt @@ -25,10 +26,12 @@ import octobot_commons.constants as constants import octobot_commons.enums as commons_enums import octobot_commons.symbols as symbols +import octobot_commons.configuration as commons_configuration import octobot_trading.errors as trading_errors import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.exchanges as trading_exchanges +import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.personal_data as personal_data import octobot_trading.personal_data.orders as personal_data_orders import octobot_trading.util.test_tools.exchanges_test_tools as exchanges_test_tools @@ -51,7 +54,20 @@ class AbstractAuthenticatedExchangeTester: ORDER_CURRENCY = "BTC" SETTLEMENT_CURRENCY = "USDT" SYMBOL = f"{ORDER_CURRENCY}/{SETTLEMENT_CURRENCY}" + TIME_FRAME = "1h" VALID_ORDER_ID = "8bb80a81-27f7-4415-aa50-911ea46d841c" + SPECIAL_ORDER_TYPES_BY_EXCHANGE_ID: dict[ + str, ( + str, # symbol + str, # order type key in 'info' dict + str, # order type found in 'info' dict + str, # parsed trading_enums.TradeOrderType + str, # parsed trading_enums.TradeOrderSide + bool, # trigger above (on higher price than order price) + ) + ] = {} # stop loss / take profit and other special order types to be successfully parsed + # details of an order that exists but can"t be cancelled + UNCANCELLABLE_ORDER_ID_SYMBOL_TYPE: tuple[str, str, trading_enums.TraderOrderType] = None ORDER_SIZE = 10 # % of portfolio to include in test orders PORTFOLIO_TYPE_FOR_SIZE = trading_constants.CONFIG_PORTFOLIO_FREE CONVERTS_ORDER_SIZE_BEFORE_PUSHING_TO_EXCHANGES = False @@ -90,12 +106,17 @@ class AbstractAuthenticatedExchangeTester: CHECK_EMPTY_ACCOUNT = False # set True when the account to check has no funds. Warning: does not check order # parse/create/fill/cancel or portfolio & trades parsing IS_BROKER_ENABLED_ACCOUNT = True # set False when this test account can't generate broker fees + # set True when this exchange used to have symbols that can't be traded through API (ex: MEXC) + USED_TO_HAVE_UNTRADABLE_SYMBOL = False # Implement all "test_[name]" methods, call super() to run the test, pass to ignore it. # Override the "inner_test_[name]" method to override a test content. # Add method call to subclasses to be able to run them independently async def test_get_portfolio(self): + # encoded_a = _get_encoded_value("") # tool to get encoded values + # encoded_b = _get_encoded_value("") + # encoded_c = _get_encoded_value("") async with self.local_exchange_manager(): await self.inner_test_get_portfolio() @@ -141,6 +162,98 @@ def check_portfolio_content(self, portfolio): at_least_one_value = True assert at_least_one_value + async def test_untradable_symbols(self): + await self.inner_test_untradable_symbols() + + async def inner_test_untradable_symbols(self): + if not self.USED_TO_HAVE_UNTRADABLE_SYMBOL: + # nothing to do + return + async with self.local_exchange_manager(): + all_symbols = self.exchange_manager.exchange.get_all_available_symbols() + tradable_symbols = await self.exchange_manager.exchange.get_all_tradable_symbols() + assert len(all_symbols) > len(tradable_symbols) + untradable_symbols = [ + symbol + for symbol in all_symbols + if symbol not in tradable_symbols + and symbol.endswith(f"/{self.SETTLEMENT_CURRENCY}") + and ( + symbols.parse_symbol(symbol).is_spot() + if self.EXCHANGE_TYPE == trading_enums.ExchangeTypes.SPOT.value + else symbols.parse_symbol(symbol).is_future() + ) + ] + tradable_symbols = [ + symbol + for symbol in all_symbols + if symbol in tradable_symbols + and symbol.endswith(f"/{self.SETTLEMENT_CURRENCY}") + and ( + symbols.parse_symbol(symbol).is_spot() + if self.EXCHANGE_TYPE == trading_enums.ExchangeTypes.SPOT.value + else symbols.parse_symbol(symbol).is_future() + ) + ] + # has untradable symbols of this trading type + assert len(untradable_symbols) > 0 + first_untradable_symbol = untradable_symbols[0] + # Public data + # market status is available + assert ccxt_constants.CCXT_INFO in self.exchange_manager.exchange.get_market_status(first_untradable_symbol) + # fetching ohlcv is ok + assert len( + await self.exchange_manager.exchange.get_symbol_prices( + first_untradable_symbol, commons_enums.TimeFrames(self.TIME_FRAME) + ) + ) > 5 + # fetching kline is ok + kline = await self.exchange_manager.exchange.get_kline_price( + first_untradable_symbol, commons_enums.TimeFrames(self.TIME_FRAME) + ) + assert len(kline) == 1 + assert len(kline[0]) == 6 + # fetching ticker is ok + ticker = await self.exchange_manager.exchange.get_price_ticker(first_untradable_symbol) + assert ticker + price = ticker[trading_enums.ExchangeConstantsTickersColumns.CLOSE.value] + assert price > 0 + # fetching recent trades is ok + recent_trades = await self.exchange_manager.exchange.get_recent_trades(first_untradable_symbol) + assert len(recent_trades) > 1 + # fetching order book is ok + order_book = await self.exchange_manager.exchange.get_order_book(first_untradable_symbol) + assert len(order_book[trading_enums.ExchangeConstantsOrderBookInfoColumns.ASKS.value]) > 0 + # is in all tickers + all_tickers = await self.exchange_manager.exchange.get_all_currencies_price_ticker() + assert all_tickers[first_untradable_symbol][trading_enums.ExchangeConstantsTickersColumns.CLOSE.value] > 0 + # Orders + # try creating & cancelling orders on 5 random tradable and untradable symbols + symbols_to_test = 5 + tradable_stepper = random.randint(1, len(tradable_symbols) - 2) + tradable_indexes = [tradable_stepper * i for i in range(0, symbols_to_test)] + untradable_stepper = random.randint(1, len(untradable_symbols) - 2) + untradable_indexes = [untradable_stepper * i for i in range(0, symbols_to_test)] + to_test_symbols = [ + tradable_symbols[i % (len(tradable_symbols) - 1)] for i in tradable_indexes + ] + [ + untradable_symbols[i % (len(untradable_symbols) - 1)] for i in untradable_indexes + ] + for i, symbol in enumerate(to_test_symbols): + ticker = await self.exchange_manager.exchange.get_price_ticker(symbol) + price = ticker[trading_enums.ExchangeConstantsTickersColumns.CLOSE.value] + price = self.get_order_price(decimal.Decimal(str(price)), False, symbol=symbol) + size = self.get_order_size( + await self.get_portfolio(), price, symbol=symbol, + settlement_currency=self.SETTLEMENT_CURRENCY + ) + buy_limit = await self.create_limit_order( + price, size, trading_enums.TradeOrderSide.BUY, + symbol=symbol + ) + await self.cancel_order(buy_limit) + print(f"{i+1}/{len(to_test_symbols)} : {symbol} Create & cancel order OK") + async def test_get_account_id(self): async with self.local_exchange_manager(): await self.inner_test_get_account_id() @@ -278,10 +391,7 @@ async def inner_test_get_api_key_permissions(self): "_get_api_key_rights_using_order", mock.AsyncMock(side_effect=origin_get_api_key_rights_using_order) ) as _get_api_key_rights_using_order_mock: permissions = await self.exchange_manager.exchange_backend._get_api_key_rights() - assert len(permissions) > 0 - assert trading_backend.enums.APIKeyRights.READING in permissions - assert trading_backend.enums.APIKeyRights.SPOT_TRADING in permissions - assert trading_backend.enums.APIKeyRights.FUTURES_TRADING in permissions + self._ensure_required_permissions(permissions) if self.USE_ORDER_OPERATION_TO_CHECK_API_KEY_RIGHTS: # failed ? did not use _get_api_key_rights_using_order while expected _get_api_key_rights_using_order_mock.assert_called_once() @@ -289,6 +399,11 @@ async def inner_test_get_api_key_permissions(self): # failed ? used _get_api_key_rights_using_order when not expected _get_api_key_rights_using_order_mock.assert_not_called() + def _ensure_required_permissions(self, permissions): + assert len(permissions) > 0 + assert trading_backend.enums.APIKeyRights.READING in permissions + assert trading_backend.enums.APIKeyRights.SPOT_TRADING in permissions + async def test_missing_trading_api_key_permissions(self): async with self.local_exchange_manager(identifiers_suffix="_READONLY"): await self.inner_test_missing_trading_api_key_permissions() @@ -327,8 +442,53 @@ async def inner_test_is_valid_account(self): assert isinstance(error, str) assert len(error) > 0 + + async def test_get_special_orders(self): + if self.SPECIAL_ORDER_TYPES_BY_EXCHANGE_ID: + async with self.local_exchange_manager(): + await self.inner_test_get_special_orders() + + async def inner_test_get_special_orders(self): + # open_orders = await self.get_open_orders(_symbols=["TAO/USDT"]) + # print(open_orders) # to print special orders when they are open + # return + for exchange_id, order_details in self.SPECIAL_ORDER_TYPES_BY_EXCHANGE_ID.items(): + symbol, info_key, info_type, expected_type, expected_side, expected_trigger_above = order_details + fetched_order = await self.exchange_manager.exchange.get_order(exchange_id, symbol=symbol) + assert fetched_order is not None + self._check_fetched_order_dicts([fetched_order]) + # ensure parsing order doesn't crash + parsed = personal_data.create_order_instance_from_raw(self.exchange_manager.trader, fetched_order) + assert isinstance(parsed, personal_data.Order) + + assert fetched_order[trading_enums.ExchangeConstantsOrderColumns.SYMBOL.value] == symbol + found_type = fetched_order[ccxt_constants.CCXT_INFO][info_key] + assert found_type == info_type, f"[{exchange_id}]: {found_type} != {info_type}" + parsed_type = fetched_order[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] + assert parsed_type == expected_type, f"[{exchange_id}]: {parsed_type} != {expected_type}" + assert fetched_order[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] == expected_side + if expected_trigger_above is None: + assert trading_enums.ExchangeConstantsOrderColumns.TRIGGER_ABOVE.value not in fetched_order + else: + parsed_trigger_above = fetched_order[trading_enums.ExchangeConstantsOrderColumns.TRIGGER_ABOVE.value] + assert parsed_trigger_above == expected_trigger_above, ( + f"[{exchange_id}]: {parsed_trigger_above} != {expected_trigger_above}" + ) + if isinstance(parsed, personal_data.LimitOrder): + assert parsed.trigger_above == parsed_trigger_above + if expected_type == trading_enums.TradeOrderType.LIMIT.value: + assert fetched_order[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] > 0 + elif expected_type == trading_enums.TradeOrderType.STOP_LOSS.value: + assert fetched_order[trading_enums.ExchangeConstantsOrderColumns.STOP_PRICE.value] > 0 + elif expected_type == trading_enums.TradeOrderType.UNSUPPORTED.value: + assert isinstance(parsed, personal_data.UnsupportedOrder) + else: + # ensure all cases are covered, otherwise there is a problem in order type parsing + assert expected_type == trading_enums.TradeOrderType.MARKET + async def test_create_and_cancel_limit_orders(self): async with self.local_exchange_manager(): + await self.inner_test_cancel_uncancellable_order() await self.inner_test_create_and_cancel_limit_orders() async def inner_test_create_and_cancel_limit_orders(self, symbol=None, settlement_currency=None, margin_type=None): @@ -396,6 +556,12 @@ async def inner_test_create_and_cancel_limit_orders(self, symbol=None, settlemen assert await self.order_not_in_open_orders(open_orders, buy_limit, symbol=symbol) assert await self.order_in_cancelled_orders(cancelled_orders, buy_limit, symbol=symbol) + async def inner_test_cancel_uncancellable_order(self): + if self.UNCANCELLABLE_ORDER_ID_SYMBOL_TYPE: + order_id, symbol, order_type = self.UNCANCELLABLE_ORDER_ID_SYMBOL_TYPE + with pytest.raises(trading_errors.ExchangeOrderCancelError): + await self.exchange_manager.exchange.cancel_order(order_id, symbol, order_type) + async def test_create_and_fill_market_orders(self): async with self.local_exchange_manager(): await self.inner_test_create_and_fill_market_orders() @@ -747,12 +913,12 @@ async def get_my_recent_trades(self, exchange_data=None): async def get_closed_orders(self, symbol=None): return await self.exchange_manager.exchange.get_closed_orders(symbol or self.SYMBOL) - async def get_cancelled_orders(self, exchange_data=None, force_fetch=False): + async def get_cancelled_orders(self, exchange_data=None, force_fetch=False, _symbols=None): if not force_fetch and not self.exchange_manager.exchange.SUPPORT_FETCHING_CANCELLED_ORDERS: # skipped return [] exchange_data = exchange_data or self.get_exchange_data() - return await exchanges_test_tools.get_cancelled_orders(self.exchange_manager, exchange_data) + return await exchanges_test_tools.get_cancelled_orders(self.exchange_manager, exchange_data, symbols=_symbols) async def check_require_closed_orders_from_recent_trades(self, symbol=None): if self.exchange_manager.exchange.REQUIRE_CLOSED_ORDERS_FROM_RECENT_TRADES: @@ -1088,9 +1254,9 @@ def get_order_price(self, price, is_above_price, symbol=None, price_diff=None): price * (decimal.Decimal(str(multiplier))) ) - async def get_open_orders(self, exchange_data=None): + async def get_open_orders(self, exchange_data=None, _symbols=None): exchange_data = exchange_data or self.get_exchange_data() - orders = await exchanges_test_tools.get_open_orders(self.exchange_manager, exchange_data) + orders = await exchanges_test_tools.get_open_orders(self.exchange_manager, exchange_data, symbols=_symbols) self.check_duplicate(orders) self._check_fetched_order_dicts(orders) return orders @@ -1362,7 +1528,7 @@ def get_exchange_data(self, symbol=None, all_symbols=None) -> exchange_data_impo exchange_details={"name": self.exchange_manager.exchange_name}, markets=[ { - "id": s, "symbol": s, "info": {}, "time_frame": "1h", + "id": s, "symbol": s, "info": {}, "time_frame": self.TIME_FRAME, "close": [0], "open": [0], "high": [0], "low": [0], "volume": [0], "time": [0] # todo } for s in _symbols @@ -1377,3 +1543,7 @@ def market_filter(market): ) return market_filter + + +def _get_encoded_value(raw) -> str: + return commons_configuration.encrypt(raw).decode() diff --git a/additional_tests/exchanges_tests/abstract_authenticated_future_exchange_tester.py b/additional_tests/exchanges_tests/abstract_authenticated_future_exchange_tester.py index d6ab6805d..1e23387e3 100644 --- a/additional_tests/exchanges_tests/abstract_authenticated_future_exchange_tester.py +++ b/additional_tests/exchanges_tests/abstract_authenticated_future_exchange_tester.py @@ -15,12 +15,12 @@ # License along with OctoBot. If not, see . import contextlib import decimal -import ccxt import pytest import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.errors as trading_errors +import trading_backend.enums from additional_tests.exchanges_tests import abstract_authenticated_exchange_tester @@ -33,7 +33,6 @@ class AbstractAuthenticatedFutureExchangeTester( INVERSE_SYMBOL = None MIN_PORTFOLIO_SIZE = 2 # ensure fetching currency for linear and inverse SUPPORTS_GET_LEVERAGE = True - SUPPORTS_SET_LEVERAGE = True SUPPORTS_EMPTY_POSITION_SET_MARGIN_TYPE = True async def test_get_empty_linear_and_inverse_positions(self): @@ -85,9 +84,12 @@ async def inner_test_get_and_set_leverage(self): assert origin_leverage != trading_constants.ZERO if self.SUPPORTS_GET_LEVERAGE: assert origin_leverage == await self.get_leverage() - if not self.SUPPORTS_SET_LEVERAGE: - return new_leverage = origin_leverage + 1 + if not self.exchange_manager.exchange.UPDATE_LEVERAGE_FROM_API: + # can't set from api: make sure of that + with pytest.raises(trading_errors.NotSupported): + await self.exchange_manager.exchange.connector.set_symbol_leverage(self.SYMBOL, float(new_leverage)) + return await self.set_leverage(new_leverage) await self._check_margin_type_and_leverage(origin_margin_type, new_leverage) # did not change margin type # change leverage back to origin value @@ -190,6 +192,10 @@ async def _inner_test_create_and_cancel_limit_orders_for_margin_type( symbol=self.INVERSE_SYMBOL, settlement_currency=self.ORDER_CURRENCY, margin_type=margin_type ) + def _ensure_required_permissions(self, permissions): + super()._ensure_required_permissions(permissions) + assert trading_backend.enums.APIKeyRights.FUTURES_TRADING in permissions + async def inner_test_create_and_fill_market_orders(self): portfolio = await self.get_portfolio() position = await self.get_position() diff --git a/additional_tests/exchanges_tests/test_ascendex.py b/additional_tests/exchanges_tests/test_ascendex.py index f43b2800d..845e55cdc 100644 --- a/additional_tests/exchanges_tests/test_ascendex.py +++ b/additional_tests/exchanges_tests/test_ascendex.py @@ -40,6 +40,9 @@ async def test_get_portfolio_with_market_filter(self): # pass if not implemented pass + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -63,6 +66,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_binance.py b/additional_tests/exchanges_tests/test_binance.py index 7755dcc6a..f93ea0a30 100644 --- a/additional_tests/exchanges_tests/test_binance.py +++ b/additional_tests/exchanges_tests/test_binance.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import pytest +import octobot_trading.enums from additional_tests.exchanges_tests import abstract_authenticated_exchange_tester @@ -41,6 +42,38 @@ class TestBinanceAuthenticatedExchange( IS_BROKER_ENABLED_ACCOUNT = False IS_AUTHENTICATED_REQUEST_CHECK_AVAILABLE = True # set True when is_authenticated_request is implemented + SPECIAL_ORDER_TYPES_BY_EXCHANGE_ID: dict[ + str, ( + str, # symbol + str, # order type key in 'info' dict + str, # order type found in 'info' dict + str, # parsed trading_enums.TradeOrderType + str, # parsed trading_enums.TradeOrderSide + bool, # trigger above (on higher price than order price) + ) + ] = { + "6799804660": ( + "BNB/USDT", "type", "TAKE_PROFIT", + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.SELL.value, True + ), + '6799810041': ( + "BNB/USDT", "type", "STOP_LOSS", + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.SELL.value, False + ), + '6799798838': ( + "BNB/USDT", "type", "TAKE_PROFIT_LIMIT", + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.SELL.value, True + ), + '6799795001': ( + "BNB/USDT", "type", "STOP_LOSS_LIMIT", + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.SELL.value, False + ), + } # stop loss / take profit and other special order types to be successfully parsed + # details of an order that exists but can"t be cancelled + UNCANCELLABLE_ORDER_ID_SYMBOL_TYPE: tuple[str, str, octobot_trading.enums.TraderOrderType] = ( + "6799798838", "BNB/USDT", octobot_trading.enums.TraderOrderType.BUY_LIMIT.value + ) + async def test_get_portfolio(self): await super().test_get_portfolio() @@ -48,6 +81,9 @@ async def test_get_portfolio(self): async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): await super().test_get_account_id() @@ -69,6 +105,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_binance_futures.py b/additional_tests/exchanges_tests/test_binance_futures.py index 57cc09cde..f3ff05e8c 100644 --- a/additional_tests/exchanges_tests/test_binance_futures.py +++ b/additional_tests/exchanges_tests/test_binance_futures.py @@ -15,6 +15,7 @@ # License along with OctoBot. If not, see . import pytest +import octobot_trading.enums from additional_tests.exchanges_tests import abstract_authenticated_future_exchange_tester, \ abstract_authenticated_exchange_tester @@ -38,6 +39,38 @@ class TestBinanceFuturesAuthenticatedExchange( EXPECTED_QUOTE_MIN_ORDER_SIZE = 200 # min quote value of orders to create (used to check market status parsing) IS_AUTHENTICATED_REQUEST_CHECK_AVAILABLE = True # set True when is_authenticated_request is implemented + SPECIAL_ORDER_TYPES_BY_EXCHANGE_ID: dict[ + str, ( + str, # symbol + str, # order type key in 'info' dict + str, # order type found in 'info' dict + str, # parsed trading_enums.TradeOrderType + str, # parsed trading_enums.TradeOrderSide + bool, # trigger above (on higher price than order price) + ) + ] = { + "4075521283": ( + "BTC/USDT:USDT", "type", "TAKE_PROFIT_MARKET", + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.SELL.value, True + ), + '622529': ( + "BTC/USDC:USDC", "type", "STOP_MARKET", + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.SELL.value, False + ), + '4076521927': ( + "BTC/USDT:USDT", "type", "TAKE_PROFIT", + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.BUY.value, False + ), + '4076521976': ( + "BTC/USDT:USDT", "type", "STOP", + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.SELL.value, False + ), + } # stop loss / take profit and other special order types to be successfully parsed + # details of an order that exists but can"t be cancelled + UNCANCELLABLE_ORDER_ID_SYMBOL_TYPE: tuple[str, str, octobot_trading.enums.TraderOrderType] = ( + "4076521927", "BTC/USDT:USDT", octobot_trading.enums.TraderOrderType.BUY_LIMIT.value + ) + async def _set_account_types(self, account_types): # todo remove this and use both types when exchange-side multi portfolio is enabled self.exchange_manager.exchange._futures_account_types = account_types @@ -48,6 +81,9 @@ async def test_get_portfolio(self): async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() # can have small variations failing the test when positions are open + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): await super().test_get_account_id() @@ -86,6 +122,9 @@ async def test_get_and_set_leverage(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_bingx.py b/additional_tests/exchanges_tests/test_bingx.py index 8d402a4d1..dce470c2e 100644 --- a/additional_tests/exchanges_tests/test_bingx.py +++ b/additional_tests/exchanges_tests/test_bingx.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import pytest +import octobot_trading.enums from additional_tests.exchanges_tests import abstract_authenticated_exchange_tester @@ -38,12 +39,47 @@ class TestBingxAuthenticatedExchange( VALID_ORDER_ID = "1812980957928929280" + SPECIAL_ORDER_TYPES_BY_EXCHANGE_ID: dict[ + str, ( + str, # symbol + str, # order type key in 'info' dict + str, # order type found in 'info' dict + str, # parsed trading_enums.TradeOrderType + str, # parsed trading_enums.TradeOrderSide + bool, # trigger above (on higher price than order price) + ) + ] = { + "1877004154170146816": ( + "TAO/USDT", "type", "TAKE_STOP_MARKET", + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.SELL.value, False + ), + '1877004191864356864': ( + "TAO/USDT", "type", "TAKE_STOP_MARKET", + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.SELL.value, True + ), + '1877004220704391168': ( + "TAO/USDT", "type", "TAKE_STOP_LIMIT", + octobot_trading.enums.TradeOrderType.UNSUPPORTED.value, octobot_trading.enums.TradeOrderSide.SELL.value, None + ), + '1877004292053696512': ( + "TAO/USDT", "type", "TAKE_STOP_LIMIT", + octobot_trading.enums.TradeOrderType.UNSUPPORTED.value, octobot_trading.enums.TradeOrderSide.SELL.value, None + ), + } # stop loss / take profit and other special order types to be successfully parsed + # details of an order that exists but can"t be cancelled + UNCANCELLABLE_ORDER_ID_SYMBOL_TYPE: tuple[str, str, octobot_trading.enums.TraderOrderType] = ( + "1877004292053696512", "TAO/USDT", octobot_trading.enums.TraderOrderType.SELL_LIMIT.value + ) + async def test_get_portfolio(self): await super().test_get_portfolio() async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): await super().test_get_account_id() @@ -65,6 +101,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_bitget.py b/additional_tests/exchanges_tests/test_bitget.py index 33c03e91d..63f46c6c3 100644 --- a/additional_tests/exchanges_tests/test_bitget.py +++ b/additional_tests/exchanges_tests/test_bitget.py @@ -42,6 +42,9 @@ async def test_get_portfolio_with_market_filter(self): # pass if not implemented pass + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -65,6 +68,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_bitmart.py b/additional_tests/exchanges_tests/test_bitmart.py index f1eacab34..7c6700ac2 100644 --- a/additional_tests/exchanges_tests/test_bitmart.py +++ b/additional_tests/exchanges_tests/test_bitmart.py @@ -40,6 +40,9 @@ async def test_get_portfolio(self): async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -63,6 +66,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_bybit.py b/additional_tests/exchanges_tests/test_bybit.py index 1eb4dde3c..b893070f4 100644 --- a/additional_tests/exchanges_tests/test_bybit.py +++ b/additional_tests/exchanges_tests/test_bybit.py @@ -44,6 +44,9 @@ async def test_get_portfolio_with_market_filter(self): # pass if not implemented pass + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -67,6 +70,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_bybit_futures.py b/additional_tests/exchanges_tests/test_bybit_futures.py index 950b07c0b..a90efa93a 100644 --- a/additional_tests/exchanges_tests/test_bybit_futures.py +++ b/additional_tests/exchanges_tests/test_bybit_futures.py @@ -47,6 +47,9 @@ async def test_get_portfolio_with_market_filter(self): # pass if not implemented pass + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -79,6 +82,9 @@ async def test_get_and_set_leverage(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_coinbase.py b/additional_tests/exchanges_tests/test_coinbase.py index 7b3790f06..6e484f29a 100644 --- a/additional_tests/exchanges_tests/test_coinbase.py +++ b/additional_tests/exchanges_tests/test_coinbase.py @@ -15,6 +15,7 @@ # License along with OctoBot. If not, see . import pytest +import octobot_trading.enums from additional_tests.exchanges_tests import abstract_authenticated_exchange_tester # All test coroutines will be treated as marked. @@ -29,7 +30,7 @@ class TestCoinbaseAuthenticatedExchange( ORDER_CURRENCY = "ADA" SETTLEMENT_CURRENCY = "BTC" SYMBOL = f"{ORDER_CURRENCY}/{SETTLEMENT_CURRENCY}" - ORDER_SIZE = 5 # % of portfolio to include in test orders + ORDER_SIZE = 25 # % of portfolio to include in test orders CONVERTS_ORDER_SIZE_BEFORE_PUSHING_TO_EXCHANGES = True VALID_ORDER_ID = "8bb80a81-27f7-4415-aa50-911ea46d841c" USE_ORDER_OPERATION_TO_CHECK_API_KEY_RIGHTS = True # set True when api key rights can't be checked using a @@ -37,12 +38,43 @@ class TestCoinbaseAuthenticatedExchange( IS_BROKER_ENABLED_ACCOUNT = False IS_AUTHENTICATED_REQUEST_CHECK_AVAILABLE = True # set True when is_authenticated_request is implemented + SPECIAL_ORDER_TYPES_BY_EXCHANGE_ID: dict[ + str, ( + str, # symbol + str, # order type key in 'info' dict + str, # order type found in 'info' dict + str, # parsed trading_enums.TradeOrderType + str, # parsed trading_enums.TradeOrderSide + bool, # trigger above (on higher price than order price) + ) + ] = { + '7e03c745-7340-49ef-8af1-b8f7fe431c8a': ( + "BTC/EUR", "order_type", "STOP_LIMIT", # sell at a lower price + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.SELL.value, False + ), + '1e2f0918-5728-4c68-b8f4-6fd804396248': ( + "ETH/BTC", "order_type", "STOP_LIMIT", # buy at a higher price + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.BUY.value, True + ), + 'f4016e50-1f0b-4caa-abe5-1ec00af18be9': ( + "ETH/BTC", "order_type", "STOP_LIMIT", # buy at a lower price + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.BUY.value, False + ), + } # stop loss / take profit and other special order types to be successfully parsed + # details of an order that exists but can"t be cancelled + UNCANCELLABLE_ORDER_ID_SYMBOL_TYPE: tuple[str, str, octobot_trading.enums.TraderOrderType] = ( + "f4016e50-1f0b-4caa-abe5-1ec00af18be9", "ETH/BTC", octobot_trading.enums.TraderOrderType.BUY_LIMIT.value + ) + async def test_get_portfolio(self): await super().test_get_portfolio() async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): await super().test_get_account_id() @@ -64,6 +96,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_coinex.py b/additional_tests/exchanges_tests/test_coinex.py index d4fa0a9c1..8fa9c5abe 100644 --- a/additional_tests/exchanges_tests/test_coinex.py +++ b/additional_tests/exchanges_tests/test_coinex.py @@ -39,6 +39,9 @@ async def test_get_portfolio(self): async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -62,6 +65,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_cryptocom.py b/additional_tests/exchanges_tests/test_cryptocom.py index 7963937b9..23ca404de 100644 --- a/additional_tests/exchanges_tests/test_cryptocom.py +++ b/additional_tests/exchanges_tests/test_cryptocom.py @@ -40,6 +40,9 @@ async def test_get_portfolio_with_market_filter(self): # pass if not implemented pass + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -63,6 +66,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_gateio.py b/additional_tests/exchanges_tests/test_gateio.py index d5cf41b7e..009813fee 100644 --- a/additional_tests/exchanges_tests/test_gateio.py +++ b/additional_tests/exchanges_tests/test_gateio.py @@ -41,6 +41,9 @@ async def test_get_portfolio(self): async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -65,6 +68,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_hollaex.py b/additional_tests/exchanges_tests/test_hollaex.py index be63620b9..07f31c107 100644 --- a/additional_tests/exchanges_tests/test_hollaex.py +++ b/additional_tests/exchanges_tests/test_hollaex.py @@ -44,6 +44,9 @@ async def test_get_portfolio(self): async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): await super().test_get_account_id() @@ -65,6 +68,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_htx.py b/additional_tests/exchanges_tests/test_htx.py index a22c17725..43923ff28 100644 --- a/additional_tests/exchanges_tests/test_htx.py +++ b/additional_tests/exchanges_tests/test_htx.py @@ -41,6 +41,9 @@ async def test_get_portfolio_with_market_filter(self): # pass if not implemented pass + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -64,6 +67,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_kucoin.py b/additional_tests/exchanges_tests/test_kucoin.py index ef48cfbe7..3295f5392 100644 --- a/additional_tests/exchanges_tests/test_kucoin.py +++ b/additional_tests/exchanges_tests/test_kucoin.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import pytest +import octobot_trading.enums from additional_tests.exchanges_tests import abstract_authenticated_exchange_tester @@ -36,15 +37,53 @@ class TestKucoinAuthenticatedExchange( VALID_ORDER_ID = "6617e84c5c1e0000083c71f7" IS_AUTHENTICATED_REQUEST_CHECK_AVAILABLE = True # set True when is_authenticated_request is implemented + SPECIAL_ORDER_TYPES_BY_EXCHANGE_ID: dict[ + str, ( + str, # symbol + str, # order type key in 'info' dict + str, # order type found in 'info' dict + str, # parsed trading_enums.TradeOrderType + str, # parsed trading_enums.TradeOrderSide + bool, # trigger above (on higher price than order price) + ) + ] = { + "vs93gpruc6ikekiv003o48ci": ( + "BTC/USDT", "type", "market", + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.BUY.value, False + ), + 'vs93gpruc6n45taf003lr546': ( + "BTC/USDT", "type", "market", + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.BUY.value, True + ), + 'vs93gpruc69s00cs003tat0g': ( + "BTC/USDT", "type", "limit", + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.BUY.value, False + ), + 'vs93gpruc5q45taf003lr545': ( + "BTC/USDT", "type", "limit", + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.BUY.value, True + ), + } # stop loss / take profit and other special order types to be successfully parsed + # details of an order that exists but can"t be cancelled + UNCANCELLABLE_ORDER_ID_SYMBOL_TYPE: tuple[str, str, octobot_trading.enums.TraderOrderType] = ( + "vs93gpruc69s00cs003tat0g", "BTC/USDT", octobot_trading.enums.TraderOrderType.BUY_LIMIT.value + ) + async def test_get_portfolio(self): await super().test_get_portfolio() async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_kucoin_futures.py b/additional_tests/exchanges_tests/test_kucoin_futures.py index a26b66a5d..8727b6f9e 100644 --- a/additional_tests/exchanges_tests/test_kucoin_futures.py +++ b/additional_tests/exchanges_tests/test_kucoin_futures.py @@ -15,6 +15,7 @@ # License along with OctoBot. If not, see . import pytest +import octobot_trading.enums from additional_tests.exchanges_tests import abstract_authenticated_future_exchange_tester # All test coroutines will be treated as marked. @@ -33,7 +34,6 @@ class TestKucoinFuturesAuthenticatedExchange( INVERSE_SYMBOL = f"{ORDER_CURRENCY}/USD:{ORDER_CURRENCY}" ORDER_SIZE = 5 # % of portfolio to include in test orders SUPPORTS_GET_LEVERAGE = False - SUPPORTS_SET_LEVERAGE = False USE_ORDER_OPERATION_TO_CHECK_API_KEY_RIGHTS = True VALID_ORDER_ID = "6617e84c5c1e0000083c71f7" EXPECT_MISSING_FEE_IN_CANCELLED_ORDERS = False @@ -41,12 +41,47 @@ class TestKucoinFuturesAuthenticatedExchange( EXPECTED_QUOTE_MIN_ORDER_SIZE = 40 EXPECT_BALANCE_FILTER_BY_MARKET_STATUS = True + SPECIAL_ORDER_TYPES_BY_EXCHANGE_ID: dict[ + str, ( + str, # symbol + str, # order type key in 'info' dict + str, # order type found in 'info' dict + str, # parsed trading_enums.TradeOrderType + str, # parsed trading_enums.TradeOrderSide + bool, # trigger above (on higher price than order price) + ) + ] = { + "266424660906831872": ( + "ETH/USDT:USDT", "type", "market", + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.BUY.value, False + ), + '266424746172764160': ( + "ETH/USDT:USDT", "type", "market", + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.SELL.value, False + ), + '266424798085668865': ( + "ETH/USDT:USDT", "type", "limit", + octobot_trading.enums.TradeOrderType.LIMIT.value, octobot_trading.enums.TradeOrderSide.BUY.value, False + ), + '266424826044899328': ( + "ETH/USDT:USDT", "type", "limit", + octobot_trading.enums.TradeOrderType.STOP_LOSS.value, octobot_trading.enums.TradeOrderSide.SELL.value, False + ), + } # stop loss / take profit and other special order types to be successfully parsed + # details of an order that exists but can"t be cancelled + UNCANCELLABLE_ORDER_ID_SYMBOL_TYPE: tuple[str, str, octobot_trading.enums.TraderOrderType] = ( + "266424798085668865", "ETH/USDT:USDT", octobot_trading.enums.TraderOrderType.BUY_LIMIT.value + ) + async def test_get_portfolio(self): await super().test_get_portfolio() async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() # can have small variations failing the test when positions are open + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): await super().test_get_account_id() @@ -77,6 +112,9 @@ async def test_get_and_set_leverage(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): # todo test cross position order creation (kucoin param) at next ccxt update (will support set margin type) await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_mexc.py b/additional_tests/exchanges_tests/test_mexc.py index a47659703..14975241b 100644 --- a/additional_tests/exchanges_tests/test_mexc.py +++ b/additional_tests/exchanges_tests/test_mexc.py @@ -28,12 +28,15 @@ class TestMEXCAuthenticatedExchange( EXCHANGE_NAME = "mexc" EXCHANGE_TENTACLE_NAME = "MEXC" ORDER_CURRENCY = "BTC" - SETTLEMENT_CURRENCY = "USDC" + SETTLEMENT_CURRENCY = "USDT" SYMBOL = f"{ORDER_CURRENCY}/{SETTLEMENT_CURRENCY}" ORDER_SIZE = 30 # % of portfolio to include in test orders CONVERTS_ORDER_SIZE_BEFORE_PUSHING_TO_EXCHANGES = True CANCELLED_ORDERS_IN_CLOSED_ORDERS = True EXPECT_MISSING_FEE_IN_CANCELLED_ORDERS = False + IS_ACCOUNT_ID_AVAILABLE = False + USE_ORDER_OPERATION_TO_CHECK_API_KEY_RIGHTS = True + USED_TO_HAVE_UNTRADABLE_SYMBOL = True async def test_get_portfolio(self): await super().test_get_portfolio() @@ -41,9 +44,11 @@ async def test_get_portfolio(self): async def test_get_portfolio_with_market_filter(self): await super().test_get_portfolio_with_market_filter() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): - # pass if not implemented - pass + await super().test_get_account_id() async def test_is_authenticated_request(self): await super().test_is_authenticated_request() @@ -52,11 +57,10 @@ async def test_invalid_api_key_error(self): await super().test_invalid_api_key_error() async def test_get_api_key_permissions(self): - # pass if not implemented - pass + await super().test_get_api_key_permissions() async def test_missing_trading_api_key_permissions(self): - pass + await super().test_missing_trading_api_key_permissions() async def test_get_not_found_order(self): await super().test_get_not_found_order() @@ -64,6 +68,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() @@ -80,7 +87,7 @@ async def test_get_cancelled_orders(self): await super().test_get_cancelled_orders() async def test_create_and_cancel_stop_orders(self): - # pass if not implemented + # NOT SUPPORTED by MEXC API (10/01/25) pass async def test_edit_limit_order(self): diff --git a/additional_tests/exchanges_tests/test_okx.py b/additional_tests/exchanges_tests/test_okx.py index 632cceaf2..db4f85c1c 100644 --- a/additional_tests/exchanges_tests/test_okx.py +++ b/additional_tests/exchanges_tests/test_okx.py @@ -45,12 +45,18 @@ async def test_get_portfolio_with_market_filter(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() async def test_get_account_id(self): await super().test_get_account_id() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_is_authenticated_request(self): await super().test_is_authenticated_request() diff --git a/additional_tests/exchanges_tests/test_okx_futures.py b/additional_tests/exchanges_tests/test_okx_futures.py index 656efb7a9..fd47c3936 100644 --- a/additional_tests/exchanges_tests/test_okx_futures.py +++ b/additional_tests/exchanges_tests/test_okx_futures.py @@ -46,6 +46,9 @@ async def test_get_portfolio_with_market_filter(self): async def test_get_account_id(self): await super().test_get_account_id() + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_is_authenticated_request(self): await super().test_is_authenticated_request() @@ -73,6 +76,9 @@ async def test_get_and_set_leverage(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/exchanges_tests/test_phemex.py b/additional_tests/exchanges_tests/test_phemex.py index b570e3bd8..6d2ba7f84 100644 --- a/additional_tests/exchanges_tests/test_phemex.py +++ b/additional_tests/exchanges_tests/test_phemex.py @@ -39,6 +39,9 @@ async def test_get_portfolio_with_market_filter(self): # pass if not implemented pass + async def test_untradable_symbols(self): + await super().test_untradable_symbols() + async def test_get_account_id(self): # pass if not implemented pass @@ -62,6 +65,9 @@ async def test_get_not_found_order(self): async def test_is_valid_account(self): await super().test_is_valid_account() + async def test_get_special_orders(self): + await super().test_get_special_orders() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() diff --git a/additional_tests/supabase_backend_tests/test_storage.py b/additional_tests/supabase_backend_tests/test_storage.py index ab73b3b2f..45f373f13 100644 --- a/additional_tests/supabase_backend_tests/test_storage.py +++ b/additional_tests/supabase_backend_tests/test_storage.py @@ -28,14 +28,22 @@ async def test_upload_asset(admin_client): asset_name = "test_upload_asset" asset_bucket = "product-images" await admin_client.remove_asset(asset_bucket, asset_name) # remove asset if exists - uploaded_asset_id = await admin_client.upload_asset(asset_bucket, asset_name, asset_content) + uploaded_asset_path = await admin_client.upload_asset(asset_bucket, asset_name, asset_content) assets = await admin_client.list_assets(asset_bucket) - asset_by_id = { - asset["id"]: asset + asset_by_name = { + asset["name"]: asset for asset in assets } - assert uploaded_asset_id in asset_by_id - assert asset_by_id[uploaded_asset_id]["name"] == asset_name + assert uploaded_asset_path in asset_by_name + assert asset_by_name[uploaded_asset_path]["name"] == asset_name await admin_client.remove_asset(asset_bucket, asset_name) + + assets = await admin_client.list_assets(asset_bucket) + asset_by_name = { + asset["name"]: asset + for asset in assets + } + # asset is removed + assert uploaded_asset_path not in asset_by_name diff --git a/octobot/community/authentication.py b/octobot/community/authentication.py index 3b40136d4..523e88104 100644 --- a/octobot/community/authentication.py +++ b/octobot/community/authentication.py @@ -607,6 +607,8 @@ async def _initialize_account(self, minimal=False, fetch_private_data=True): await self._ensure_init_community_feed() except authentication.AuthenticationError as err: self.logger.info(f"Login aborted: no authenticated session: {err}") + if await self.has_login_info(): + await self.logout() except authentication.UnavailableError as e: self.logger.exception(e, True, f"Error when fetching community data, " f"please check your internet connection.") @@ -803,7 +805,8 @@ async def _auth_handler(self): except authentication.FailedAuthentication as e: if should_warn: self.logger.warning(f"Invalid authentication details, please re-authenticate. {e}") - await self.logout() + if await self.has_login_info(): + await self.logout() except authentication.UnavailableError: raise except Exception as e: diff --git a/octobot/community/errors_upload/sentry_aiohttp_transport.py b/octobot/community/errors_upload/sentry_aiohttp_transport.py index 1a77a4db9..83ddcaf5f 100644 --- a/octobot/community/errors_upload/sentry_aiohttp_transport.py +++ b/octobot/community/errors_upload/sentry_aiohttp_transport.py @@ -17,8 +17,6 @@ import typing import asyncio import aiohttp -import io -import gzip import sentry_sdk import sentry_sdk.consts @@ -33,6 +31,8 @@ def __init__( self, options: typing.Dict[str, typing.Any] ): super().__init__(options) + # WARNING: override default "br" value: not supported by Glitchtip yet + self._compression_algo = "gzip" # use custom async worker instead of default sentry thread worker # does not support proxies, at least for now self._worker = AiohttpWorker(queue_size=options["transport_queue_size"]) @@ -75,7 +75,7 @@ def record_loss(reason: str) -> None: pass elif response.status >= 300 or response.status < 200: - sentry_sdk.utils.logger.error( + logging.getLogger(self.__class__.__name__).warning( "Unexpected status code: %s (body: %s)", response.status, await response.text(), @@ -121,14 +121,7 @@ async def _async_send_envelope( if client_report_item is not None: envelope.items.append(client_report_item) - body = io.BytesIO() - if self._compresslevel == 0: - envelope.serialize_into(body) - else: - with gzip.GzipFile( - fileobj=body, mode="w", compresslevel=self._compresslevel - ) as f: - envelope.serialize_into(f) + content_encoding, body = self._serialize_envelope(envelope) assert self.parsed_dsn is not None sentry_sdk.utils.logger.debug( @@ -141,8 +134,8 @@ async def _async_send_envelope( headers = { "Content-Type": "application/x-sentry-envelope", } - if self._compresslevel > 0: - headers["Content-Encoding"] = "gzip" + if content_encoding: + headers["Content-Encoding"] = content_encoding await self._async_send_request( body.getvalue(), @@ -163,13 +156,11 @@ def capture_event( def capture_envelope( self, envelope: sentry_sdk.envelope.Envelope ) -> None: - hub = self.hub_cls.current async def send_envelope_wrapper() -> None: - with hub: # pylint: disable=not-context-manager - with sentry_sdk.utils.capture_internal_exceptions(): - await self._async_send_envelope(envelope) - self._flush_client_reports() + with sentry_sdk.utils.capture_internal_exceptions(): + await self._async_send_envelope(envelope) + self._flush_client_reports() if not self._worker.submit(send_envelope_wrapper): self.on_dropped_event("full_queue") @@ -225,8 +216,7 @@ def full(self) -> bool: return len(self.call_tasks) > self._queue_size def flush(self, timeout: float, callback=None) -> None: - sentry_sdk.utils.logger.debug("background worker got flush request") - sentry_sdk.utils.logger.debug("background worker flush ignored") + sentry_sdk.utils.logger.debug("Custom background worker got flush request, ignored") async def _async_call(self, callback): try: diff --git a/octobot/community/models/community_public_data.py b/octobot/community/models/community_public_data.py index bb009dd99..3d289c527 100644 --- a/octobot/community/models/community_public_data.py +++ b/octobot/community/models/community_public_data.py @@ -23,8 +23,9 @@ def __init__(self): self.products = _DataElement({}, False) def set_products(self, products): - self.products.value = {product[enums.ProductKeys.ID.value]: product for product in products} - self.products.fetched = True + if products: + self.products.value = {product[enums.ProductKeys.ID.value]: product for product in products} + self.products.fetched = True def get_product_slug(self, product_id): return self.products.value[product_id][enums.ProductKeys.SLUG.value] diff --git a/octobot/community/models/formatters.py b/octobot/community/models/formatters.py index e7bff3f88..092029c14 100644 --- a/octobot/community/models/formatters.py +++ b/octobot/community/models/formatters.py @@ -123,6 +123,9 @@ def format_orders(orders: list, exchange_name: str) -> list: trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value], backend_enums.OrderKeys.SIDE.value: storage_order[trading_constants.STORAGE_ORIGIN_VALUE][ trading_enums.ExchangeConstantsOrderColumns.SIDE.value], + backend_enums.OrderKeys.TRIGGER_ABOVE.value: storage_order[trading_constants.STORAGE_ORIGIN_VALUE].get( + trading_enums.ExchangeConstantsOrderColumns.TRIGGER_ABOVE.value + ), backend_enums.OrderKeys.EXCHANGE_ID.value: storage_order[trading_constants.STORAGE_ORIGIN_VALUE][ trading_enums.ExchangeConstantsOrderColumns.EXCHANGE_ID.value], backend_enums.OrderKeys.CHAINED.value: format_orders( diff --git a/octobot/community/supabase_backend/community_supabase_client.py b/octobot/community/supabase_backend/community_supabase_client.py index 3e15042f3..ceac74dc0 100644 --- a/octobot/community/supabase_backend/community_supabase_client.py +++ b/octobot/community/supabase_backend/community_supabase_client.py @@ -122,7 +122,7 @@ async def sign_up(self, email: str, password: str) -> None: async def sign_out(self, options: gotrue.types.SignOutOptions) -> None: try: await self.auth.sign_out(options) - except gotrue.errors.AuthApiError: + except (postgrest.exceptions.APIError, gotrue.errors.AuthApiError): pass def _requires_email_validation(self, user: gotrue.types.User) -> bool: @@ -137,7 +137,7 @@ async def restore_session(self): async def refresh_session(self, refresh_token: typing.Union[str, None] = None): try: await self.auth.refresh_session(refresh_token=refresh_token) - except gotrue.errors.AuthError as err: + except (postgrest.exceptions.APIError, gotrue.errors.AuthError) as err: raise authentication.AuthenticationError(f"Community auth error: {err}") from err async def sign_in_with_otp_token(self, token): @@ -229,16 +229,21 @@ async def fetch_checkout_url(self, payment_method: str, redirect_url: str) -> di return json.loads(json.loads(resp)["message"]) async def fetch_bot(self, bot_id) -> dict: - try: - # https://postgrest.org/en/stable/references/api/resource_embedding.html#hint-disambiguation - return (await self.table("bots").select("*,bot_deployment:bot_deployments!bots_current_deployment_id_fkey(*)").eq( - enums.BotKeys.ID.value, bot_id - ).execute()).data[0] - except IndexError: - raise errors.BotNotFoundError(f"Can't find bot with id: {bot_id}") + with jwt_expired_auth_raiser(): + try: + # https://postgrest.org/en/stable/references/api/resource_embedding.html#hint-disambiguation + return (await self.table("bots").select("*,bot_deployment:bot_deployments!bots_current_deployment_id_fkey(*)").eq( + enums.BotKeys.ID.value, bot_id + ).execute()).data[0] + except IndexError: + raise errors.BotNotFoundError(f"Can't find bot with id: {bot_id}") async def fetch_bots(self) -> list: - return (await self.table("bots").select("*,bot_deployment:bot_deployments!bots_current_deployment_id_fkey!inner(*)").execute()).data + with jwt_expired_auth_raiser(): + return ( + await self.table("bots").select( + "*,bot_deployment:bot_deployments!bots_current_deployment_id_fkey!inner(*)" + ).execute()).data async def create_bot(self, deployment_type: enums.DeploymentTypes) -> dict: created_bot = (await self.table("bots").insert({ @@ -305,19 +310,23 @@ async def fetch_startup_info(self, bot_id) -> dict: return resp.data[0] async def fetch_products(self, category_types: list[str]) -> list: - return ( - await self.table("products").select( - "*," - "category:product_categories!inner(slug, name_translations, type, metadata)," - "results:product_results!products_current_result_id_fkey(" - " profitability," - " reference_market_profitability" - ")" - ).eq( - enums.ProductKeys.VISIBILITY.value, "public" - ).in_("category.type", category_types) - .execute() - ).data + try: + return ( + await self.table("products").select( + "*," + "category:product_categories!inner(slug, name_translations, type, metadata)," + "results:product_results!products_current_result_id_fkey(" + " profitability," + " reference_market_profitability" + ")" + ).eq( + enums.ProductKeys.VISIBILITY.value, "public" + ).in_("category.type", category_types) + .execute() + ).data + except postgrest.exceptions.APIError as err: + commons_logging.get_logger(__name__).error(f"Error when fetching products: {err}") + return [] async def fetch_subscribed_products_urls(self) -> list: resp = await self.rpc("get_subscribed_products_urls").execute() @@ -428,9 +437,10 @@ async def fetch_bot_profile_data(self, bot_config_id: str) -> commons_profiles.P ")" ).eq(enums.BotConfigKeys.ID.value, bot_config_id).execute()).data[0] try: - profile_data = commons_profiles.ProfileData.from_dict( - bot_config["product_config"][enums.ProfileConfigKeys.CONFIG.value] - ) + profile_config = bot_config["product_config"][enums.ProfileConfigKeys.CONFIG.value] + if not profile_config: + raise TypeError(f"product_config.config is '{profile_config}'") + profile_data = commons_profiles.ProfileData.from_dict(profile_config) except (TypeError, KeyError) as err: raise errors.InvalidBotConfigError(f"Missing bot product config: {err} ({err.__class__.__name__})") from err profile_data.profile_details.name = bot_config["product_config"].get("product", {}).get( @@ -862,7 +872,7 @@ async def upload_asset(self, bucket_name: str, asset_name: str, content: typing. Not implemented for authenticated users """ result = await self.storage.from_(bucket_name).upload(asset_name, content) - return result.json()["Id"] + return result.path async def list_assets(self, bucket_name: str) -> list[dict[str, str]]: """ @@ -870,11 +880,11 @@ async def list_assets(self, bucket_name: str) -> list[dict[str, str]]: """ return await self.storage.from_(bucket_name).list() - async def remove_asset(self, bucket_name: str, asset_name: str) -> None: + async def remove_asset(self, bucket_name: str, asset_path: str) -> None: """ Not implemented for authenticated users """ - await self.storage.from_(bucket_name).remove(asset_name) + await self.storage.from_(bucket_name).remove([asset_path]) async def send_signal(self, table, product_id: str, signal: str): return (await self.table(table).insert({ @@ -934,4 +944,14 @@ async def aclose(self): except RuntimeError: # happens when the event loop is closed already pass - self.production_anon_client = None \ No newline at end of file + self.production_anon_client = None + + +@contextlib.contextmanager +def jwt_expired_auth_raiser(): + try: + yield + except postgrest.exceptions.APIError as err: + if "JWT expired" in str(err): + raise authentication.AuthenticationError(f"Please re-login to your OctoBot account: {err}") from err + raise diff --git a/octobot/community/supabase_backend/enums.py b/octobot/community/supabase_backend/enums.py index 2c7dcfa84..c421db22c 100644 --- a/octobot/community/supabase_backend/enums.py +++ b/octobot/community/supabase_backend/enums.py @@ -173,6 +173,7 @@ class OrderKeys(enum.Enum): TYPE = "type" CHAINED = "chained" SIDE = "side" + TRIGGER_ABOVE = "trigger_above" class PositionKeys(enum.Enum): diff --git a/octobot/constants.py b/octobot/constants.py index 7e0108ad3..97e25675f 100644 --- a/octobot/constants.py +++ b/octobot/constants.py @@ -111,7 +111,7 @@ USE_FETCHED_BOT_CONFIG = os_util.parse_boolean_environment_var("USE_FETCHED_BOT_CONFIG", "false") CAN_INSTALL_TENTACLES = os_util.parse_boolean_environment_var("CAN_INSTALL_TENTACLES", str(not IS_CLOUD_ENV)) TRACKING_ID = os.getenv("TRACKING_ID", "eoe1stwyun" if IS_DEMO else "eoe06soct7" if IS_CLOUD_ENV else "f726lk9q59") -PH_TRACKING_ID = os.getenv("PH_TRACKING_ID", "phc_VydQbPkMXoNhgd0xJde4hUgbWGlEJ3aaLrSu5sudFdJ") +PH_TRACKING_ID = os.getenv("PH_TRACKING_ID", "phc_QSuFy6zqOXXKT7zAYboYS4nJShfKovpB172aa8X9nXf") # Profiles download urls to import at startup if missing, split by "," TO_DOWNLOAD_PROFILES = os.getenv("TO_DOWNLOAD_PROFILES", None) # Profiles to force select at startup, identified by profile id, download url or name diff --git a/requirements.txt b/requirements.txt index b5d646de9..2e55c85de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ # Drakkar-Software requirements OctoBot-Commons==1.9.70 -OctoBot-Trading==2.4.140 +OctoBot-Trading==2.4.147 OctoBot-Evaluators==1.9.7 OctoBot-Tentacles-Manager==2.9.16 -OctoBot-Services==1.6.21 +OctoBot-Services==1.6.23 OctoBot-Backtesting==1.9.7 Async-Channel==2.2.1 -trading-backend==1.2.30 +trading-backend==1.2.32 ## Others colorlog==6.8.0 @@ -18,14 +18,14 @@ setuptools==69.0.3 # Community websockets -gmqtt==0.6.16 +gmqtt==0.7.0 pgpy==0.6.0 # Error tracking -sentry-sdk==2.13.0 # always make sure sentry_aiohttp_transport.py keep working +sentry-sdk==2.19.2 # always make sure sentry_aiohttp_transport.py keep working # Supabase ensure supabase_backend_tests keep passing when updating any of those -supabase==2.7.1 # Supabase client +supabase==2.11.0 # Supabase client gotrue # Supabase authenticated API (required by supabase and enforced to allow direct import) postgrest # Supabase posgres calls (required by supabase and enforced to allow direct import) diff --git a/tests/unit_tests/community/errors_upload/test_sentry_aiohttp_transport.py b/tests/unit_tests/community/errors_upload/test_sentry_aiohttp_transport.py index eca475a29..b311b2191 100644 --- a/tests/unit_tests/community/errors_upload/test_sentry_aiohttp_transport.py +++ b/tests/unit_tests/community/errors_upload/test_sentry_aiohttp_transport.py @@ -61,9 +61,6 @@ def _before_send_callback(event: dict, hint: dict): SENTRY_CONFIG["enabled"] = False if handle and hasattr(handle._client.transport, "async_kill"): await handle._client.transport.async_kill() - client = sentry_sdk.Hub.current.client - if client is not None: - client.close(timeout=0) def _mocked_context(return_value): diff --git a/tests/unit_tests/community/test_community_mqtt_feed.py b/tests/unit_tests/community/test_community_mqtt_feed.py index 52e3be0c5..1feb8a371 100644 --- a/tests/unit_tests/community/test_community_mqtt_feed.py +++ b/tests/unit_tests/community/test_community_mqtt_feed.py @@ -69,8 +69,6 @@ async def connected_community_feed(authenticator): finally: if feed is not None: await feed.stop() - if feed._mqtt_client is not None and not feed._mqtt_client._resend_task.done(): - feed._mqtt_client._resend_task.cancel() async def test_start_and_connect(connected_community_feed):