diff --git a/src/kraken_infinity_grid/gridbot.py b/src/kraken_infinity_grid/gridbot.py index 45fb461..7bb921a 100644 --- a/src/kraken_infinity_grid/gridbot.py +++ b/src/kraken_infinity_grid/gridbot.py @@ -215,7 +215,7 @@ def __init__( ) self.database.init_db() - async def on_message( # noqa: C901,PLR0912,PLR0911 + async def on_message( # noqa: C901, PLR0912 self: Self, message: dict | list, ) -> None: @@ -281,10 +281,10 @@ async def on_message( # noqa: C901,PLR0912,PLR0911 # 108297.6, 'change': -2800.0, 'change_pct': -2.62}]} self.configuration.update({"last_price_time": datetime.now()}) - last_price = self.ticker.last + # last_price = self.ticker.last self.ticker = SimpleNamespace(last=float(data[0]["last"])) - if last_price == self.ticker.last: - return + # if last_price == self.ticker.last: + # return if self.unsold_buy_order_txids.count() != 0: self.om.add_missed_sell_orders() diff --git a/src/kraken_infinity_grid/order_management.py b/src/kraken_infinity_grid/order_management.py index 14a3d63..ee157c6 100644 --- a/src/kraken_infinity_grid/order_management.py +++ b/src/kraken_infinity_grid/order_management.py @@ -339,6 +339,9 @@ def new_buy_order( if txid_to_delete is not None: self.__s.orderbook.remove(filters={"txid": txid_to_delete}) + if len(self.__s.get_active_buy_orders().all()) >= self.__s.n_open_buy_orders: # type: ignore[no-untyped-call] + return + # Check if algorithm reached the max_investment value if self.__s.max_investment_reached: return @@ -421,6 +424,8 @@ def new_sell_order( # noqa: C901 # ====================================================================== if txid_id_to_delete is not None: # If corresponding buy order filled + # GridSell always has txid_id_to_delete set. + # Add the txid of the corresponding buy order to the unsold buy # order txids in order to ensure that the corresponding sell order # will be placed - even if placing now fails. @@ -476,7 +481,13 @@ def new_sell_order( # noqa: C901 ), ) - if self.__s.strategy in {"GridHODL", "SWING"}: + if self.__s.strategy in {"GridHODL", "SWING"} or ( + self.__s.strategy == "GridSell" and volume is None + ): + # For GridSell: This is only the case if there is no corresponding + # buy order and the sell order was placed, e.g. due to an extra sell + # order via selling of partially filled buy orders. + # Respect the fee to not reduce the quote currency over time, while # accumulating the base currency. volume = float( @@ -487,7 +498,6 @@ def new_sell_order( # noqa: C901 pair=self.__s.symbol, ), ) - # ====================================================================== # Check if there is enough base currency available for selling. @@ -674,6 +684,7 @@ def handle_cancel_order(self: OrderManager, txid: str) -> None: if self.__s.dry_run: LOG.info("DRY RUN: Not cancelling order: %s", txid) return + self.__s.trade.cancel_order(txid=txid) self.__s.orderbook.remove(filters={"txid": txid}) diff --git a/tests/integration/README.md b/tests/integration/README.md index 75d2f7d..52d0755 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -5,17 +5,34 @@ mainly focus on mocking the external dependencies (python-kraken-sdk) in order to simulate the real-world exchange and the behavior of the system on new ticker events, executions and so on. -**Characteristics:** +**Main Test Cases** + +The following cases are implemented in the integration tests. + +| Test Case | GridSell | GridHODL | SWING | DCA | +| ---------------------------------------------- | -------- | -------- | ----- | --- | +| Run through initial setup | x | x | x | x | +| Placement of $n$ buy orders | x | x | x | x | +| Shifting-up buy orders | x | x | x | x | +| Filling a buy order | x | x | x | x | +| Ensuring $n$ open buy orders after X | x | x | x | x | +| Filling a sell order | x | x | x | - | +| Rapid price drop | x | x | x | x | +| Rapid price rise | x | x | x | x | +| Max investment reached | x | x | x | x | +| Handling surplus from partly filled buy orders | x | x | x | - | + +**Characteristics** - Mocks external dependencies (Kraken API) - Tests multiple components working together (Focuses on component interactions) - Tests full system behavior (Verifies business logic flows end-to-end) - Verifies component interactions (Uses mocks only for external systems) -- Tests database interactions (Uses in-memory database) +- Tests database interactions (Uses sqlite database) - Tests message flow and state changes (Validates state changes) -**It does not include the following:** +**It does not include the following** - Live tests against the live Kraken API -- Tests against the CLI (which would be E2E) +- Tests against the CLI and validating DB (which would be E2E) - Input validation (as the input is fixed) diff --git a/tests/integration/helper.py b/tests/integration/helper.py index 7c89914..38ac4b3 100644 --- a/tests/integration/helper.py +++ b/tests/integration/helper.py @@ -8,7 +8,7 @@ """ Helper data structures used for integration testing. """ import uuid -from typing import Self +from typing import Any, Callable, Self from kraken.spot import Trade, User @@ -32,52 +32,181 @@ def __init__(self: Self, **kwargs) -> None: # noqa: ANN003 "XXBT": {"balance": "100.0", "hold_trade": "0.0"}, "ZUSD": {"balance": "1000000.0", "hold_trade": "0.0"}, } - - def create_order(self: Self, **kwargs) -> None: # noqa: ANN003 - self.__orders |= { - (txid := str(uuid.uuid4()).upper()): { - "userref": kwargs["userref"], - "descr": { - "pair": "BTCUSD", - "type": kwargs["side"], - "ordertype": kwargs["ordertype"], - "price": kwargs["price"], - }, - "status": "open", - "vol": kwargs["volume"], - "vol_exec": "0.0", - "cost": "0.0", - "fee": "0.0", + self.__fee = 0.0025 + + def create_order(self: Self, **kwargs) -> dict: # noqa: ANN003 + """Create a new order and update balances if needed.""" + txid = str(uuid.uuid4()).upper() + order = { + "userref": kwargs["userref"], + "descr": { + "pair": "BTCUSD", + "type": kwargs["side"], + "ordertype": kwargs["ordertype"], + "price": kwargs["price"], }, + "status": "open", + "vol": kwargs["volume"], + "vol_exec": "0.0", + "cost": "0.0", + "fee": "0.0", } + if kwargs["side"] == "buy": + required_balance = float(kwargs["price"]) * float(kwargs["volume"]) + if float(self.__balances["ZUSD"]["balance"]) < required_balance: + raise ValueError("Insufficient balance to create buy order") + self.__balances["ZUSD"]["balance"] = str( + float(self.__balances["ZUSD"]["balance"]) - required_balance, + ) + self.__balances["ZUSD"]["hold_trade"] = str( + float(self.__balances["ZUSD"]["hold_trade"]) + required_balance, + ) + elif kwargs["side"] == "sell": + if float(self.__balances["XXBT"]["balance"]) < float(kwargs["volume"]): + raise ValueError("Insufficient balance to create sell order") + self.__balances["XXBT"]["balance"] = str( + float(self.__balances["XXBT"]["balance"]) - float(kwargs["volume"]), + ) + self.__balances["XXBT"]["hold_trade"] = str( + float(self.__balances["XXBT"]["hold_trade"]) + float(kwargs["volume"]), + ) + + self.__orders[txid] = order return {"txid": [txid]} - def cancel_order(self: Self, txid: str) -> None: - self.__orders.get(txid, {}).update({"status": "canceled"}) - - def fill_order(self: Self, txid: str) -> None: + def fill_order(self: Self, txid: str, volume: float | None = None) -> None: + """Fill an order and update balances.""" order = self.__orders.get(txid, {}) - order["fee"] = str(float(order["vol"]) * 0.0025) - order |= { - "status": "closed", - "vol_exec": order["vol"], - "cost": str(float(order["vol"]) + float(order["fee"])), - } + if not order: + return + + if volume is None: + volume = float(order["vol"]) + + if volume > float(order["vol"]) - float(order["vol_exec"]): + raise ValueError( + "Cannot fill order with volume higher than remaining order volume", + ) + + executed_volume = float(order["vol_exec"]) + volume + remaining_volume = float(order["vol"]) - executed_volume + + order["fee"] = str(float(order["vol_exec"]) * self.__fee) + order["vol_exec"] = str(executed_volume) + order["cost"] = str( + executed_volume * float(order["descr"]["price"]) + float(order["fee"]), + ) + + if remaining_volume <= 0: + order["status"] = "closed" + else: + order["status"] = "open" + + self.__orders[txid] = order + + if order["descr"]["type"] == "buy": + self.__balances["XXBT"]["balance"] = str( + float(self.__balances["XXBT"]["balance"]) + volume, + ) + self.__balances["ZUSD"]["balance"] = str( + float(self.__balances["ZUSD"]["balance"]) - float(order["cost"]), + ) + self.__balances["ZUSD"]["hold_trade"] = str( + float(self.__balances["ZUSD"]["hold_trade"]) - float(order["cost"]), + ) + elif order["descr"]["type"] == "sell": + self.__balances["XXBT"]["balance"] = str( + float(self.__balances["XXBT"]["balance"]) - volume, + ) + self.__balances["XXBT"]["hold_trade"] = str( + float(self.__balances["XXBT"]["hold_trade"]) - volume, + ) + self.__balances["ZUSD"]["balance"] = str( + float(self.__balances["ZUSD"]["balance"]) + float(order["cost"]), + ) + + async def on_ticker_update(self: Self, callback: Callable, last: float) -> None: + """Update the ticker and fill orders if needed.""" + await callback( + { + "channel": "ticker", + "data": [{"symbol": "BTC/USD", "last": last}], + }, + ) + + async def fill_order(txid: str) -> None: + self.fill_order(txid) + await callback( + { + "channel": "executions", + "type": "update", + "data": [{"exec_type": "filled", "order_id": txid}], + }, + ) + + for order in self.get_open_orders()["open"].values(): + if ( + order["descr"]["type"] == "buy" + and float(order["descr"]["price"]) >= last + ) or ( + order["descr"]["type"] == "sell" + and float(order["descr"]["price"]) <= last + ): + await fill_order(order["txid"]) - def cancel_all_orders(self, **kwargs) -> None: # noqa: ARG002,ANN003 + def cancel_order(self: Self, txid: str) -> None: + """Cancel an order and update balances if needed.""" + order = self.__orders.get(txid, {}) + if not order: + return + + order.update({"status": "canceled"}) + self.__orders[txid] = order + + if order["descr"]["type"] == "buy": + executed_cost = float(order["vol_exec"]) * float(order["descr"]["price"]) + remaining_cost = ( + float(order["vol"]) * float(order["descr"]["price"]) - executed_cost + ) + self.__balances["ZUSD"]["balance"] = str( + float(self.__balances["ZUSD"]["balance"]) + remaining_cost, + ) + self.__balances["ZUSD"]["hold_trade"] = str( + float(self.__balances["ZUSD"]["hold_trade"]) - remaining_cost, + ) + self.__balances["XXBT"]["balance"] = str( + float(self.__balances["XXBT"]["balance"]) - float(order["vol_exec"]), + ) + elif order["descr"]["type"] == "sell": + remaining_volume = float(order["vol"]) - float(order["vol_exec"]) + self.__balances["XXBT"]["balance"] = str( + float(self.__balances["XXBT"]["balance"]) + remaining_volume, + ) + self.__balances["XXBT"]["hold_trade"] = str( + float(self.__balances["XXBT"]["hold_trade"]) - remaining_volume, + ) + self.__balances["ZUSD"]["balance"] = str( + float(self.__balances["ZUSD"]["balance"]) - float(order["cost"]), + ) + + def cancel_all_orders(self: Self, **kwargs: Any) -> None: # noqa: ARG002 + """Cancel all open orders.""" for txid in self.__orders: self.cancel_order(txid) - def get_open_orders(self, **kwargs) -> dict: # noqa: ARG002,ANN003 + def get_open_orders(self, **kwargs: Any) -> dict: # noqa: ARG002 + """Get all open orders.""" return { "open": {k: v for k, v in self.__orders.items() if v["status"] == "open"}, } def get_orders_info(self: Self, txid: str) -> dict: + """Get information about a specific order.""" if (order := self.__orders.get(txid, None)) is not None: return {txid: order} return {} - def get_balances(self, **kwargs) -> dict: # noqa: ARG002, ANN003 + def get_balances(self: Self, **kwargs: Any) -> dict: # noqa: ARG002 + """Get the user's current balances.""" return self.__balances diff --git a/tests/integration/test_integration_DCA.py b/tests/integration/test_integration_DCA.py index 3634a32..d5efa28 100644 --- a/tests/integration/test_integration_DCA.py +++ b/tests/integration/test_integration_DCA.py @@ -4,7 +4,7 @@ # GitHub: https://github.com/btschwertfeger # -"""Integration test for the DCA strategy.""" +""" Integration test for the DCA strategy. """ import logging from unittest import mock @@ -51,13 +51,7 @@ async def test_integration_DCA( # noqa: PLR0915 # Mock the initial setup instance.market.get_ticker.return_value = {"XXBTZUSD": {"c": ["50000.0"]}} - - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 50000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 50000.0) assert not instance.is_ready_to_trade # ========================================================================== @@ -103,12 +97,7 @@ async def test_integration_DCA( # noqa: PLR0915 # ========================================================================== # 2. SHIFTING UP BUY ORDERS # Check if shifting up the buy orders works - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 60000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 60000.0) # We should now still have 5 buy orders, but at a higher price. The other # orders should be canceled. @@ -129,20 +118,14 @@ async def test_integration_DCA( # noqa: PLR0915 # ========================================================================== # 3. FILLING A BUY ORDER # Now lets let the price drop a bit so that a buy order gets triggered. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 59000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 59900.0) # Quick re-check ... the price update should not affect any orderbook # changes when dropping. - current_orders = instance.orderbook.get_orders().all() for order, price, volume in zip( - current_orders, - [59405.9, 58817.7, 58235.3, 57658.7, 57087.8], - [0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168], + instance.orderbook.get_orders().all(), + (59405.9, 58817.7, 58235.3, 57658.7, 57087.8), + (0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168), strict=False, ): assert order.userref == instance.userref @@ -151,22 +134,15 @@ async def test_integration_DCA( # noqa: PLR0915 assert order.price == price assert order.volume == volume - # Now trigger the execution of the first buy order - instance.trade.fill_order(current_orders[0].txid) # fill in "upstream" - await instance.on_message( # notify downstream - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": current_orders[0].txid}], - }, - ) + # Filling the first order + await instance.trade.on_ticker_update(instance.on_message, 59000.0) assert instance.orderbook.count() == 4 # Ensure that we have 4 buy orders and *no* sell order for order, price, volume in zip( instance.orderbook.get_orders().all(), - [58817.7, 58235.3, 57658.7, 57087.8], - [0.00170016, 0.00171717, 0.00173434, 0.00175168], + (58817.7, 58235.3, 57658.7, 57087.8), + (0.00170016, 0.00171717, 0.00173434, 0.00175168), strict=True, ): assert order.userref == instance.userref @@ -180,16 +156,12 @@ async def test_integration_DCA( # noqa: PLR0915 # ========================================================================== # 4. ENSURING N OPEN BUY ORDERS # If there is a new price event, the algorithm will place the 5th buy order. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 59100.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 59100.0) + for order, price, volume, side in zip( instance.orderbook.get_orders().all(), - [58817.7, 58235.3, 57658.7, 57087.8, 56522.5], - [0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.0017692], + (58817.7, 58235.3, 57658.7, 57087.8, 56522.5), + (0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.0017692), ["buy"] * 5, strict=True, ): @@ -204,38 +176,18 @@ async def test_integration_DCA( # noqa: PLR0915 # ========================================================================== # 5. RAPID PRICE DROP - FILLING ALL BUY ORDERS # Now check the behavior for a rapid price drop. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 50000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 50000.0) assert instance.ticker.last == 50000.0 - for order in instance.orderbook.get_orders().all(): - instance.trade.fill_order(order.txid) - await instance.on_message( - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": order.txid}], - }, - ) - current_orders = instance.orderbook.get_orders().all() - assert len(current_orders) == 0 + assert len(instance.orderbook.get_orders().all()) == 0 assert instance.orderbook.count() == 0 # ========================================================================== # 6. ENSURE N OPEN BUY ORDERS - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 50100.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 50100.0) for order, price, volume in zip( instance.orderbook.get_orders().all(), - [49603.9, 49112.7, 48626.4, 48144.9, 47668.2], - [0.00201597, 0.00203613, 0.00205649, 0.00207706, 0.00209783], + (49603.9, 49112.7, 48626.4, 48144.9, 47668.2), + (0.00201597, 0.00203613, 0.00205649, 0.00207706, 0.00209783), strict=True, ): assert order.userref == instance.userref @@ -245,3 +197,23 @@ async def test_integration_DCA( # noqa: PLR0915 assert order.volume == volume assert instance.orderbook.count() == 5 + + # ========================================================================== + # 7. MAX INVESTMENT REACHED + + # First ensure that new buy orders can be placed... + assert not instance.max_investment_reached + instance.om.cancel_all_open_buy_orders() + assert instance.orderbook.count() == 0 + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert instance.orderbook.count() == 5 + + # Now with a different max investment, the max investment should be reached + # and no further orders be placed. + assert not instance.max_investment_reached + instance.max_investment = 202 # 200 USD + fee + instance.om.cancel_all_open_buy_orders() + assert instance.orderbook.count() == 0 + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert instance.orderbook.count() == 2 + assert instance.max_investment_reached diff --git a/tests/integration/test_integration_GridHODL.py b/tests/integration/test_integration_GridHODL.py index afe8bf9..9d5403f 100644 --- a/tests/integration/test_integration_GridHODL.py +++ b/tests/integration/test_integration_GridHODL.py @@ -4,12 +4,7 @@ # GitHub: https://github.com/btschwertfeger # -""" GridSell Integration test for GridHODL strategy. - -TODOs: - -- [ ] Check for unfilled surplus due to partly filled buy orders -""" +""" GridSell Integration test for GridHODL strategy. """ import logging from unittest import mock @@ -43,7 +38,7 @@ def config() -> dict: @pytest.mark.asyncio @mock.patch("kraken_infinity_grid.order_management.sleep", return_value=None) @mock.patch("kraken_infinity_grid.gridbot.sleep", return_value=None) -async def test_integration_HODL( # noqa: C901,PLR0915 +async def test_integration_GridHODL( # noqa: PLR0915 mock_sleep_gridbot: mock.Mock, # noqa: ARG001 mock_sleep_order_management: mock.Mock, # noqa: ARG001 instance: KrakenInfinityGridBot, @@ -61,12 +56,7 @@ async def test_integration_HODL( # noqa: C901,PLR0915 # Mock the initial setup instance.market.get_ticker.return_value = {"XXBTZUSD": {"c": ["50000.0"]}} - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 50000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 50000.0) assert not instance.is_ready_to_trade # ========================================================================== @@ -97,8 +87,8 @@ async def test_integration_HODL( # noqa: C901,PLR0915 # which is taken into account. for order, price, volume in zip( instance.orderbook.get_orders().all(), - [49504.9, 49014.7, 48529.4, 48048.9, 47573.1], - [0.00202, 0.0020402, 0.0020606, 0.00208121, 0.00210202], + (49504.9, 49014.7, 48529.4, 48048.9, 47573.1), + (0.00202, 0.0020402, 0.0020606, 0.00208121, 0.00210202), strict=True, ): assert order.price == price @@ -110,19 +100,14 @@ async def test_integration_HODL( # noqa: C901,PLR0915 # ========================================================================== # 2. SHIFTING UP BUY ORDERS # Check if shifting up the buy orders works - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 60000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 60000.0) # We should now still have 5 buy orders, but at a higher price. The other # orders should be canceled. for order, price, volume in zip( instance.orderbook.get_orders().all(), - [59405.9, 58817.7, 58235.3, 57658.7, 57087.8], - [0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168], + (59405.9, 58817.7, 58235.3, 57658.7, 57087.8), + (0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168), strict=True, ): assert order.userref == instance.userref @@ -134,20 +119,15 @@ async def test_integration_HODL( # noqa: C901,PLR0915 # ========================================================================== # 3. FILLING A BUY ORDER # Now lets let the price drop a bit so that a buy order gets triggered. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 59000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 59990.0) # Quick re-check ... the price update should not affect any orderbook # changes when dropping. current_orders = instance.orderbook.get_orders().all() for order, price, volume in zip( instance.orderbook.get_orders().all(), - [59405.9, 58817.7, 58235.3, 57658.7, 57087.8], - [0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168], + (59405.9, 58817.7, 58235.3, 57658.7, 57087.8), + (0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168), strict=False, ): assert order.userref == instance.userref @@ -156,21 +136,14 @@ async def test_integration_HODL( # noqa: C901,PLR0915 assert order.volume == volume # Now trigger the execution of the first buy order - instance.trade.fill_order(current_orders[0].txid) # fill in "upstream" - await instance.on_message( # notify downstream - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": current_orders[0].txid}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 59000.0) assert instance.orderbook.count() == 5 # Ensure that we have 4 buy orders and 1 sell order for order, price, volume, side in zip( instance.orderbook.get_orders().all(), - [58817.7, 58235.3, 57658.7, 57087.8, 59999.9], - [0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.00167504], + (58817.7, 58235.3, 57658.7, 57087.8, 59999.9), + (0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.00167504), ["buy"] * 4 + ["sell"], strict=True, ): @@ -183,18 +156,13 @@ async def test_integration_HODL( # noqa: C901,PLR0915 # ========================================================================== # 4. ENSURING N OPEN BUY ORDERS # If there is a new price event, the algorithm will place the 5th buy order. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 59100.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 59100.0) assert instance.ticker.last == 59100.0 for order, price, volume, side in zip( instance.orderbook.get_orders().all(), - [58817.7, 58235.3, 57658.7, 57087.8, 59999.9, 56522.5], - [0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.00167504, 0.0017692], + (58817.7, 58235.3, 57658.7, 57087.8, 59999.9, 56522.5), + (0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.00167504, 0.0017692), ["buy"] * 4 + ["sell"] + ["buy"], strict=True, ): @@ -209,26 +177,13 @@ async def test_integration_HODL( # noqa: C901,PLR0915 # ========================================================================== # 5. FILLING A SELL ORDER # Now let's see if the sell order gets triggered. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 60000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 60000.0) assert instance.ticker.last == 60000.0 - current_orders = instance.orderbook.get_orders().all() - instance.trade.fill_order(current_orders[4].txid) # fill in "upstream" - await instance.on_message( # notify downstream - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": current_orders[4].txid}], - }, - ) + for order, price, volume, side in zip( instance.orderbook.get_orders().all(), - [58817.7, 58235.3, 57658.7, 57087.8, 56522.5], - [0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.0017692], + (58817.7, 58235.3, 57658.7, 57087.8, 56522.5), + (0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.0017692), ["buy"] * 5, strict=True, ): @@ -248,27 +203,13 @@ async def test_integration_HODL( # noqa: C901,PLR0915 # ========================================================================== # 6. RAPID PRICE DROP - FILLING ALL BUY ORDERS # Now check the behavior for a rapid price drop. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 50000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 50000.0) assert instance.ticker.last == 50000.0 - for order in instance.orderbook.get_orders().all(): - instance.trade.fill_order(order.txid) - await instance.on_message( - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": order.txid}], - }, - ) for order, price, volume in zip( instance.orderbook.get_orders().all(), - [59405.8, 58817.6, 58235.2, 57658.6, 57087.7], - [0.00169179, 0.00170871, 0.0017258, 0.00174306, 0.00176049], + (59405.8, 58817.6, 58235.2, 57658.6, 57087.7), + (0.00169179, 0.00170871, 0.0017258, 0.00174306, 0.00176049), strict=True, ): assert order.userref == instance.userref @@ -280,21 +221,20 @@ async def test_integration_HODL( # noqa: C901,PLR0915 assert instance.orderbook.count() == 5 # ========================================================================== - # 7. ENSURE N OPEN BUY ORDERS - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 59100.0}], - }, - ) + # 7. SELL ALL AND ENSURE N OPEN BUY ORDERS + # Here we temporarily have more than 5 buy orders, since every sell order + # triggers a new buy order, causing us to have 9 buy orders and a single + # sell order. Which is not a problem, since the buy orders that are too + # much will get canceled after the next price update. + await instance.trade.on_ticker_update(instance.on_message, 59100.0) current_orders = instance.orderbook.get_orders().all() - assert len(current_orders) == 10 - assert instance.orderbook.count() == 10 + assert len(current_orders) == 6 + assert instance.orderbook.count() == 6 for order, price, volume in zip( (o for o in current_orders if o.side == "sell"), - [59405.8, 58817.6, 58235.2, 57658.6, 57087.7], - [0.00169179, 0.00170871, 0.0017258, 0.00174306, 0.00176049], + (59405.8,), + (0.00169179,), strict=True, ): assert order.userref == instance.userref @@ -305,8 +245,8 @@ async def test_integration_HODL( # noqa: C901,PLR0915 for order, price, volume in zip( (o for o in current_orders if o.side == "buy"), - [58514.8, 57935.4, 57361.7, 56793.7, 56231.3], - [0.00170896, 0.00172606, 0.00174332, 0.00176075, 0.00177836], + (58514.8, 57935.4, 57361.7, 56793.7, 56231.3), + (0.00170896, 0.00172606, 0.00174332, 0.00176075, 0.00177836), strict=True, ): assert order.userref == instance.userref @@ -314,3 +254,127 @@ async def test_integration_HODL( # noqa: C901,PLR0915 assert order.price == price assert order.volume == volume assert order.side == "buy" + + # ========================================================================== + # 8. MAX INVESTMENT REACHED + + # First ensure that new buy orders can be placed... + assert not instance.max_investment_reached + instance.om.cancel_all_open_buy_orders() + assert instance.orderbook.count() == 1 + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert instance.orderbook.count() == 6 + + # Now with a different max investment, the max investment should be reached + # and no further orders be placed. + assert not instance.max_investment_reached + instance.max_investment = 202 # 200 USD + fee + instance.om.cancel_all_open_buy_orders() + assert instance.orderbook.count() == 1 + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert instance.orderbook.count() == 2 + assert instance.max_investment_reached + + +@pytest.mark.integration +@pytest.mark.asyncio +@mock.patch("kraken_infinity_grid.order_management.sleep", return_value=None) +@mock.patch("kraken_infinity_grid.gridbot.sleep", return_value=None) +async def test_integration_GridHODL_unfilled_surplus( + mock_sleep_gridbot: mock.Mock, # noqa: ARG001 + mock_sleep_order_management: mock.Mock, # noqa: ARG001 + instance: KrakenInfinityGridBot, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Integration test for the GridHODL strategy using pre-generated websocket + messages. + + This test checks if the unfilled surplus is handled correctly. + + unfilled surplus: The base currency volume that was partly filled by an buy + order, before the order was cancelled. + """ + caplog.set_level(logging.INFO) + + # Mock the initial setup + instance.market.get_ticker.return_value = {"XXBTZUSD": {"c": ["50000.0"]}} + + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert not instance.is_ready_to_trade + + # ========================================================================== + # During the following processing, the following steps are done: + # 1. The algorithm prepares for trading (see setup) + # 2. The order manager checks the price range + # 3. The order manager checks for n open buy orders + # 4. The order manager places new orders + await instance.on_message( + { + "channel": "executions", + "type": "snapshot", + "data": [{"exec_type": "canceled", "order_id": "txid0"}], + }, + ) + + # The algorithm should already be ready to trade + assert instance.is_ready_to_trade + + # ========================================================================== + # 1. PLACEMENT OF INITIAL N BUY ORDERS + # After both fake-websocket channels are connected, the algorithm went + # through its full setup and placed orders against the fake Kraken API and + # finally saved those results into the local orderbook table. + + # Check if the five initial buy orders are placed with the expected price + # and volume. Note that the interval is not exactly 0.01 due to the fee + # which is taken into account. + for order, price, volume in zip( + instance.orderbook.get_orders().all(), + (49504.9, 49014.7, 48529.4, 48048.9, 47573.1), + (0.00202, 0.0020402, 0.0020606, 0.00208121, 0.00210202), + strict=True, + ): + assert order.price == price + assert order.volume == volume + assert order.side == "buy" + assert order.symbol == "BTCUSD" + assert order.userref == instance.userref + + # ========================================================================== + # 2. BUYING PARTLY FILLED and ensure that the unfilled surplus is handled + instance.trade.fill_order(instance.orderbook.get_orders().first().txid, 0.002) + assert instance.orderbook.count() == 5 + + balances = instance.trade.get_balances() + assert balances["XXBT"]["balance"] == "100.002" + assert float(balances["ZUSD"]["balance"]) == pytest.approx(999400.99) + + instance.om.handle_cancel_order( + instance.orderbook.get_orders().first().txid, + ) + + assert instance.configuration.get()["vol_of_unfilled_remaining"] == 0.002 + assert ( + instance.configuration.get()["vol_of_unfilled_remaining_max_price"] == 49504.9 + ) + + # ========================================================================== + # 3. SELLING THE UNFILLED SURPLUS + # The sell-check is done only during cancelling orders, as this is the + # only time where this amount is touched. So we need to create another + # partly filled order. + + instance.om.new_buy_order(49504.9) + assert len(instance.trade.get_open_orders()["open"]) == 5 + + order = next(o for o in instance.orderbook.get_orders().all() if o.price == 49504.9) + instance.trade.fill_order(order["txid"], 0.002) + instance.om.handle_cancel_order(order["txid"]) + + assert len(instance.trade.get_open_orders()["open"]) == 5 + assert instance.configuration.get()["vol_of_unfilled_remaining"] == 0.0 + + sell_orders = instance.orderbook.get_orders(filters={"side": "sell"}).all() + assert sell_orders[0].price == 50500.0 + assert sell_orders[0].volume == pytest.approx(0.00199014) diff --git a/tests/integration/test_integration_GridSell.py b/tests/integration/test_integration_GridSell.py index c2fac9d..d368e69 100644 --- a/tests/integration/test_integration_GridSell.py +++ b/tests/integration/test_integration_GridSell.py @@ -4,12 +4,7 @@ # GitHub: https://github.com/btschwertfeger # -""" GridSell Integration test for GridSell strategy. - -TODOs: - -- [ ] Check for unfilled surplus due to partly filled buy orders -""" +""" GridSell Integration test for GridSell strategy. """ import logging from unittest import mock @@ -43,7 +38,7 @@ def config() -> dict: @pytest.mark.asyncio @mock.patch("kraken_infinity_grid.order_management.sleep", return_value=None) @mock.patch("kraken_infinity_grid.gridbot.sleep", return_value=None) -async def test_integration_GridSell( # noqa: PLR0915,C901 +async def test_integration_GridSell( # noqa: PLR0915 mock_sleep_gridbot: mock.Mock, # noqa: ARG001 mock_sleep_order_management: mock.Mock, # noqa: ARG001 instance: KrakenInfinityGridBot, @@ -58,11 +53,15 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 API, the algorithm and database. The test tries to cover almost all cases that could happen during the trading process. - It tests: * Handling of ticker updates * Handling of execution updates * - Initialization after the ticker and execution channels are connected * - Placing of buy orders and shifting them up * Execution of buy orders and - placement of corresponding sell orders * Execution of sell orders * Full - database interactions using in-memory SQLite + It tests: + + * Handling of ticker updates + * Handling of execution updates + * Initialization after the ticker and execution channels are connected + * Placing of buy orders and shifting them up + * Execution of buy orders and placement of corresponding sell orders + * Execution of sell orders + * Full database interactions using SQLite It does not cover the following cases: @@ -78,12 +77,7 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 # Mock the initial setup instance.market.get_ticker.return_value = {"XXBTZUSD": {"c": ["50000.0"]}} - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 50000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 50000.0) assert not instance.is_ready_to_trade # ========================================================================== @@ -129,19 +123,14 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 # ========================================================================== # 2. SHIFTING UP BUY ORDERS # Check if shifting up the buy orders works - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 60000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 60000.0) # We should now still have 5 buy orders, but at a higher price. The other # orders should be canceled. for order, price, volume in zip( instance.orderbook.get_orders().all(), - [59405.9, 58817.7, 58235.3, 57658.7, 57087.8], - [0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168], + (59405.9, 58817.7, 58235.3, 57658.7, 57087.8), + (0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168), strict=True, ): assert order.userref == instance.userref @@ -153,20 +142,15 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 # ========================================================================== # 3. FILLING A BUY ORDER # Now lets let the price drop a bit so that a buy order gets triggered. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 59000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 59900.0) # Quick re-check ... the price update should not affect any orderbook # changes when dropping. current_orders = instance.orderbook.get_orders().all() for order, price, volume, side in zip( current_orders, - [59405.9, 58817.7, 58235.3, 57658.7, 57087.8], - [0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168], + (59405.9, 58817.7, 58235.3, 57658.7, 57087.8), + (0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168), ["buy"] * 5, strict=False, ): @@ -176,17 +160,9 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 assert order.side == side assert order.volume == volume - # Now trigger the execution of the first buy order - instance.trade.fill_order(current_orders[0].txid) # fill in "upstream" - await instance.on_message( # notify downstream - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": current_orders[0].txid}], - }, - ) assert instance.orderbook.count() == 5 + await instance.trade.on_ticker_update(instance.on_message, 59000.0) # Ensure that we have 4 buy orders and 1 sell order for order, price, volume, side in zip( instance.orderbook.get_orders().all(), @@ -204,17 +180,12 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 # ========================================================================== # 4. ENSURING N OPEN BUY ORDERS # If there is a new price event, the algorithm will place the 5th buy order. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 59100.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 59100.0) for order, price, volume, side in zip( instance.orderbook.get_orders().all(), - [58817.7, 58235.3, 57658.7, 57087.8, 59999.9, 56522.5], - [0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.00168333, 0.0017692], + (58817.7, 58235.3, 57658.7, 57087.8, 59999.9, 56522.5), + (0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.00168333, 0.0017692), ["buy"] * 4 + ["sell"] + ["buy"], strict=True, ): @@ -227,27 +198,13 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 # ========================================================================== # 5. FILLING A SELL ORDER # Now let's see if the sell order gets triggered. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 60000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 60000.0) assert instance.ticker.last == 60000.0 - current_orders = instance.orderbook.get_orders().all() - instance.trade.fill_order(current_orders[4].txid) # fill in "upstream" - await instance.on_message( # notify downstream - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": current_orders[4].txid}], - }, - ) for order, price, volume, side in zip( instance.orderbook.get_orders().all(), - [58817.7, 58235.3, 57658.7, 57087.8, 56522.5], - [0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.0017692], + (58817.7, 58235.3, 57658.7, 57087.8, 56522.5), + (0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.0017692), ["buy"] * 5, strict=True, ): @@ -265,27 +222,13 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 # ========================================================================== # 6. RAPID PRICE DROP - FILLING ALL BUY ORDERS # Now check the behavior for a rapid price drop. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 50000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 50000.0) assert instance.ticker.last == 50000.0 - for order in instance.orderbook.get_orders().all(): - instance.trade.fill_order(order.txid) - await instance.on_message( - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": order.txid}], - }, - ) for order, price, volume in zip( instance.orderbook.get_orders().all(), - [59405.8, 58817.6, 58235.2, 57658.6, 57087.7], - [0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.0017692], + (59405.8, 58817.6, 58235.2, 57658.6, 57087.7), + (0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.0017692), strict=True, ): assert order.userref == instance.userref @@ -296,19 +239,14 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 # ========================================================================== # 7. ENSURE N OPEN BUY ORDERS - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 59100.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 59100.0) current_orders = instance.orderbook.get_orders().all() - assert len(current_orders) == 10 + assert len(current_orders) == 6 for order, price, volume in zip( (o for o in current_orders if o.side == "sell"), - [59405.8, 58817.6, 58235.2, 57658.6, 57087.7], - [0.00170016, 0.00171717, 0.00173434, 0.00175168, 0.0017692], + (59405.8,), + (0.00170016,), strict=True, ): assert order.userref == instance.userref @@ -319,8 +257,8 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 for order, price, volume in zip( (o for o in current_orders if o.side == "buy"), - [58514.8, 57935.4, 57361.7, 56793.7, 56231.3], - [0.00170896, 0.00172606, 0.00174332, 0.00176075, 0.00177836], + (58514.8, 57935.4, 57361.7, 56793.7, 56231.3), + (0.00170896, 0.00172606, 0.00174332, 0.00176075, 0.00177836), strict=True, ): assert order.userref == instance.userref @@ -329,4 +267,128 @@ async def test_integration_GridSell( # noqa: PLR0915,C901 assert order.volume == volume assert order.side == "buy" - assert instance.orderbook.count() == 10 + assert instance.orderbook.count() == 6 + + # ========================================================================== + # 8. MAX INVESTMENT REACHED + + # First ensure that new buy orders can be placed... + assert not instance.max_investment_reached + instance.om.cancel_all_open_buy_orders() + assert instance.orderbook.count() == 1 + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert instance.orderbook.count() == 6 + + # Now with a different max investment, the max investment should be reached + # and no further orders be placed. + assert not instance.max_investment_reached + instance.max_investment = 202 # 200 USD + fee + instance.om.cancel_all_open_buy_orders() + assert instance.orderbook.count() == 1 + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert instance.orderbook.count() == 2 + assert instance.max_investment_reached + + +@pytest.mark.integration +@pytest.mark.asyncio +@mock.patch("kraken_infinity_grid.order_management.sleep", return_value=None) +@mock.patch("kraken_infinity_grid.gridbot.sleep", return_value=None) +async def test_integration_GridSell_unfilled_surplus( + mock_sleep_gridbot: mock.Mock, # noqa: ARG001 + mock_sleep_order_management: mock.Mock, # noqa: ARG001 + instance: KrakenInfinityGridBot, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Integration test for the GridSell strategy using pre-generated websocket + messages. + + This test checks if the unfilled surplus is handled correctly. + + unfilled surplus: The base currency volume that was partly filled by an buy + order, before the order was cancelled. + """ + caplog.set_level(logging.INFO) + + # Mock the initial setup + instance.market.get_ticker.return_value = {"XXBTZUSD": {"c": ["50000.0"]}} + + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert not instance.is_ready_to_trade + + # ========================================================================== + # During the following processing, the following steps are done: + # 1. The algorithm prepares for trading (see setup) + # 2. The order manager checks the price range + # 3. The order manager checks for n open buy orders + # 4. The order manager places new orders + await instance.on_message( + { + "channel": "executions", + "type": "snapshot", + "data": [{"exec_type": "canceled", "order_id": "txid0"}], + }, + ) + + # The algorithm should already be ready to trade + assert instance.is_ready_to_trade + + # ========================================================================== + # 1. PLACEMENT OF INITIAL N BUY ORDERS + # After both fake-websocket channels are connected, the algorithm went + # through its full setup and placed orders against the fake Kraken API and + # finally saved those results into the local orderbook table. + + # Check if the five initial buy orders are placed with the expected price + # and volume. Note that the interval is not exactly 0.01 due to the fee + # which is taken into account. + for order, price, volume in zip( + instance.orderbook.get_orders().all(), + (49504.9, 49014.7, 48529.4, 48048.9, 47573.1), + (0.00202, 0.0020402, 0.0020606, 0.00208121, 0.00210202), + strict=True, + ): + assert order.price == price + assert order.volume == volume + assert order.side == "buy" + assert order.symbol == "BTCUSD" + assert order.userref == instance.userref + + # ========================================================================== + # 2. BUYING PARTLY FILLED and ensure that the unfilled surplus is handled + instance.trade.fill_order(instance.orderbook.get_orders().first().txid, 0.002) + assert instance.orderbook.count() == 5 + + balances = instance.trade.get_balances() + assert balances["XXBT"]["balance"] == "100.002" + assert float(balances["ZUSD"]["balance"]) == pytest.approx(999400.99) + + instance.om.handle_cancel_order( + instance.orderbook.get_orders().first().txid, + ) + + assert instance.configuration.get()["vol_of_unfilled_remaining"] == 0.002 + assert ( + instance.configuration.get()["vol_of_unfilled_remaining_max_price"] == 49504.9 + ) + + # ========================================================================== + # 3. SELLING THE UNFILLED SURPLUS + # The sell-check is done only during cancelling orders, as this is the + # only time where this amount is touched. So we need to create another + # partly filled order. + + instance.om.new_buy_order(49504.9) + assert len(instance.trade.get_open_orders()["open"]) == 5 + + order = next(o for o in instance.orderbook.get_orders().all() if o.price == 49504.9) + instance.trade.fill_order(order["txid"], 0.002) + instance.om.handle_cancel_order(order["txid"]) + + assert len(instance.trade.get_open_orders()["open"]) == 5 + assert instance.configuration.get()["vol_of_unfilled_remaining"] == 0.0 + + sell_orders = instance.orderbook.get_orders(filters={"side": "sell"}).all() + assert sell_orders[0].price == 50500.0 + assert sell_orders[0].volume == pytest.approx(0.00199014) diff --git a/tests/integration/test_integration_SWING.py b/tests/integration/test_integration_SWING.py index 94c862a..3d0fd11 100644 --- a/tests/integration/test_integration_SWING.py +++ b/tests/integration/test_integration_SWING.py @@ -4,12 +4,7 @@ # GitHub: https://github.com/btschwertfeger # -""" GridSell Integration test for SWING strategy. - -TODOs: - -- [ ] Check for unfilled surplus due to partly filled buy orders -""" +""" GridSell Integration test for SWING strategy. """ import logging from unittest import mock @@ -43,7 +38,7 @@ def config() -> dict: @pytest.mark.asyncio @mock.patch("kraken_infinity_grid.order_management.sleep", return_value=None) @mock.patch("kraken_infinity_grid.gridbot.sleep", return_value=None) -async def test_integration_SWING( # noqa: C901 +async def test_integration_SWING( mock_sleep_gridbot: mock.Mock, # noqa: ARG001 mock_sleep_order_management: mock.Mock, # noqa: ARG001 instance: KrakenInfinityGridBot, @@ -58,12 +53,7 @@ async def test_integration_SWING( # noqa: C901 # Mock the initial setup instance.market.get_ticker.return_value = {"XXBTZUSD": {"c": ["50000.0"]}} - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 50000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 50000.0) assert not instance.is_ready_to_trade # ========================================================================== @@ -100,8 +90,8 @@ async def test_integration_SWING( # noqa: C901 # existing BTC to sell. for order, price, volume, side in zip( current_orders, - [49504.9, 49014.7, 48529.4, 48048.9, 47573.1, 51005.0], - [0.00202, 0.0020402, 0.0020606, 0.00208121, 0.00210202, 0.00197044], + (49504.9, 49014.7, 48529.4, 48048.9, 47573.1, 51005.0), + (0.00202, 0.0020402, 0.0020606, 0.00208121, 0.00210202, 0.00197044), ["buy"] * 5 + ["sell"], strict=True, ): @@ -114,30 +104,13 @@ async def test_integration_SWING( # noqa: C901 # 2. RAPID PRICE DROP - FILLING ALL BUY ORDERS + CREATING SELL ORDERS # Now check the behavior for a rapid price drop. # It should fill the buy orders and place 6 new sell orders. - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 40000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 40000.0) assert instance.ticker.last == 40000.0 - for order in instance.orderbook.get_orders().all(): - if order.side == "buy": - instance.trade.fill_order(order.txid) - await instance.on_message( - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": order.txid}], - }, - ) - current_orders = instance.orderbook.get_orders().all() - assert len(current_orders) == 6 for order, price, volume in zip( - current_orders, - [51005.0, 49999.9, 49504.8, 49014.6, 48529.3, 48048.8], - [0.00197044, 0.00201005, 0.00203015, 0.00205046, 0.00207096, 0.00209167], + instance.orderbook.get_orders().all(), + (51005.0, 49999.9, 49504.8, 49014.6, 48529.3, 48048.8), + (0.00197044, 0.00201005, 0.00203015, 0.00205046, 0.00207096, 0.00209167), strict=True, ): assert order.side == "sell" @@ -146,19 +119,14 @@ async def test_integration_SWING( # noqa: C901 # ========================================================================== # 3. NEW TICKER TO ENSURE N OPEN BUY ORDERS - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 40000.1}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 40000.1) current_orders = instance.orderbook.get_orders().all() assert len(current_orders) == 11 for order, price, volume in zip( (o for o in current_orders if o.side == "sell"), - [51005.0, 49999.9, 49504.8, 49014.6, 48529.3, 48048.8], - [0.00197044, 0.00201005, 0.00203015, 0.00205046, 0.00207096, 0.00209167], + (51005.0, 49999.9, 49504.8, 49014.6, 48529.3, 48048.8), + (0.00197044, 0.00201005, 0.00203015, 0.00205046, 0.00207096, 0.00209167), strict=True, ): assert order.price == price @@ -166,8 +134,8 @@ async def test_integration_SWING( # noqa: C901 for order, price, volume in zip( (o for o in current_orders if o.side == "buy"), - [39604.0, 39211.8, 38823.5, 38439.1, 38058.5], - [0.00252499, 0.00255025, 0.00257575, 0.00260151, 0.00262753], + (39604.0, 39211.8, 38823.5, 38439.1, 38058.5), + (0.00252499, 0.00255025, 0.00257575, 0.00260151, 0.00262753), strict=True, ): assert order.price == price @@ -176,52 +144,14 @@ async def test_integration_SWING( # noqa: C901 # ========================================================================== # 4. FILLING SELL ORDERS WHILE SHIFTING UP BUY ORDERS # Check if shifting up the buy orders works - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 60000.0}], - }, - ) + await instance.trade.on_ticker_update(instance.on_message, 60000.0) assert instance.ticker.last == 60000.0 - for order in instance.orderbook.get_orders().all(): - if order.side == "sell": - instance.trade.fill_order(order.txid) - await instance.on_message( - { - "channel": "executions", - "type": "update", - "data": [{"exec_type": "filled", "order_id": order.txid}], - }, - ) - - # We should now still have 10 buy orders, 5 of the already existing ones and - # 5 based on the executed sell orders. + + # We should now still have 5 buy orders. for order, price, volume in zip( instance.orderbook.get_orders().all(), - [ - 59405.9, - 58817.7, - 58235.3, - 57658.7, - 57087.8, - 50500.0, - 49504.8, - 49014.6, - 48529.3, - 48048.8, - ], - [ - 0.00168333, - 0.00170016, - 0.00171717, - 0.00173434, - 0.00175168, - 0.00198019, - 0.00202, - 0.0020402, - 0.00206061, - 0.00208121, - ], + (59405.9, 58817.7, 58235.3, 57658.7, 57087.8), + (0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168), strict=True, ): assert order.price == price @@ -230,24 +160,142 @@ async def test_integration_SWING( # noqa: C901 assert order.symbol == "BTCUSD" assert order.side == "buy" - # ========================================================================== - # 5. ENSURING THAT ONLY N BUY ORDERS EXIST - # ... the highest n buy orders ... +@pytest.mark.integration +@pytest.mark.asyncio +@mock.patch("kraken_infinity_grid.order_management.sleep", return_value=None) +@mock.patch("kraken_infinity_grid.gridbot.sleep", return_value=None) +async def test_integration_SWING_unfilled_surplus( + mock_sleep_gridbot: mock.Mock, # noqa: ARG001 + mock_sleep_order_management: mock.Mock, # noqa: ARG001 + instance: KrakenInfinityGridBot, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Integration test for the SWING strategy using pre-generated websocket + messages. + + This test checks if the unfilled surplus is handled correctly. + + unfilled surplus: The base currency volume that was partly filled by an buy + order, before the order was cancelled. + """ + caplog.set_level(logging.INFO) + + # Mock the initial setup + instance.market.get_ticker.return_value = {"XXBTZUSD": {"c": ["50000.0"]}} + + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert not instance.is_ready_to_trade + + # ========================================================================== + # During the following processing, the following steps are done: + # 1. The algorithm prepares for trading (see setup) + # 2. The order manager checks the price range + # 3. The order manager checks for n open buy orders + # 4. The order manager places new orders await instance.on_message( { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 60000.1}], + "channel": "executions", + "type": "snapshot", + "data": [{"exec_type": "canceled", "order_id": "txid0"}], }, ) - for order, price, volume in zip( + # The algorithm should already be ready to trade + assert instance.is_ready_to_trade + + # ========================================================================== + # 1. PLACEMENT OF INITIAL N BUY ORDERS + # After both fake-websocket channels are connected, the algorithm went + # through its full setup and placed orders against the fake Kraken API and + # finally saved those results into the local orderbook table. + + # Check if the five initial buy orders are placed with the expected price + # and volume. Note that the interval is not exactly 0.01 due to the fee + # which is taken into account. + for order, price, volume, side in zip( instance.orderbook.get_orders().all(), - [59405.9, 58817.7, 58235.3, 57658.7, 57087.8], - [0.00168333, 0.00170016, 0.00171717, 0.00173434, 0.00175168], - strict=False, + (49504.9, 49014.7, 48529.4, 48048.9, 47573.1, 51005.0), + (0.00202, 0.0020402, 0.0020606, 0.00208121, 0.00210202, 0.00197044), + ["buy"] * 5 + ["sell"], + strict=True, ): - assert order.userref == instance.userref - assert order.symbol == "BTCUSD" assert order.price == price assert order.volume == volume + assert order.side == side + assert order.symbol == "BTCUSD" + assert order.userref == instance.userref + + balances = instance.trade.get_balances() + assert float(balances["XXBT"]["balance"]) == pytest.approx(99.99802956) + assert float(balances["XXBT"]["hold_trade"]) == pytest.approx(0.00197044) + assert float(balances["ZUSD"]["balance"]) == pytest.approx(999500.0011705891) + assert float(balances["ZUSD"]["hold_trade"]) == pytest.approx(499.99882941100003) + + # ========================================================================== + # 2. BUYING PARTLY FILLED and ensure that the unfilled surplus is handled + instance.trade.fill_order(instance.orderbook.get_orders().first().txid, 0.002) + assert instance.orderbook.count() == 6 + + # We have not 100.002 here, since the GridSell is initially creating a sell + # order which reduces the available base balance. + balances = instance.trade.get_balances() + assert float(balances["XXBT"]["balance"]) == pytest.approx(100.00002956) + assert float(balances["XXBT"]["hold_trade"]) == pytest.approx(0.00197044) + assert float(balances["ZUSD"]["balance"]) == pytest.approx(999400.9913705891) + assert float(balances["ZUSD"]["hold_trade"]) == pytest.approx(400.98902941100005) + + instance.om.handle_cancel_order( + instance.orderbook.get_orders().first().txid, + ) + + assert instance.configuration.get()["vol_of_unfilled_remaining"] == 0.002 + assert ( + instance.configuration.get()["vol_of_unfilled_remaining_max_price"] == 49504.9 + ) + + # ========================================================================== + # 3. SELLING THE UNFILLED SURPLUS + # The sell-check is done only during cancelling orders, as this is the + # only time where this amount is touched. So we need to create another + # partly filled order. + + instance.om.new_buy_order(49504.9) + assert len(instance.trade.get_open_orders()["open"]) == 6 + + order = next(o for o in instance.orderbook.get_orders().all() if o.price == 49504.9) + instance.trade.fill_order(order["txid"], 0.002) + instance.om.handle_cancel_order(order["txid"]) + + # We will have 6 orders, 2 sell and 3 buy. We don't have 5 buy orders since + # we don't triggered the price update. + assert len(instance.trade.get_open_orders()["open"]) == 6 + # Ensure that the unfilled surplus is now 0.0 + assert instance.configuration.get()["vol_of_unfilled_remaining"] == 0.0 + + # Get the sell order that was placed as extra sell order. This one is + # 'interval' above the the highest buy price. + sell_orders = instance.orderbook.get_orders(filters={"side": "sell", "id": 7}).all() + assert sell_orders[0].price == 50500.0 + assert sell_orders[0].volume == pytest.approx(0.00199014) + + # ========================================================================== + # 4. MAX INVESTMENT REACHED + + # First ensure that new buy orders can be placed... + assert not instance.max_investment_reached + instance.om.cancel_all_open_buy_orders() + assert instance.orderbook.count() == 2 # two sell orders + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert instance.orderbook.count() == 7 # 2 sell, 5 buy + + # Now with a different max investment, the max investment should be reached + # and no further orders be placed. + assert not instance.max_investment_reached + instance.max_investment = 202 # 200 USD + fee + instance.om.cancel_all_open_buy_orders() + assert instance.orderbook.count() == 2 + await instance.trade.on_ticker_update(instance.on_message, 50000.0) + assert instance.orderbook.count() == 2 + assert instance.max_investment_reached diff --git a/tests/test_gridbot.py b/tests/test_gridbot.py index 36045b1..c8ff371 100644 --- a/tests/test_gridbot.py +++ b/tests/test_gridbot.py @@ -315,19 +315,17 @@ async def test_on_message_ticker(instance: KrakenInfinityGridBot) -> None: ], }, ) - # == Ensure not checking price range if price does not change - instance.om.check_price_range.assert_not_called() + assert instance.ticker.last == 50000.0 + + # == Ensure checking price range if price does not change + instance.om.check_price_range.assert_called_once() # == Ensure saving the last price time instance.configuration.update.assert_called_once() - assert instance.ticker.last == 50000.0 - - # == Ensure doing nothing if the price did not change - instance.om.check_price_range.assert_not_called() # == Simulate a finished buy order which was missed to sell instance.unsold_buy_order_txids.count.return_value = 1 - instance.om.add_missed_sell_orders.assert_not_called() + instance.om.add_missed_sell_orders.assert_called_once() # Trigger another price update await instance.on_message( @@ -342,10 +340,10 @@ async def test_on_message_ticker(instance: KrakenInfinityGridBot) -> None: }, ) # == Ensure missed sell orders will be handled in case there are any - instance.om.add_missed_sell_orders.assert_called_once() + assert instance.om.add_missed_sell_orders.call_count == 2 # == Ensure price range check is performed on new price - instance.om.check_price_range.assert_called_once() + assert instance.om.check_price_range.call_count == 2 @pytest.mark.asyncio diff --git a/tests/test_order_management.py b/tests/test_order_management.py index b29a3b0..b2f2d13 100644 --- a/tests/test_order_management.py +++ b/tests/test_order_management.py @@ -39,6 +39,7 @@ def strategy() -> mock.Mock: strategy.dry_run = False strategy.max_investment = 10000 strategy.amount_per_grid = 100 + strategy.n_open_buy_orders = 5 strategy.interval = 0.01 strategy.fee = 0.0026 strategy.symbol = "BTC/USD" @@ -49,6 +50,7 @@ def strategy() -> mock.Mock: strategy.ticker = mock.Mock() strategy.ticker.last = 50000.0 strategy.save_exit = sys.exit + strategy.max_investment_reached = False strategy.amount_per_grid_plus_fee = strategy.amount_per_grid * (1 + strategy.fee) return strategy @@ -474,6 +476,8 @@ def test_new_buy_order( 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 + # No other open orders + strategy.get_active_buy_orders.return_value.all.return_value = [] order_manager.new_buy_order(order_price=50000.0) strategy.pending_txids.add.assert_called_once_with("txid1") @@ -487,12 +491,15 @@ def test_new_buy_order_max_invest_reached( ) -> None: """Test placing a new buy order without sufficient funds.""" strategy.max_investment_reached = True + # No other open orders + strategy.get_active_buy_orders.return_value.all.return_value = [] order_manager.new_buy_order(order_price=50000.0) strategy.trade.create_order.assert_not_called() strategy.pending_txids.add.assert_not_called() +@pytest.mark.wip def test_new_buy_order_not_enough_funds( order_manager: OrderManager, strategy: mock.Mock, @@ -502,10 +509,13 @@ def test_new_buy_order_not_enough_funds( 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 + # No other open orders + strategy.get_active_buy_orders.return_value.all.return_value = [] order_manager.new_buy_order(order_price=50000.0) strategy.trade.create_order.assert_not_called() strategy.pending_txids.add.assert_not_called() + strategy.t.send_to_telegram.assert_called_once() # ==============================================================================