diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 000000000..09ddca9cb --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,23 @@ + +tasks: + - name: Initialize OctoBot + init: | + cd OctoBot + python3 -m pip install -Ur requirements.txt + command: | + python3 start.py +ports: + - port: 5001 + onOpen: open-preview + name: OctoBot +github: + prebuilds: + master: true + branches: true + pullRequests: true + pullRequestsFromForks: true + addCheck: true + addComment: true + addBadge: true + + diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ee44083..ce6a02558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 *It is strongly advised to perform an update of your tentacles after updating OctoBot. (start.py tentacles --install --all)* +## [0.4.41] - 2023-03-03 +### Added +- Trades PNL history for supported trading mode +- Support for OKX futures +- Support for market orders in Dip Analyser +### Updated +- Revamped the trading tab of the web interface +- Reduced required RAM for long-lasting instances +- Optimized disc read/write operations when browsing the web interface +### Fixed +- Orders synchronization and cancel issues +- Future trading positions synchronization issues +- Order creation issues related to order minimum and maximum amounts + ## [0.4.40] - 2023-02-17 ### Fixed - Historical portfolio reset diff --git a/README.md b/README.md index eb0c5a35a..80cfa991a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OctoBot [0.4.40](https://octobot.click/gh-changelog) +# OctoBot [0.4.41](https://octobot.click/gh-changelog) [![PyPI](https://img.shields.io/pypi/v/OctoBot.svg)](https://octobot.click/gh-pypi) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/e07fb190156d4efb8e7d07aaa5eff2e1)](https://app.codacy.com/gh/Drakkar-Software/OctoBot?utm_source=github.com&utm_medium=referral&utm_content=Drakkar-Software/OctoBot&utm_campaign=Badge_Grade_Dashboard)[![Downloads](https://pepy.tech/badge/octobot/month)](https://pepy.tech/project/octobot) [![Dockerhub](https://img.shields.io/docker/pulls/drakkarsoftware/octobot.svg)](https://octobot.click/gh-dockerhub) @@ -16,6 +16,9 @@

