From 843c7319e6ee9d69f4d57cd5f6eead71a0748963 Mon Sep 17 00:00:00 2001 From: "Benjamin T. Schwertfeger" Date: Wed, 8 Jan 2025 07:52:46 +0100 Subject: [PATCH] Resolve "Hitting the max_investment ends in infinite loop" (#38) --- src/kraken_infinity_grid/database.py | 12 ++- src/kraken_infinity_grid/gridbot.py | 25 ++++- src/kraken_infinity_grid/order_management.py | 104 +++++++------------ src/kraken_infinity_grid/setup.py | 7 -- tests/test_gridbot.py | 33 ++++++ tests/test_order_management.py | 64 ++++++++---- 6 files changed, 142 insertions(+), 103 deletions(-) diff --git a/src/kraken_infinity_grid/database.py b/src/kraken_infinity_grid/database.py index d0a5ec4..d89ad3f 100644 --- a/src/kraken_infinity_grid/database.py +++ b/src/kraken_infinity_grid/database.py @@ -78,6 +78,7 @@ def get_rows( self: Self, table: Table, filters: dict | None = None, + exclude: dict | None = None, ) -> MappingResult: """Fetch rows from the specified table with optional filters.""" LOG.debug( @@ -88,7 +89,9 @@ def get_rows( query = select(table) if filters: for column, value in filters.items(): - query = query.where(table.c[column] == value) + query = query.where( + table.c[column] == value and table.c[column] != exclude, + ) return self.session.execute(query).mappings() def update_row( @@ -150,7 +153,11 @@ def add(self: Self, order: dict) -> None: volume=order["vol"], ) - def get_orders(self: Self, filters: dict | None = None) -> MappingResult: + def get_orders( + self: Self, + filters: dict | None = None, + exclude: dict | None = None, + ) -> MappingResult: """Get orders from the orderbook.""" LOG.debug("Getting orders from the orderbook with filters: %s", filters) if not filters: @@ -158,6 +165,7 @@ def get_orders(self: Self, filters: dict | None = None) -> MappingResult: return self.__db.get_rows( self.__table, filters=filters | {"userref": self.__userref}, + exclude=exclude, ) def remove(self: Self, filters: dict) -> None: diff --git a/src/kraken_infinity_grid/gridbot.py b/src/kraken_infinity_grid/gridbot.py index a4adf59..0eb2744 100644 --- a/src/kraken_infinity_grid/gridbot.py +++ b/src/kraken_infinity_grid/gridbot.py @@ -152,8 +152,6 @@ def __init__( self.amount_per_grid: float = float(config["amount_per_grid"]) self.max_investment: float = config["max_investment"] self.n_open_buy_orders: int = config["n_open_buy_orders"] - self.max_invest_reached: bool = False - self.investment: float = 0 self.fee: float | None = None self.base_currency: str = config["base_currency"] self.quote_currency: str = config["quote_currency"] @@ -558,8 +556,25 @@ def get_order_price( def get_value_of_orders(self: Self, orders: Iterable) -> float: """Returns the overall invested quote that is invested""" LOG.debug("Getting value of open orders...") - investment = 0.0 - for order in orders: - investment += float(order["price"]) * float(order["volume"]) + investment = sum( + float(order["price"]) * float(order["volume"]) for order in orders + ) LOG.debug("Value of open orders: %d %s", investment, self.quote_currency) return investment + + @property + def investment(self: Self) -> float: + """Returns the current investment based on open orders.""" + return self.get_value_of_orders( + orders=self.orderbook.get_orders(), + ) + + @property + def max_investment_reached(self: Self) -> bool: + """Returns True if the maximum investment is reached.""" + # TODO: put this as class variable + new_position_value = self.amount_per_grid + self.amount_per_grid * self.fee + + return (self.max_investment <= self.investment + new_position_value) or ( + self.max_investment <= self.investment + ) diff --git a/src/kraken_infinity_grid/order_management.py b/src/kraken_infinity_grid/order_management.py index 07bdba8..1b97157 100644 --- a/src/kraken_infinity_grid/order_management.py +++ b/src/kraken_infinity_grid/order_management.py @@ -76,9 +76,6 @@ def assign_order_by_txid(self: Self, txid: str, tries: int = 1) -> None: self.__s.orderbook.update(order_to_assign, filters={"txid": txid}) LOG.info("%s: Updated order in orderbook.", txid) - self.__s.investment = self.__s.get_value_of_orders( - orders=self.__s.orderbook.get_orders().all(), # type: ignore[no-untyped-call] - ) LOG.info( "Current invested value: %f / %d %s", self.__s.investment, @@ -159,7 +156,7 @@ def __check_n_open_buy_orders(self: OrderManager) -> None: len(active_buy_orders) < self.__s.n_open_buy_orders and can_place_buy_order and self.__s.pending_txids.count() == 0 - and self.__s.max_investment > self.__s.investment + and not self.__s.max_investment_reached ): fetched_balances: dict[str, float] = self.__s.get_balances() @@ -343,6 +340,13 @@ def new_buy_order( # FIXME: do proper dryrun return + if txid_to_delete is not None: + self.__s.orderbook.remove(filters={"txid": txid_to_delete}) + + # Check if algorithm reached the max_investment value + if self.__s.max_investment_reached: + return + current_balances = self.__s.get_balances() # Compute the target price for the upcoming buy order. @@ -368,76 +372,44 @@ def new_buy_order( self.__s.amount_per_grid + self.__s.amount_per_grid * self.__s.fee ) - # Compute the value of all open orders. - value_of_open_orders = self.__s.get_value_of_orders( - orders=self.__s.orderbook.get_orders(), - ) - # ====================================================================== # Check if there is enough quote balance available to place a buy order. if current_balances["quote_available"] > new_position_value: - # Check if algorithm reached the max_investment value - if self.__s.max_investment <= value_of_open_orders + new_position_value: - if not self.__s.max_invest_reached: - # Ensuring the message below is only sent once - self.__s.max_invest_reached = True - # FIXME: is txid_to_delete even possible with buy orders? - if txid_to_delete is not None: - self.__s.orderbook.remove(filters={"txid": txid_to_delete}) - - message = str( - "Bot reached maximum value, not buying. (max value:" - f" {self.__s.max_investment} {self.__s.quote_currency}," - f" current invested value: {value_of_open_orders}" - f" {self.__s.quote_currency})", - ) - self.__s.t.send_to_telegram(message=message) - return - - # ============================================================== - # Algorithm has not reached max_investment yet and is ready to buy. - else: - self.__s.max_invest_reached = False - LOG.info( - "Placing order to buy %s %s @ %s %s.", - volume, - self.__s.base_currency, - order_price, - self.__s.quote_currency, - ) + LOG.info( + "Placing order to buy %s %s @ %s %s.", + volume, + self.__s.base_currency, + order_price, + self.__s.quote_currency, + ) - # Place new buy order, append id to pending list and delete - # corresponding sell order from local orderbook - placed_order = self.__s.trade.create_order( - ordertype="limit", - side="buy", - volume=volume, - pair=self.__s.symbol, - price=order_price, - userref=self.__s.userref, - validate=self.__s.dry_run, - ) + # Place a new buy order, append txid to pending list and delete + # corresponding sell order from local orderbook. + placed_order = self.__s.trade.create_order( + ordertype="limit", + side="buy", + volume=volume, + pair=self.__s.symbol, + price=order_price, + userref=self.__s.userref, + validate=self.__s.dry_run, + ) - self.__s.pending_txids.add(placed_order["txid"][0]) - if txid_to_delete is not None: - self.__s.orderbook.remove(filters={"txid": txid_to_delete}) - self.__s.om.assign_order_by_txid(placed_order["txid"][0]) - return + self.__s.pending_txids.add(placed_order["txid"][0]) + # if txid_to_delete is not None: + # self.__s.orderbook.remove(filters={"txid": txid_to_delete}) + self.__s.om.assign_order_by_txid(placed_order["txid"][0]) + return # ====================================================================== # Not enough available funds to place a buy order. - else: - if txid_to_delete is not None: - # FIXME: does that make sense in buy orders? - self.__s.orderbook.remove(filters={"txid": txid_to_delete}) - - message = f"⚠️ {self.__s.symbol}" - message += f"├ Not enough {self.__s.quote_currency}" - message += f"├ to buy {volume} {self.__s.base_currency}" - message += f"└ for {order_price} {self.__s.quote_currency}" - self.__s.t.send_to_telegram(message) - LOG.warning("Current balances: %s", current_balances) - return + message = f"⚠️ {self.__s.symbol}" + message += f"├ Not enough {self.__s.quote_currency}" + message += f"├ to buy {volume} {self.__s.base_currency}" + message += f"└ for {order_price} {self.__s.quote_currency}" + self.__s.t.send_to_telegram(message) + LOG.warning("Current balances: %s", current_balances) + return def new_sell_order( # noqa: C901 self: OrderManager, diff --git a/src/kraken_infinity_grid/setup.py b/src/kraken_infinity_grid/setup.py index c182eeb..4a5dd4a 100644 --- a/src/kraken_infinity_grid/setup.py +++ b/src/kraken_infinity_grid/setup.py @@ -268,13 +268,6 @@ def prepare_for_trading(self: Self) -> None: self.__s.ticker = SimpleNamespace(last=float(ticker["c"][0])) - # Check how much is actual invested in this asset pair based on the open - # orders of this instance. - ## - self.__s.investment = self.__s.get_value_of_orders( - orders=self.__s.orderbook.get_orders(), - ) - # Update the orderbook, check for closed, filled, cancelled trades, # and submit new orders if necessary. ## diff --git a/tests/test_gridbot.py b/tests/test_gridbot.py index f66e3cb..28dad36 100644 --- a/tests/test_gridbot.py +++ b/tests/test_gridbot.py @@ -190,6 +190,39 @@ def test_get_value_of_orders(instance: KrakenInfinityGridBot) -> None: assert value == 0 +def test_investment(instance: KrakenInfinityGridBot) -> None: + """Test the investment property.""" + instance.orderbook.get_orders.return_value = [ + {"price": 50000.0, "volume": 0.1}, + {"price": 49000.0, "volume": 0.2}, + ] + assert instance.investment == 14800.0 + + instance.orderbook.get_orders.return_value = [] + assert instance.investment == 0.0 + + +def test_max_investment_reached(instance: KrakenInfinityGridBot) -> None: + """Test the max_investment_reached property.""" + instance.amount_per_grid = 1000.0 + instance.fee = 0.01 + instance.max_investment = 20000.0 + + # Case where max investment is not reached + instance.orderbook.get_orders.return_value = [ + {"price": 50000.0, "volume": 0.1}, + {"price": 49000.0, "volume": 0.2}, + ] + assert not instance.max_investment_reached + + # Case where max investment is reached + instance.orderbook.get_orders.return_value = [ + {"price": 50000.0, "volume": 0.3}, + {"price": 49000.0, "volume": 0.2}, + ] + assert instance.max_investment_reached + + # ============================================================================== # on_message ## diff --git a/tests/test_order_management.py b/tests/test_order_management.py index 86b62bd..5e35e72 100644 --- a/tests/test_order_management.py +++ b/tests/test_order_management.py @@ -161,6 +161,11 @@ def test_check_pending_txids( mock_assign_all_pending_transactions.assert_called_once() +# ============================================================================== +# check_near_buy_orders +## + + @mock.patch.object(OrderManager, "handle_cancel_order") def test_check_near_buy_orders_cancel( mock_handle_cancel_order: mock.Mock, # noqa: ARG001 @@ -194,9 +199,6 @@ def test_check_near_buy_orders_good_distance( strategy.get_active_buy_orders.assert_not_called() -# ============================================================================== -# check_n_open_buy_orders -## @mock.patch.object(OrderManager, "handle_cancel_order") def test_check_near_buy_orders_cancel_no_buys( mock_handle_cancel_order: mock.Mock, # noqa: ARG001 @@ -211,6 +213,11 @@ def test_check_near_buy_orders_cancel_no_buys( strategy.get_active_buy_orders.assert_not_called() +# ============================================================================== +# check_n_open_buy_orders +## + + @mock.patch.object(OrderManager, "handle_arbitrage") def test_check_n_open_buy_orders( mock_handle_arbitrage: mock.Mock, @@ -227,6 +234,7 @@ def test_check_n_open_buy_orders( strategy.n_open_buy_orders = 5 # Current investment strategy.investment = 1000.0 + strategy.max_investment_reached = False # The currently available quote currency strategy.get_balances.return_value = {"quote_available": 10000.0} # No pending transactions @@ -279,6 +287,23 @@ def test_check_n_open_buy_orders( assert mock_handle_arbitrage.call_count == 4 +@mock.patch.object(OrderManager, "handle_arbitrage") +def test_check_n_open_buy_orders_max_investment_reached( + mock_handle_arbitrage: mock.Mock, + strategy: mock.Mock, +) -> None: + """ + Test checking that the function does not place any order if the maximum + investment is reached. + """ + strategy.max_investment_reached = True + + # Ensure no API call is made in order to avoid rate-limiting + strategy.get_balances.assert_not_called() + # Ensure no buy orders are placed + mock_handle_arbitrage.assert_not_called() + + # ============================================================================== @@ -435,25 +460,6 @@ def test_handle_arbitrage( # ============================================================================== # new_buy_order ## -def test_new_buy_order_max_invest_reached( - order_manager: OrderManager, - strategy: mock.Mock, -) -> None: - """ - Test placing a new buy order, that exceeds the maximum investment defined. - """ - strategy.get_balances.return_value = {"quote_available": 1000.0} - strategy.max_investment = 4999.0 - strategy.max_invest_reached = False - strategy.get_value_of_orders.return_value = 5000.0 - strategy.trade.create_order.return_value = {"txid": ["txid1"]} - strategy.trade.truncate.side_effect = [50000.0, 100.0] # price, volume - - order_manager.new_buy_order(order_price=50000.0) - - assert strategy.max_invest_reached - strategy.trade.create_order.assert_not_called() - strategy.pending_txids.add.assert_not_called() def test_new_buy_order( @@ -463,17 +469,29 @@ def test_new_buy_order( """Test placing a new buy order successfully.""" strategy.get_balances.return_value = {"quote_available": 1000.0} strategy.max_investment = 6000.0 + strategy.max_investment_reached = False strategy.get_value_of_orders.return_value = 5000.0 strategy.trade.create_order.return_value = {"txid": ["txid1"]} strategy.trade.truncate.side_effect = [50000.0, 100.0] # price, volume order_manager.new_buy_order(order_price=50000.0) - assert not strategy.max_invest_reached strategy.pending_txids.add.assert_called_once_with("txid1") strategy.trade.create_order.assert_called_once() strategy.om.assign_order_by_txid.assert_called_once_with("txid1") +def test_new_buy_order_max_invest_reached( + order_manager: OrderManager, + strategy: mock.Mock, +) -> None: + """Test placing a new buy order without sufficient funds.""" + strategy.max_investment_reached = True + + order_manager.new_buy_order(order_price=50000.0) + strategy.trade.create_order.assert_not_called() + strategy.pending_txids.add.assert_not_called() + + def test_new_buy_order_not_enough_funds( order_manager: OrderManager, strategy: mock.Mock,