![Web Interface](../assets/web-interface.gif) + + + ## Description [Octobot](https://www.octobot.online/) is a powerful, fully modular open-source cryptocurrency trading robot. @@ -55,6 +58,7 @@ Register to OctoBot Cloud to deploy your OctoBot in the cloud. No installation r [![Deploy to OctoBot Cloud](https://dabuttonfactory.com/button.png?t=Deploy+now&f=Roboto-Bold&ts=18&tc=fff&hp=20&vp=15&c=11&bgt=unicolored&bgc=422afb)](https://octobot.click/gh-deploy) + #### [With executable](https://www.octobot.info/installation/with-binary) Follow the [2 steps installation guide](https://www.octobot.online/executable_installation/) @@ -109,6 +113,11 @@ python3 start.py Octobot supports many [exchanges](https://octobot.click/gh-exchanges) thanks to the [ccxt library](https://github.com/ccxt/ccxt). To activate trading on an exchange, just configure OctoBot with your API keys as described [on the exchange documentation](https://www.octobot.online/guides/#exchanges). +## Contribute from a browser IDE +Make changes and contribute to OctoBot in a single click with an **already setup and ready to code developer environment** using Gitpod ! + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Drakkar-Software/OctoBot) + ## Disclaimer Do not risk money which you are afraid to lose. USE THE SOFTWARE AT YOUR OWN RISK. THE AUTHORS AND ALL AFFILIATES ASSUME NO RESPONSIBILITY FOR YOUR TRADING RESULTS. diff --git a/exchanges_tests/abstract_authenticated_exchange_tester.py b/exchanges_tests/abstract_authenticated_exchange_tester.py index cae32037d..97b906125 100644 --- a/exchanges_tests/abstract_authenticated_exchange_tester.py +++ b/exchanges_tests/abstract_authenticated_exchange_tester.py @@ -51,6 +51,8 @@ class AbstractAuthenticatedExchangeTester: CANCEL_TIMEOUT = 15 EDIT_TIMEOUT = 15 MIN_PORTFOLIO_SIZE = 1 + DUPLICATE_TRADES_RATIO = 0 + SUPPORTS_DOUBLE_BUNDLED_ORDERS = True # 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. @@ -68,10 +70,16 @@ async def test_create_and_cancel_limit_orders(self): await self.inner_test_create_and_cancel_limit_orders() async def inner_test_create_and_cancel_limit_orders(self): + symbol = None price = self.get_order_price(await self.get_price(), False) size = self.get_order_size(await self.get_portfolio(), price) open_orders = await self.get_open_orders() - buy_limit = await self.create_limit_order(price, size, trading_enums.TradeOrderSide.BUY) + # DEBUG tools, uncomment to create specific orders + # price = decimal.Decimal("0.047445") + # size = decimal.Decimal("4619") + # symbol = "OM/USDT" + # end debug tools + buy_limit = await self.create_limit_order(price, size, trading_enums.TradeOrderSide.BUY, symbol=symbol) self.check_created_limit_order(buy_limit, price, size, trading_enums.TradeOrderSide.BUY) assert await self.order_in_open_orders(open_orders, buy_limit) await self.cancel_order(buy_limit) @@ -88,16 +96,19 @@ async def inner_test_create_and_fill_market_orders(self): size = self.get_order_size(portfolio, price) buy_market = await self.create_market_order(current_price, size, trading_enums.TradeOrderSide.BUY) self.check_created_market_order(buy_market, size, trading_enums.TradeOrderSide.BUY) - await self.wait_for_fill(buy_market) - sell_size = self.get_sell_size_from_buy_order(buy_market) - post_buy_portfolio = await self.get_portfolio() - self.check_portfolio_changed(portfolio, post_buy_portfolio, False) - # sell: reset portfolio - sell_market = await self.create_market_order(current_price, sell_size, trading_enums.TradeOrderSide.SELL) - self.check_created_market_order(sell_market, sell_size, trading_enums.TradeOrderSide.SELL) - await self.wait_for_fill(sell_market) - post_sell_portfolio = await self.get_portfolio() - self.check_portfolio_changed(post_buy_portfolio, post_sell_portfolio, True) + post_buy_portfolio = {} + try: + await self.wait_for_fill(buy_market) + post_buy_portfolio = await self.get_portfolio() + self.check_portfolio_changed(portfolio, post_buy_portfolio, False) + finally: + sell_size = self.get_sell_size_from_buy_order(buy_market) + # sell: reset portfolio + sell_market = await self.create_market_order(current_price, sell_size, trading_enums.TradeOrderSide.SELL) + self.check_created_market_order(sell_market, sell_size, trading_enums.TradeOrderSide.SELL) + await self.wait_for_fill(sell_market) + post_sell_portfolio = await self.get_portfolio() + self.check_portfolio_changed(post_buy_portfolio, post_sell_portfolio, True) async def test_create_and_cancel_stop_orders(self): # pass if not implemented @@ -187,46 +198,165 @@ async def inner_test_edit_stop_order(self): await self.cancel_order(stop_loss) assert await self.order_not_in_open_orders(open_orders, stop_loss) - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + async with self.local_exchange_manager(): + await self.inner_test_create_single_bundled_orders() + + async def inner_test_create_single_bundled_orders(self): + await self._test_untriggered_single_bundled_orders() + await self._test_triggered_simple_bundled_orders() + + async def test_create_double_bundled_orders(self): # pass if not implemented async with self.local_exchange_manager(): - await self.inner_test_create_bundled_orders() + await self.inner_test_create_double_bundled_orders() + + async def inner_test_create_double_bundled_orders(self): + await self._test_untriggered_double_bundled_orders() + await self._test_triggered_double_bundled_orders() + + async def _test_untriggered_single_bundled_orders(self): + # tests uncreated bundled orders (remain bound to initial order but initial order does not get filled) + current_price = await self.get_price() + stop_loss, _ = await self._get_bundled_orders_stop_take_profit(current_price) + size = stop_loss.origin_quantity + open_orders = await self.get_open_orders() + + price = personal_data.decimal_adapt_price( + self.exchange_manager.exchange.get_market_status(self.SYMBOL), + stop_loss.origin_price * decimal.Decimal(f"{1 + self.ORDER_PRICE_DIFF/2/100}") + ) + limit_order = await self.create_limit_order(price, size, + trading_enums.TradeOrderSide.BUY, + push_on_exchange=False) + params = await self.bundle_orders(limit_order, stop_loss) + limit_order = await self._create_order_on_exchange(limit_order, params=params) + self.check_created_limit_order(limit_order, price, size, trading_enums.TradeOrderSide.BUY) + # stop and take profit are bundled but not created as long as the initial order is not filled + additionally_fetched_orders = await self.get_similar_orders_in_open_orders(open_orders, [limit_order]) + assert len(additionally_fetched_orders) == 1 # only created limit_order + await self.cancel_order(limit_order) + # limit_order no more in open orders, stop and take profits are not created + assert len(await self.get_open_orders()) == len(open_orders) + + async def _test_untriggered_double_bundled_orders(self): + # tests uncreated bundled orders (remain bound to initial order but initial order does not get filled) + current_price = await self.get_price() + stop_loss, take_profit = await self._get_bundled_orders_stop_take_profit(current_price) + size = stop_loss.origin_quantity + open_orders = await self.get_open_orders() - async def inner_test_create_bundled_orders(self): + price = personal_data.decimal_adapt_price( + self.exchange_manager.exchange.get_market_status(self.SYMBOL), + stop_loss.origin_price * decimal.Decimal(f"{1 + self.ORDER_PRICE_DIFF/2/100}") + ) + limit_order = await self.create_limit_order(price, size, + trading_enums.TradeOrderSide.BUY, + push_on_exchange=False) + params = await self.bundle_orders(limit_order, stop_loss, take_profit=take_profit) + if not self.SUPPORTS_DOUBLE_BUNDLED_ORDERS: + await self._create_order_on_exchange(limit_order, params=params, expected_creation_error=True) + return + limit_order = await self._create_order_on_exchange(limit_order, params=params) + self.check_created_limit_order(limit_order, price, size, trading_enums.TradeOrderSide.BUY) + # stop and take profit are bundled but not created as long as the initial order is not filled + additionally_fetched_orders = await self.get_similar_orders_in_open_orders(open_orders, [limit_order]) + assert len(additionally_fetched_orders) == 1 # only created limit_order + await self.cancel_order(limit_order) + # limit_order no more in open orders, stop and take profits are not created + assert len(await self.get_open_orders()) == len(open_orders) + + async def _test_triggered_simple_bundled_orders(self): + # tests bundled stop loss into open position order + current_price = await self.get_price() + stop_loss, _ = await self._get_bundled_orders_stop_take_profit(current_price) + size = stop_loss.origin_quantity + open_orders = await self.get_open_orders() + # created bundled orders + market_order = await self.create_market_order(current_price, size, + trading_enums.TradeOrderSide.BUY, + push_on_exchange=False) + params = await self.bundle_orders(market_order, stop_loss) + buy_market = await self._create_order_on_exchange(market_order, params=params) + self.check_created_market_order(buy_market, size, trading_enums.TradeOrderSide.BUY) + try: + await self.wait_for_fill(buy_market) + created_orders = [stop_loss] + fetched_conditional_orders = await self.get_similar_orders_in_open_orders(open_orders, created_orders) + for fetched_conditional_order in fetched_conditional_orders: + # ensure stop loss / take profit is fetched in open orders + # ensure stop loss / take profit cancel is working + await self.cancel_order(fetched_conditional_order) + for fetched_conditional_order in fetched_conditional_orders: + assert await self.order_not_in_open_orders(open_orders, fetched_conditional_order) + finally: + # close position + sell_market = await self.create_market_order(current_price, size, + trading_enums.TradeOrderSide.SELL) + self.check_created_market_order(sell_market, size, trading_enums.TradeOrderSide.SELL) + await self.wait_for_fill(sell_market) + + async def _test_triggered_double_bundled_orders(self): + # tests bundled stop loss and take profits into open position order current_price = await self.get_price() + stop_loss, take_profit = await self._get_bundled_orders_stop_take_profit(current_price) + size = stop_loss.origin_quantity + open_orders = await self.get_open_orders() + # created bundled orders + market_order = await self.create_market_order(current_price, size, + trading_enums.TradeOrderSide.BUY, + push_on_exchange=False) + params = await self.bundle_orders(market_order, stop_loss, take_profit=take_profit) + if not self.SUPPORTS_DOUBLE_BUNDLED_ORDERS: + await self._create_order_on_exchange(market_order, params=params, expected_creation_error=True) + return + buy_market = await self._create_order_on_exchange(market_order, params=params) + self.check_created_market_order(buy_market, size, trading_enums.TradeOrderSide.BUY) + try: + await self.wait_for_fill(buy_market) + created_orders = [stop_loss, take_profit] + fetched_conditional_orders = await self.get_similar_orders_in_open_orders(open_orders, created_orders) + for fetched_conditional_order in fetched_conditional_orders: + # ensure stop loss / take profit is fetched in open orders + # ensure stop loss / take profit cancel is working + await self.cancel_order(fetched_conditional_order) + for fetched_conditional_order in fetched_conditional_orders: + assert await self.order_not_in_open_orders(open_orders, fetched_conditional_order) + finally: + # close position + sell_market = await self.create_market_order(current_price, size, + trading_enums.TradeOrderSide.SELL) + self.check_created_market_order(sell_market, size, trading_enums.TradeOrderSide.SELL) + await self.wait_for_fill(sell_market) + + async def bundle_orders(self, initial_order, stop_loss, take_profit=None): + # bundle stop loss and take profits into open position order + params = await self.exchange_manager.trader.bundle_chained_order_with_uncreated_order(initial_order, stop_loss) + # # consider stop loss in param + stop_loss_included_params_len = len(params) + assert stop_loss_included_params_len > 0 + if take_profit: + params.update( + await self.exchange_manager.trader.bundle_chained_order_with_uncreated_order(initial_order, take_profit) + ) + # consider take profit in param + assert len(params) > stop_loss_included_params_len + return params + + async def _get_bundled_orders_stop_take_profit(self, current_price): stop_loss_price = self.get_order_price(current_price, False) take_profit_price = self.get_order_price(current_price, True) size = self.get_order_size(await self.get_portfolio(), stop_loss_price) - open_orders = await self.get_open_orders() stop_loss = await self.create_market_stop_loss_order(current_price, stop_loss_price, size, trading_enums.TradeOrderSide.SELL, push_on_exchange=False) take_profit = await self.create_order(take_profit_price, current_price, size, trading_enums.TradeOrderSide.SELL, trading_enums.TraderOrderType.TAKE_PROFIT, push_on_exchange=False) - market_order = await self.create_market_order(current_price, size, - trading_enums.TradeOrderSide.BUY, - push_on_exchange=False) - # bundle stop loss and take profits into open position order - params = await self.exchange_manager.trader.bundle_chained_order_with_uncreated_order(market_order, stop_loss) - params.update( - await self.exchange_manager.trader.bundle_chained_order_with_uncreated_order(market_order, take_profit) + return ( + stop_loss, + take_profit, ) - buy_market = await self._create_order_on_exchange(market_order, params=params) - self.check_created_market_order(buy_market, size, trading_enums.TradeOrderSide.BUY) - await self.wait_for_fill(buy_market) - created_orders = [stop_loss, take_profit] - fetched_conditional_orders = await self.get_similar_orders_in_open_orders(open_orders, created_orders) - for fetched_conditional_order in fetched_conditional_orders: - # ensure stop loss / take profit is fetched in open orders - # ensure stop loss / take profit cancel is working - await self.cancel_order(fetched_conditional_order) - for fetched_conditional_order in fetched_conditional_orders: - assert await self.order_not_in_open_orders(open_orders, fetched_conditional_order) - # close position - sell_market = await self.create_market_order(current_price, size, - trading_enums.TradeOrderSide.SELL) - self.check_created_market_order(sell_market, size, trading_enums.TradeOrderSide.SELL) - await self.wait_for_fill(sell_market) async def get_portfolio(self): return await self.exchange_manager.exchange.get_balance() @@ -238,13 +368,13 @@ async def get_closed_orders(self, symbol=None): return await self.exchange_manager.exchange.get_closed_orders(symbol or self.SYMBOL) def check_duplicate(self, orders_or_trades): - assert len({ + assert len(orders_or_trades) * (1 - self.DUPLICATE_TRADES_RATIO) <= len({ f"{o[trading_enums.ExchangeConstantsOrderColumns.ID.value]}" f"{o[trading_enums.ExchangeConstantsOrderColumns.TIMESTAMP.value]}" f"{o[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value]}" f"{o[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]}" for o in orders_or_trades - }) == len(orders_or_trades) + }) <= len(orders_or_trades) def check_raw_closed_orders(self, closed_orders): self.check_duplicate(closed_orders) @@ -257,6 +387,7 @@ def check_parsed_closed_order(self, order: personal_data.Order): assert order.symbol assert order.timestamp assert order.order_type + assert order.order_type is not trading_enums.TraderOrderType.UNKNOWN.value assert order.status if self.NO_FEE_ON_GET_CLOSED_ORDERS: assert order.fee is None @@ -284,6 +415,8 @@ def check_parsed_trade(self, trade: personal_data.Trade): assert trade.symbol assert trade.total_cost assert trade.trade_type + assert trade.trade_type is not trading_enums.TraderOrderType.UNKNOWN + assert trade.exchange_trade_type is not trading_enums.TradeOrderType.UNKNOWN assert trade.status assert trade.fee assert trade.origin_order_id @@ -400,8 +533,16 @@ async def create_order(self, price, current_price, size, side, order_type, raise AssertionError("Error when creating order") return current_order - async def _create_order_on_exchange(self, order, params=None): + async def _create_order_on_exchange(self, order, params=None, expected_creation_error=False): created_order = await self.exchange_manager.trader.create_order(order, params=params, wait_for_creation=False) + if expected_creation_error: + if created_order is None: + return None # expected + raise AssertionError( + f"Created order is not None while expected_creation_error is True. The order was created on exchange" + ) + if created_order is None: + raise AssertionError(f"Created order is None. input order: {order}, params: {params}") if created_order.status is trading_enums.OrderStatus.PENDING_CREATION: await self.wait_for_open(created_order) return await self.get_order(created_order.order_id) @@ -512,7 +653,7 @@ async def _get_order_until(self, order, validation_func, timeout): raw_order = await self.exchange_manager.exchange.get_order(order.order_id, order.symbol) if raw_order and validation_func(raw_order): return raw_order - raise TimeoutError(f"Order not filled within {timeout}s: {order}") + raise TimeoutError(f"Order not filled/cancelled within {timeout}s: {order}") async def order_in_open_orders(self, previous_open_orders, order): open_orders = await self.get_open_orders() diff --git a/exchanges_tests/abstract_authenticated_future_exchange_tester.py b/exchanges_tests/abstract_authenticated_future_exchange_tester.py index c2ce40446..8b77d2bc1 100644 --- a/exchanges_tests/abstract_authenticated_future_exchange_tester.py +++ b/exchanges_tests/abstract_authenticated_future_exchange_tester.py @@ -15,9 +15,11 @@ # License along with OctoBot. If not, see . import contextlib import decimal +import pytest import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants +import octobot_trading.errors as trading_errors from exchanges_tests import abstract_authenticated_exchange_tester @@ -27,9 +29,11 @@ class AbstractAuthenticatedFutureExchangeTester( # enter exchange name as a class variable here* EXCHANGE_TYPE = trading_enums.ExchangeTypes.FUTURE.value PORTFOLIO_TYPE_FOR_SIZE = trading_constants.CONFIG_PORTFOLIO_TOTAL - REQUIRES_SYMBOLS_TO_GET_POSITIONS = False 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): # ensure fetch empty positions @@ -38,19 +42,103 @@ async def test_get_empty_linear_and_inverse_positions(self): async def inner_test_get_empty_linear_and_inverse_positions(self): positions = await self.get_positions() - self._check_position_content(positions) + self._check_positions_content(positions) + position = await self.get_position(self.SYMBOL) + self._check_position_content(position, self.SYMBOL) for contract_type in (trading_enums.FutureContractType.LINEAR_PERPETUAL, trading_enums.FutureContractType.INVERSE_PERPETUAL): if not self.has_empty_position(self.get_filtered_positions(positions, contract_type)): empty_position_symbol = self.get_other_position_symbol(positions, contract_type) + # test with get_position empty_position = await self.get_position(empty_position_symbol) assert self.is_position_empty(empty_position) + # test with get_positions + empty_positions = await self.get_positions([empty_position_symbol]) + assert len(empty_positions) == 1 + assert self.is_position_empty(empty_positions[0]) - def _check_position_content(self, positions): + async def test_get_and_set_leverage(self): + # ensure set_leverage works + async with self.local_exchange_manager(): + await self.inner_test_get_and_set_leverage() + + async def inner_test_get_and_set_leverage(self): + contract = await self.init_and_get_contract() + origin_margin_type = contract.margin_type + origin_leverage = contract.current_leverage + 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 + 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 + await self.set_leverage(origin_leverage) + await self._check_margin_type_and_leverage(origin_margin_type, origin_leverage) # did not change margin type + + async def test_get_and_set_margin_type(self): + # ensure set_leverage works + async with self.local_exchange_manager(): + await self.inner_test_get_and_set_margin_type(allow_empty_position=True) + + async def inner_test_get_and_set_margin_type(self, allow_empty_position=False, symbol=None): + contract = await self.init_and_get_contract(symbol=symbol) + origin_margin_type = contract.margin_type + origin_leverage = contract.current_leverage + new_margin_type = trading_enums.MarginType.CROSS \ + if origin_margin_type is trading_enums.MarginType.ISOLATED else trading_enums.MarginType.ISOLATED + if not self.exchange_manager.exchange.SUPPORTS_SET_MARGIN_TYPE: + assert origin_margin_type in (trading_enums.MarginType.ISOLATED, trading_enums.MarginType.CROSS) + with pytest.raises(AttributeError): + await self.exchange_manager.exchange.connector.set_symbol_margin_type(symbol, True) + with pytest.raises(trading_errors.NotSupported): + await self.set_margin_type(new_margin_type, symbol=symbol) + return + await self.set_margin_type(new_margin_type, symbol=symbol) + position = await self.get_position(symbol=symbol) + if allow_empty_position and ( + position[trading_enums.ExchangeConstantsPositionColumns.SIZE.value] != trading_constants.ZERO + or self.SUPPORTS_EMPTY_POSITION_SET_MARGIN_TYPE + ): + # did not change leverage + await self._check_margin_type_and_leverage(new_margin_type, origin_leverage, symbol=symbol) + # restore margin type + await self.set_margin_type(origin_margin_type, symbol=symbol) + # did not change leverage + await self._check_margin_type_and_leverage(origin_margin_type, origin_leverage, symbol=symbol) + + async def set_margin_type(self, margin_type, symbol=None): + await self.exchange_manager.exchange.set_symbol_margin_type( + symbol or self.SYMBOL, + margin_type is trading_enums.MarginType.ISOLATED + ) + + async def _check_margin_type_and_leverage(self, expected_margin_type, expected_leverage, symbol=None): + margin_type, leverage = await self.get_margin_type_and_leverage_from_position(symbol=symbol) + assert expected_margin_type is margin_type + assert expected_leverage == leverage + if self.SUPPORTS_GET_LEVERAGE: + assert expected_leverage == await self.get_leverage(symbol=symbol) + + def _check_positions_content(self, positions): for position in positions: + self._check_position_content(position, None) + + def _check_position_content(self, position, symbol, position_mode=None): + if symbol: + assert position[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] == symbol + else: assert position[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] - # should not be 0 in octobot - assert position[trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value] > 0 + leverage = position[trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value] + assert isinstance(leverage, decimal.Decimal) + # should not be 0 in octobot + assert leverage > 0 + assert position[trading_enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value] is not None + assert position[trading_enums.ExchangeConstantsPositionColumns.POSITION_MODE.value] is not None + if position_mode is not None: + assert position[trading_enums.ExchangeConstantsPositionColumns.POSITION_MODE.value] is position_mode async def inner_test_create_and_fill_market_orders(self): portfolio = await self.get_portfolio() @@ -62,39 +150,64 @@ async def inner_test_create_and_fill_market_orders(self): # buy: increase position buy_market = await self.create_market_order(current_price, size, trading_enums.TradeOrderSide.BUY) self.check_created_market_order(buy_market, size, trading_enums.TradeOrderSide.BUY) - await self.wait_for_fill(buy_market) - post_buy_portfolio = await self.get_portfolio() - post_buy_position = await self.get_position() - self.check_portfolio_changed(portfolio, post_buy_portfolio, False) - self.check_position_changed(position, post_buy_position, True) - post_order_positions = await self.get_positions() - self.check_position_in_positions(pre_order_positions + post_order_positions) - # sell: reset portfolio & position - sell_market = await self.create_market_order(current_price, size, trading_enums.TradeOrderSide.SELL) - self.check_created_market_order(sell_market, size, trading_enums.TradeOrderSide.SELL) - await self.wait_for_fill(sell_market) - post_sell_portfolio = await self.get_portfolio() - post_sell_position = await self.get_position() - self.check_portfolio_changed(post_buy_portfolio, post_sell_portfolio, True) - self.check_position_changed(post_buy_position, post_sell_position, False) - # position is back to what it was at the beginning on the test - self.check_position_size(position, post_sell_position) - - async def test_create_bundled_orders(self): - async with self.local_exchange_manager(), self.required_empty_position(): - await self.inner_test_create_bundled_orders() + post_buy_portfolio = {} + post_buy_position = None + try: + await self.wait_for_fill(buy_market) + post_buy_portfolio = await self.get_portfolio() + post_buy_position = await self.get_position() + self._check_position_content(post_buy_position, self.SYMBOL, + position_mode=trading_enums.PositionMode.ONE_WAY) + self.check_portfolio_changed(portfolio, post_buy_portfolio, False) + self.check_position_changed(position, post_buy_position, True) + post_order_positions = await self.get_positions() + self.check_position_in_positions(pre_order_positions + post_order_positions) + # now that position is open, test margin type update + await self.inner_test_get_and_set_margin_type() + finally: + # sell: reset portfolio & position + sell_market = await self.create_market_order(current_price, size, trading_enums.TradeOrderSide.SELL) + self.check_created_market_order(sell_market, size, trading_enums.TradeOrderSide.SELL) + await self.wait_for_fill(sell_market) + post_sell_portfolio = await self.get_portfolio() + post_sell_position = await self.get_position() + self.check_portfolio_changed(post_buy_portfolio, post_sell_portfolio, True) + self.check_position_changed(post_buy_position, post_sell_position, False) + # position is back to what it was at the beginning on the test + self.check_position_size(position, post_sell_position) async def get_position(self, symbol=None): return await self.exchange_manager.exchange.get_position(symbol or self.SYMBOL) - async def get_positions(self): - symbols = None - if self.REQUIRES_SYMBOLS_TO_GET_POSITIONS: + async def get_positions(self, symbols=None): + symbols = symbols or None + if symbols is None and self.exchange_manager.exchange.REQUIRES_SYMBOL_FOR_EMPTY_POSITION: if self.INVERSE_SYMBOL is None: raise AssertionError(f"INVERSE_SYMBOL is required") symbols = [self.SYMBOL, self.INVERSE_SYMBOL] return await self.exchange_manager.exchange.get_positions(symbols=symbols) + async def init_and_get_contract(self, symbol=None): + symbol = symbol or self.SYMBOL + await self.exchange_manager.exchange.load_pair_future_contract(symbol) + if not self.exchange_manager.exchange.has_pair_future_contract(symbol): + raise AssertionError(f"{symbol} contract not initialized") + return self.exchange_manager.exchange.get_pair_future_contract(symbol) + + async def get_margin_type_and_leverage_from_position(self, symbol=None): + position = await self.get_position(symbol=symbol) + return ( + position[trading_enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value], + position[trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value], + ) + + async def get_leverage(self, symbol=None): + leverage = await self.exchange_manager.exchange.get_symbol_leverage(symbol or self.SYMBOL) + return leverage[trading_enums.ExchangeConstantsLeveragePropertyColumns.LEVERAGE.value] + + async def set_leverage(self, leverage, symbol=None): + return await self.exchange_manager.exchange.set_symbol_leverage(symbol or self.SYMBOL, float(leverage)) + @contextlib.asynccontextmanager async def required_empty_position(self): position = await self.get_position() @@ -168,7 +281,7 @@ def get_other_position_symbol(self, positions_blacklist, contract_type): for position in positions_blacklist ) for symbol in self.exchange_manager.exchange.connector.client.markets: - if symbol in ignored_symbols: + if symbol in ignored_symbols or self.exchange_manager.exchange.is_expirable_symbol(symbol): continue if contract_type is trading_enums.FutureContractType.INVERSE_PERPETUAL \ and self.exchange_manager.exchange.is_inverse_symbol(symbol): @@ -179,6 +292,10 @@ def get_other_position_symbol(self, positions_blacklist, contract_type): raise AssertionError(f"No free symbol for {contract_type}") def is_position_empty(self, position): + if position is None: + raise AssertionError( + f"Fetched empty position should never be None as a symbol parameter is given" + ) return position[trading_enums.ExchangeConstantsPositionColumns.SIZE.value] == trading_constants.ZERO def check_position_in_positions(self, positions, symbol=None): @@ -204,4 +321,8 @@ def check_theoretical_cost(self, symbol, quantity, price, cost): assert theoretical_cost * decimal.Decimal("0.8") <= cost <= theoretical_cost * decimal.Decimal("1.2") def _get_all_symbols(self): - return [self.SYMBOL, self.INVERSE_SYMBOL] + return [ + symbol + for symbol in (self.SYMBOL, self.INVERSE_SYMBOL) + if symbol + ] diff --git a/exchanges_tests/test_ascendex.py b/exchanges_tests/test_ascendex.py index c041a66b1..b67a92bcf 100644 --- a/exchanges_tests/test_ascendex.py +++ b/exchanges_tests/test_ascendex.py @@ -58,6 +58,10 @@ async def test_edit_stop_order(self): # pass if not implemented pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented pass diff --git a/exchanges_tests/test_binance.py b/exchanges_tests/test_binance.py index 498fbdd6f..110f656c1 100644 --- a/exchanges_tests/test_binance.py +++ b/exchanges_tests/test_binance.py @@ -30,6 +30,7 @@ class TestBinanceAuthenticatedExchange( SETTLEMENT_CURRENCY = "BUSD" SYMBOL = f"{ORDER_CURRENCY}/{SETTLEMENT_CURRENCY}" ORDER_SIZE = 50 # % of portfolio to include in test orders + DUPLICATE_TRADES_RATIO = 0.1 # allow 10% duplicate in trades (due to trade id set to order id) async def test_get_portfolio(self): await super().test_get_portfolio() @@ -58,6 +59,10 @@ async def test_edit_stop_order(self): # pass if not implemented pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented pass diff --git a/exchanges_tests/test_bitget.py b/exchanges_tests/test_bitget.py index 2f682457f..4033a22f6 100644 --- a/exchanges_tests/test_bitget.py +++ b/exchanges_tests/test_bitget.py @@ -62,6 +62,10 @@ async def test_edit_stop_order(self): # pass if not implemented pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented pass diff --git a/exchanges_tests/test_bybit.py b/exchanges_tests/test_bybit_futures.py similarity index 78% rename from exchanges_tests/test_bybit.py rename to exchanges_tests/test_bybit_futures.py index 25239b5f0..ba8b66004 100644 --- a/exchanges_tests/test_bybit.py +++ b/exchanges_tests/test_bybit_futures.py @@ -21,7 +21,7 @@ pytestmark = pytest.mark.asyncio -class TestBybitAuthenticatedExchange( +class TestBybitFuturesAuthenticatedExchange( abstract_authenticated_future_exchange_tester.AbstractAuthenticatedFutureExchangeTester ): # enter exchange name as a class variable here @@ -29,8 +29,12 @@ class TestBybitAuthenticatedExchange( ORDER_CURRENCY = "BTC" SETTLEMENT_CURRENCY = "USDT" SYMBOL = f"{ORDER_CURRENCY}/{SETTLEMENT_CURRENCY}:{SETTLEMENT_CURRENCY}" + INVERSE_SYMBOL = f"{ORDER_CURRENCY}/USD:{ORDER_CURRENCY}" ORDER_SIZE = 10 # % of portfolio to include in test orders + OPEN_TIMEOUT = 20 # larger for bybit testnet + CANCEL_TIMEOUT = 20 # larger for bybit testnet OPEN_ORDERS_IN_CLOSED_ORDERS = True + SUPPORTS_GET_LEVERAGE = False async def test_get_portfolio(self): await super().test_get_portfolio() @@ -38,6 +42,12 @@ async def test_get_portfolio(self): async def test_get_empty_linear_and_inverse_positions(self): await super().test_get_empty_linear_and_inverse_positions() + async def test_get_and_set_margin_type(self): + await super().test_get_and_set_margin_type() + + async def test_get_and_set_leverage(self): + await super().test_get_and_set_leverage() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() @@ -62,6 +72,8 @@ async def test_edit_stop_order(self): # pass if not implemented await super().test_edit_stop_order() - async def test_create_bundled_orders(self): - # pass if not implemented - await super().test_create_bundled_orders() + async def test_create_single_bundled_orders(self): + await super().test_create_single_bundled_orders() + + async def test_create_double_bundled_orders(self): + await super().test_create_double_bundled_orders() diff --git a/exchanges_tests/test_coinbasepro.py b/exchanges_tests/test_coinbasepro.py index 716d81e2b..8d6d25bd2 100644 --- a/exchanges_tests/test_coinbasepro.py +++ b/exchanges_tests/test_coinbasepro.py @@ -24,6 +24,8 @@ class TestCoinbaseproAuthenticatedExchange( abstract_authenticated_exchange_tester.AbstractAuthenticatedExchangeTester ): + # BROKEN: waiting for ccxt update on coinbase advanced + # enter exchange name as a class variable here EXCHANGE_NAME = "coinbasepro" EXCHANGE_TENTACLE_NAME = "CoinbasePro" @@ -59,6 +61,10 @@ async def test_edit_stop_order(self): # pass if not implemented pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented pass diff --git a/exchanges_tests/test_hollaex.py b/exchanges_tests/test_hollaex.py index 5caf21d8f..34b870c21 100644 --- a/exchanges_tests/test_hollaex.py +++ b/exchanges_tests/test_hollaex.py @@ -60,6 +60,10 @@ async def test_edit_stop_order(self): # pass if not implemented pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented pass diff --git a/exchanges_tests/test_huobi.py b/exchanges_tests/test_huobi.py index 1977b4920..0c8b8a2e1 100644 --- a/exchanges_tests/test_huobi.py +++ b/exchanges_tests/test_huobi.py @@ -59,6 +59,10 @@ async def test_edit_stop_order(self): # pass if not implemented pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented pass diff --git a/exchanges_tests/test_kucoin.py b/exchanges_tests/test_kucoin.py index f66b5d03d..3bc079129 100644 --- a/exchanges_tests/test_kucoin.py +++ b/exchanges_tests/test_kucoin.py @@ -58,6 +58,10 @@ async def test_edit_stop_order(self): # pass if not implemented pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented pass diff --git a/exchanges_tests/test_kucoin_futures.py b/exchanges_tests/test_kucoin_futures.py index e57d55cca..4e519b0f4 100644 --- a/exchanges_tests/test_kucoin_futures.py +++ b/exchanges_tests/test_kucoin_futures.py @@ -32,7 +32,8 @@ class TestKucoinFuturesAuthenticatedExchange( SYMBOL = f"{ORDER_CURRENCY}/{SETTLEMENT_CURRENCY}:{SETTLEMENT_CURRENCY}" INVERSE_SYMBOL = f"{ORDER_CURRENCY}/USD:{ORDER_CURRENCY}" ORDER_SIZE = 60 # % of portfolio to include in test orders - REQUIRES_SYMBOLS_TO_GET_POSITIONS = True + SUPPORTS_GET_LEVERAGE = False + SUPPORTS_SET_LEVERAGE = False async def test_get_portfolio(self): await super().test_get_portfolio() @@ -40,6 +41,12 @@ async def test_get_portfolio(self): async def test_get_empty_linear_and_inverse_positions(self): await super().test_get_empty_linear_and_inverse_positions() + async def test_get_and_set_margin_type(self): + await super().test_get_and_set_margin_type() + + async def test_get_and_set_leverage(self): + await super().test_get_and_set_leverage() + async def test_create_and_cancel_limit_orders(self): await super().test_create_and_cancel_limit_orders() @@ -66,7 +73,12 @@ async def test_edit_stop_order(self): # no exchange API to edit a live order pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + # no exchange API to bind secondary orders when creating a new order + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented # no exchange API to bind secondary orders when creating a new order pass diff --git a/exchanges_tests/test_okx.py b/exchanges_tests/test_okx.py index 1bbe436e6..cbfb52bd3 100644 --- a/exchanges_tests/test_okx.py +++ b/exchanges_tests/test_okx.py @@ -58,6 +58,10 @@ async def test_edit_stop_order(self): # pass if not implemented pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented pass diff --git a/exchanges_tests/test_okx_futures.py b/exchanges_tests/test_okx_futures.py new file mode 100644 index 000000000..6a55c6657 --- /dev/null +++ b/exchanges_tests/test_okx_futures.py @@ -0,0 +1,76 @@ +# This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) +# Copyright (c) 2023 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot 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 +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . +import pytest + +from exchanges_tests import abstract_authenticated_future_exchange_tester + +# All test coroutines will be treated as marked. +pytestmark = pytest.mark.asyncio + + +class TestOKXFuturesAuthenticatedExchange( + abstract_authenticated_future_exchange_tester.AbstractAuthenticatedFutureExchangeTester +): + # enter exchange name as a class variable here + EXCHANGE_NAME = "okx" + ORDER_CURRENCY = "DOT" # use DOT/USDT as contract size is much smaller, allowing to trade with smaller amounts + SETTLEMENT_CURRENCY = "USDT" + SYMBOL = f"{ORDER_CURRENCY}/{SETTLEMENT_CURRENCY}:{SETTLEMENT_CURRENCY}" + INVERSE_SYMBOL = f"{ORDER_CURRENCY}/USD:{ORDER_CURRENCY}" + ORDER_SIZE = 50 # % of portfolio to include in test orders + SUPPORTS_EMPTY_POSITION_SET_MARGIN_TYPE = False + SUPPORTS_DOUBLE_BUNDLED_ORDERS = False + + async def test_get_portfolio(self): + await super().test_get_portfolio() + + async def test_get_empty_linear_and_inverse_positions(self): + await super().test_get_empty_linear_and_inverse_positions() + + async def test_get_and_set_margin_type(self): + await super().test_get_and_set_margin_type() + + async def test_get_and_set_leverage(self): + await super().test_get_and_set_leverage() + + async def test_create_and_cancel_limit_orders(self): + await super().test_create_and_cancel_limit_orders() + + async def test_create_and_fill_market_orders(self): + await super().test_create_and_fill_market_orders() + + async def test_get_my_recent_trades(self): + await super().test_get_my_recent_trades() + + async def test_get_closed_orders(self): + await super().test_get_closed_orders() + + async def test_create_and_cancel_stop_orders(self): + await super().test_create_and_cancel_stop_orders() + + async def test_edit_limit_order(self): + # pass if not implemented + pass + + async def test_edit_stop_order(self): + # pass if not implemented + pass + + async def test_create_single_bundled_orders(self): + await super().test_create_single_bundled_orders() + + async def test_create_double_bundled_orders(self): + await super().test_create_double_bundled_orders() diff --git a/exchanges_tests/test_phemex.py b/exchanges_tests/test_phemex.py index ff5ec53ac..870817985 100644 --- a/exchanges_tests/test_phemex.py +++ b/exchanges_tests/test_phemex.py @@ -35,7 +35,6 @@ async def test_get_portfolio(self): await super().test_get_portfolio() async def test_create_and_cancel_limit_orders(self): - # 08 dec 2022: did not run so far, testnet is bugged: order are apparently accepted but never show up await super().test_create_and_cancel_limit_orders() async def test_create_and_fill_market_orders(self): @@ -60,6 +59,10 @@ async def test_edit_stop_order(self): # pass if not implemented pass - async def test_create_bundled_orders(self): + async def test_create_single_bundled_orders(self): + # pass if not implemented + pass + + async def test_create_double_bundled_orders(self): # pass if not implemented pass diff --git a/octobot/__init__.py b/octobot/__init__.py index 937302f9c..0dbd6cd56 100644 --- a/octobot/__init__.py +++ b/octobot/__init__.py @@ -16,5 +16,5 @@ PROJECT_NAME = "OctoBot" AUTHOR = "Drakkar-Software" -VERSION = "0.4.40" # major.minor.revision +VERSION = "0.4.41" # major.minor.revision LONG_VERSION = f"{VERSION}" diff --git a/octobot/community/__init__.py b/octobot/community/__init__.py index 80e771611..7d319ed56 100644 --- a/octobot/community/__init__.py +++ b/octobot/community/__init__.py @@ -78,7 +78,9 @@ update_bot_config_and_stats_query, select_subscribed_profiles_query, update_bot_trades_query, + upsert_bot_trades_query, update_bot_portfolio_query, + upsert_historical_bot_portfolio_query, ) from octobot.community.feeds import ( AbstractFeed, @@ -121,7 +123,9 @@ "update_bot_config_and_stats_query", "select_subscribed_profiles_query", "update_bot_trades_query", + "upsert_bot_trades_query", "update_bot_portfolio_query", + "upsert_historical_bot_portfolio_query", "AbstractFeed", "CommunityWSFeed", "CommunityMQTTFeed", diff --git a/octobot/community/authentication.py b/octobot/community/authentication.py index 30ce6b982..37b6d68a0 100644 --- a/octobot/community/authentication.py +++ b/octobot/community/authentication.py @@ -40,9 +40,7 @@ def _selected_bot_update(func): async def wrapper(*args, **kwargs): self = args[0] await self.gql_login_if_required() - if self._update_bot_lock is None: - self._update_bot_lock = asyncio.Lock() - async with self._update_bot_lock: + async with self._get_update_bot_lock(): self.logger.debug(f"@selected_bot_update: entering {func.__name__}") updated_bot = await func(*args, **kwargs) self.logger.debug(f"@selected_bot_update: exited {func.__name__}") @@ -109,6 +107,11 @@ def get_packages(self): except json.JSONDecodeError: return [] + def _get_update_bot_lock(self): + if self._update_bot_lock is None: + self._update_bot_lock = asyncio.Lock() + return self._update_bot_lock + def is_feed_connected(self): return self._community_feed is not None and self._community_feed.is_connected_to_remote_feed() @@ -439,8 +442,7 @@ async def load_user_bots(self): async def get_startup_info(self): if self._startup_info is None: - if self.user_account.gql_bot_id is None: - raise errors.BotError("No selected bot") + self.user_account.ensure_selected_bot_id() self._startup_info = startup_info.StartupInfo.from_dict( await self._fetch_startup_info( self.user_account.gql_bot_id @@ -455,11 +457,14 @@ async def get_subscribed_profile_urls(self): for profile_data in subscribed_profiles["data"] ] - async def update_trades(self, trades: list): + def is_logged_in_and_has_selected_bot(self): + return self.is_logged_in() and self.user_account.gql_bot_id is not None + + async def update_trades(self, trades: list, reset: bool): """ Updates authenticated account trades """ - if not self.is_logged_in(): + if not self.is_logged_in_and_has_selected_bot(): return try: formatted_trades = [ @@ -473,16 +478,20 @@ async def update_trades(self, trades: list): } for trade in trades ] - await self._update_bot_trades(formatted_trades) + if reset: + await self._set_bot_trades(formatted_trades) + else: + await self._add_to_bot_trades(formatted_trades) except Exception as err: self.logger.exception(err, True, f"Error when updating community trades {err}") async def update_portfolio(self, current_value: dict, initial_value: dict, - unit: str, content: dict, history: dict, price_by_asset: dict): + unit: str, content: dict, history: dict, price_by_asset: dict, + reset: bool): """ Updates authenticated account portfolio """ - if not self.is_logged_in(): + if not self.is_logged_in_and_has_selected_bot(): return try: ref_market_current_value = current_value[unit] @@ -503,16 +512,18 @@ async def update_portfolio(self, current_value: dict, initial_value: dict, "value": str(value[unit]) } for timestamp, value in history.items() - if unit in value + if unit in value and value[unit] # skip missing a 0 values ] except KeyError: pass - if not formatted_history: - return - await self._update_bot_portfolio( - ref_market_current_value, ref_market_initial_value, unit, - formatted_content, formatted_history - ) + if reset: + await self._set_bot_portfolio( + ref_market_current_value, ref_market_initial_value, unit, formatted_content, formatted_history + ) + else: + await self._update_bot_historical_portfolio( + ref_market_current_value, formatted_content, formatted_history + ) except Exception as err: self.logger.exception(err, True, f"Error when updating community portfolio {err}") @@ -571,21 +582,39 @@ async def update_bot_config_and_stats(self, profile_name, profitability): ) @_selected_bot_update - async def _update_bot_trades(self, trades): + async def _set_bot_trades(self, trades): return await self._execute_request( graphql_requests.update_bot_trades_query, self.user_account.gql_bot_id, trades ) + async def _add_to_bot_trades(self, trades): + await self.gql_login_if_required() + async with self._get_update_bot_lock(): + return await self._execute_request( + graphql_requests.upsert_bot_trades_query, + self.user_account.gql_bot_id, + trades + ) + @_selected_bot_update - async def _update_bot_portfolio(self, current_value, initial_value, unit, content, history): + async def _set_bot_portfolio(self, current_value, initial_value, unit, content, history): return await self._execute_request( graphql_requests.update_bot_portfolio_query, self.user_account.gql_bot_id, current_value, initial_value, unit, content, history ) + async def _update_bot_historical_portfolio(self, current_value, content, history): + await self.gql_login_if_required() + async with self._get_update_bot_lock(): + return await self._execute_request( + graphql_requests.upsert_historical_bot_portfolio_query, + self.user_account.gql_bot_id, + current_value, content, history + ) + async def _execute_request(self, request_factory, *args, **kwargs): query, variables, query_name = request_factory(*args, **kwargs) return await self.async_graphql_query(query, query_name, variables=variables, expected_code=200) @@ -775,14 +804,17 @@ async def _async_check_auth(self): if self._login_completed is None: self._login_completed = asyncio.Event() self._login_completed.clear() - with self._auth_context(): - async with self.get_aiohttp_session().get( - identifiers_provider.IdentifiersProvider.BACKEND_ACCOUNT_URL - ) as resp: - try: - self._handle_auth_result(resp.status, await resp.json(), resp.headers) - finally: - self._login_completed.set() + try: + with self._auth_context(): + async with self.get_aiohttp_session().get( + identifiers_provider.IdentifiersProvider.BACKEND_ACCOUNT_URL + ) as resp: + try: + self._handle_auth_result(resp.status, await resp.json(), resp.headers) + finally: + self._login_completed.set() + except Exception as e: + self.logger.exception(e) def _ensure_email(self, email): if constants.USER_ACCOUNT_EMAIL and email != constants.USER_ACCOUNT_EMAIL: diff --git a/octobot/community/community_user_account.py b/octobot/community/community_user_account.py index cb5a07e3b..d1665e094 100644 --- a/octobot/community/community_user_account.py +++ b/octobot/community/community_user_account.py @@ -128,6 +128,10 @@ def _ensure_selected_bot_data(self): if self._selected_bot_raw_data is None: raise errors.BotError(self.NO_SELECTED_BOT_DESC) + def ensure_selected_bot_id(self): + if self.gql_bot_id is None: + raise errors.BotError("No selected bot") + def flush_bot_details(self): self.gql_bot_id = None self._selected_bot_raw_data = None diff --git a/octobot/community/graphql_requests.py b/octobot/community/graphql_requests.py index f7cda2278..cdbff471f 100644 --- a/octobot/community/graphql_requests.py +++ b/octobot/community/graphql_requests.py @@ -140,6 +140,16 @@ def update_bot_trades_query(bot_id, trades) -> (str, dict, str): """, {"bot_id": bot_id, "trades": trades}, "updateOneBot" +def upsert_bot_trades_query(bot_id, trades) -> (str, dict, str): + return """ +mutation upsertBotTrades($bot_id: String, $trades: [BotTradesUpsertInputTrade]) { + upsertBotTrades(input: {bot_id: $bot_id, trades: $trades}){ + status + } +} + """, {"bot_id": bot_id, "trades": trades}, "upsertBotTrades" + + def update_bot_portfolio_query(bot_id, current_value, initial_value, unit, content, history) -> (str, dict, str): return """ mutation updateOneBot($bot_id: ObjectId, $current_value: Decimal, $initial_value: Decimal, $unit: String, $content: [BotPortfolioContentUpdateInput], $history: [BotPortfolioHistoryUpdateInput]) { @@ -152,3 +162,14 @@ def update_bot_portfolio_query(bot_id, current_value, initial_value, unit, conte } """, {"bot_id": bot_id, "current_value": str(current_value), "initial_value": str(initial_value), "unit": unit, "content": content, "history": history}, "updateOneBot" + + +def upsert_historical_bot_portfolio_query(bot_id, current_value, content, history) -> (str, dict, str): + return """ +mutation upsertBotPortfolio($bot_id: String, $current_value: Decimal, $content: [BotPortfolioUpsertInputContent], $history: [BotPortfolioUpsertInputHistory]) { + upsertBotPortfolio(input: {bot_id: $bot_id, current_value: $current_value, content: $content, history: $history}){ + status + } +} + """, {"bot_id": bot_id, "current_value": str(current_value), "content": content, "history": history}, "upsertBotPortfolio" + diff --git a/octobot/community/startup_info.py b/octobot/community/startup_info.py index ef535b52d..96470bfd9 100644 --- a/octobot/community/startup_info.py +++ b/octobot/community/startup_info.py @@ -24,8 +24,8 @@ def __init__(self, forced_profile, subscribed_products): self.forced_profile = forced_profile self.subscribed_products = subscribed_products - def get_forced_profile_url(self) -> list: - return self.forced_profile[self.URL] + def get_forced_profile_url(self) -> str: + return self.forced_profile.get(self.URL, None) def get_subscribed_products_urls(self) -> list: return [ @@ -36,8 +36,8 @@ def get_subscribed_products_urls(self) -> list: @staticmethod def from_dict(data): return StartupInfo( - data[StartupInfo.FORCED_PROFILE_URL], - data[StartupInfo.SUBSCRIBED_PRODUCTS] + data.get(StartupInfo.FORCED_PROFILE_URL, {}) or {}, + data.get(StartupInfo.SUBSCRIBED_PRODUCTS, []) or [] ) def __str__(self): diff --git a/octobot/octobot.py b/octobot/octobot.py index b72bddb87..5c7c0b7ab 100644 --- a/octobot/octobot.py +++ b/octobot/octobot.py @@ -181,6 +181,9 @@ async def _store_run_metadata_when_available(self): exchange_managers = [ trading_api.get_exchange_manager_from_exchange_id(exchange_manager_id) for exchange_manager_id in self.exchange_producer.exchange_manager_ids + if trading_api.is_trader_existing_and_enabled( + trading_api.get_exchange_manager_from_exchange_id(exchange_manager_id) + ) ] # start automations now that everything started await self.automation.initialize() @@ -189,10 +192,13 @@ async def _store_run_metadata_when_available(self): except asyncio.TimeoutError: pass try: - await storage.clear_run_metadata(self.bot_id) - await storage.store_run_metadata(self.bot_id, exchange_managers, self.start_time) + if exchange_managers: + await storage.clear_run_metadata(self.bot_id) + await storage.store_run_metadata(self.bot_id, exchange_managers, self.start_time) + else: + self.logger.debug("Skipping run metadata update: no available exchange manager") except Exception as err: - self.logger.exception(err, True, f"Error when storing live matadata: {err}") + self.logger.exception(err, True, f"Error when storing live metadata: {err}") async def stop(self): try: @@ -245,7 +251,8 @@ def _log_config(self): trader_str = "real trader" if has_real_trader else "simulated trader" if has_simulated_trader else "no trader" traded_symbols = trading_api.get_config_symbols(self.config, True) symbols_str = ', '.join(set(traded_symbols)) - self.logger.info(f"Starting OctoBot with {trader_str} on {', '.join(exchanges)} " + self.logger.info(f"Starting OctoBot with {trader_str} on " + f"{', '.join(exchanges) if exchanges else 'no exchange'} " f"trading {symbols_str or 'nothing'} and using bot_id: {self.bot_id}") def get_edited_config(self, config_key, dict_only=True): diff --git a/requirements.txt b/requirements.txt index 8bbc52947..5a853ae62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,11 @@ cython==0.29.32 # Drakkar-Software requirements -OctoBot-Commons==1.8.11 -OctoBot-Trading==2.3.23 +OctoBot-Commons==1.8.12 +OctoBot-Trading==2.3.24 OctoBot-Evaluators==1.8.3 OctoBot-Tentacles-Manager==2.8.4 -OctoBot-Services==1.4.3 +OctoBot-Services==1.4.4 OctoBot-Backtesting==1.8.0 Async-Channel==2.1.0 trading-backend==1.0.19