From e3bf38151edf67d8102f7c3670b1fe8a0b9969a5 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Fri, 10 Jan 2025 09:00:49 +0100 Subject: [PATCH 1/4] Stash --- src/kraken_infinity_grid/order_management.py | 1 + tests/integration/helper.py | 39 ++++++++-- .../integration/test_integration_GridHODL.py | 75 ++++++++++++++++++- 3 files changed, 106 insertions(+), 9 deletions(-) diff --git a/src/kraken_infinity_grid/order_management.py b/src/kraken_infinity_grid/order_management.py index 9133d26..179ce47 100644 --- a/src/kraken_infinity_grid/order_management.py +++ b/src/kraken_infinity_grid/order_management.py @@ -675,6 +675,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/helper.py b/tests/integration/helper.py index 7c89914..989d62c 100644 --- a/tests/integration/helper.py +++ b/tests/integration/helper.py @@ -6,9 +6,8 @@ # pylint: disable=arguments-differ """ Helper data structures used for integration testing. """ - import uuid -from typing import Self +from typing import Any, Self from kraken.spot import Trade, User @@ -50,11 +49,22 @@ def create_order(self: Self, **kwargs) -> None: # noqa: ANN003 "fee": "0.0", }, } - return {"txid": [txid]} def cancel_order(self: Self, txid: str) -> None: - self.__orders.get(txid, {}).update({"status": "canceled"}) + order = self.__orders.get(txid, {}) + order.update({"status": "canceled"}) + self.__orders[txid] = order + + if order["vol_exec"] != "0.0" and order["descr"]["type"] == "buy": + self.__balances["XXBT"]["balance"] = float( + self.__balances["ZUSD"]["balance"], + ) + float(order["vol_exec"]) + self.__balances["ZUSD"]["balance"] = float( + self.__balances["ZUSD"]["balance"], + ) - float(order["cost"]) + + # Sell orders do not get canceled... def fill_order(self: Self, txid: str) -> None: order = self.__orders.get(txid, {}) @@ -64,20 +74,33 @@ def fill_order(self: Self, txid: str) -> None: "vol_exec": order["vol"], "cost": str(float(order["vol"]) + float(order["fee"])), } - - def cancel_all_orders(self, **kwargs) -> None: # noqa: ARG002,ANN003 + self.__orders[txid] = order + self.__balances["XXBT"]["balance"] = str( + float(self.__balances["XXBT"]["balance"]) - float(order["vol_exec"]), + ) + self.__balances["ZUSD"]["balance"] = str( + float(self.__balances["ZUSD"]["balance"]) - float(order["cost"]), + ) + + def cancel_all_orders(self: Self, **kwargs: Any) -> None: # noqa: ARG002 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 return { "open": {k: v for k, v in self.__orders.items() if v["status"] == "open"}, } + def update_order(self: Self, txid: str, **kwargs: Any) -> dict: + order = self.__orders.get(txid, {}) + order.update(kwargs) + self.__orders[txid] = order + return {txid: order} + def get_orders_info(self: Self, txid: str) -> dict: 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 return self.__balances diff --git a/tests/integration/test_integration_GridHODL.py b/tests/integration/test_integration_GridHODL.py index afe8bf9..8c29314 100644 --- a/tests/integration/test_integration_GridHODL.py +++ b/tests/integration/test_integration_GridHODL.py @@ -43,7 +43,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: C901,PLR0915 mock_sleep_gridbot: mock.Mock, # noqa: ARG001 mock_sleep_order_management: mock.Mock, # noqa: ARG001 instance: KrakenInfinityGridBot, @@ -314,3 +314,76 @@ async def test_integration_HODL( # noqa: C901,PLR0915 assert order.price == price assert order.volume == volume assert order.side == "buy" + + +@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.on_message( + { + "channel": "ticker", + "data": [{"symbol": "BTC/USD", "last": 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 + + # ========================================================================== From 5aeed9f9f669a653364ff503454e4f7a51999529 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Fri, 10 Jan 2025 18:40:44 +0100 Subject: [PATCH 2/4] Extend the integration tests --- src/kraken_infinity_grid/gridbot.py | 8 +- src/kraken_infinity_grid/order_management.py | 3 + tests/integration/helper.py | 198 ++++++++++++---- tests/integration/test_integration_DCA.py | 86 ++----- .../integration/test_integration_GridHODL.py | 172 ++++++-------- .../integration/test_integration_GridSell.py | 214 +++++++++++------- tests/integration/test_integration_SWING.py | 179 ++++++++------- tests/test_gridbot.py | 16 +- tests/test_order_management.py | 7 + 9 files changed, 488 insertions(+), 395 deletions(-) diff --git a/src/kraken_infinity_grid/gridbot.py b/src/kraken_infinity_grid/gridbot.py index 387f71d..2c65c4b 100644 --- a/src/kraken_infinity_grid/gridbot.py +++ b/src/kraken_infinity_grid/gridbot.py @@ -210,7 +210,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: @@ -276,10 +276,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 179ce47..0bca356 100644 --- a/src/kraken_infinity_grid/order_management.py +++ b/src/kraken_infinity_grid/order_management.py @@ -340,6 +340,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 diff --git a/tests/integration/helper.py b/tests/integration/helper.py index 989d62c..ce15c45 100644 --- a/tests/integration/helper.py +++ b/tests/integration/helper.py @@ -7,7 +7,7 @@ """ Helper data structures used for integration testing. """ import uuid -from typing import Any, Self +from typing import Any, Callable, Self from kraken.spot import Trade, User @@ -31,76 +31,188 @@ 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: + """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["vol_exec"] != "0.0" and order["descr"]["type"] == "buy": - self.__balances["XXBT"]["balance"] = float( - self.__balances["ZUSD"]["balance"], - ) + float(order["vol_exec"]) - self.__balances["ZUSD"]["balance"] = float( - self.__balances["ZUSD"]["balance"], - ) - float(order["cost"]) + 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 fill_order(self: Self, txid: str, volume: float | None = None) -> None: + """Fill an order and update balances.""" + order = self.__orders.get(txid, {}) + if not order: + return - # Sell orders do not get canceled... + if volume is None: + volume = float(order["vol"]) - def fill_order(self: Self, txid: str) -> None: - 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"])), - } - self.__orders[txid] = order - self.__balances["XXBT"]["balance"] = str( - float(self.__balances["XXBT"]["balance"]) - float(order["vol_exec"]), + 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"]), ) - self.__balances["ZUSD"]["balance"] = str( - float(self.__balances["ZUSD"]["balance"]) - float(order["cost"]), + + 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: 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: Any) -> dict: # noqa: ARG002 + """Get all open orders.""" return { "open": {k: v for k, v in self.__orders.items() if v["status"] == "open"}, } - def update_order(self: Self, txid: str, **kwargs: Any) -> dict: - order = self.__orders.get(txid, {}) - order.update(kwargs) - self.__orders[txid] = order - return {txid: order} + # def update_order(self: Self, txid: str, **kwargs: Any) -> dict: + # """ Update an order. """ + # order = self.__orders.get(txid, {}) + # order.update(kwargs) + # self.__orders[txid] = order + # return {txid: order} 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: 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..9a179ad 100644 --- a/tests/integration/test_integration_DCA.py +++ b/tests/integration/test_integration_DCA.py @@ -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 diff --git a/tests/integration/test_integration_GridHODL.py b/tests/integration/test_integration_GridHODL.py index 8c29314..9786e97 100644 --- a/tests/integration/test_integration_GridHODL.py +++ b/tests/integration/test_integration_GridHODL.py @@ -43,7 +43,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_GridHODL( # 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 +61,7 @@ async def test_integration_GridHODL( # 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 +92,8 @@ async def test_integration_GridHODL( # 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 +105,14 @@ async def test_integration_GridHODL( # 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 +124,15 @@ async def test_integration_GridHODL( # 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 +141,14 @@ async def test_integration_GridHODL( # 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 +161,13 @@ async def test_integration_GridHODL( # 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 +182,13 @@ async def test_integration_GridHODL( # 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 +208,13 @@ async def test_integration_GridHODL( # 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 +226,20 @@ async def test_integration_GridHODL( # 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 +250,8 @@ async def test_integration_GridHODL( # 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 @@ -340,12 +285,7 @@ async def test_integration_GridHODL_unfilled_surplus( # 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 # ========================================================================== @@ -376,8 +316,8 @@ async def test_integration_GridHODL_unfilled_surplus( # 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 @@ -387,3 +327,39 @@ async def test_integration_GridHODL_unfilled_surplus( 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..d500cee 100644 --- a/tests/integration/test_integration_GridSell.py +++ b/tests/integration/test_integration_GridSell.py @@ -43,7 +43,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, @@ -78,12 +78,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 +124,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 +143,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 +161,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 +181,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 +199,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 +223,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 +240,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 +258,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 +268,107 @@ 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 + + +# @pytest.mark.wip +# @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, +# mock_sleep_order_management: mock.Mock, +# 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..0bd18c7 100644 --- a/tests/integration/test_integration_SWING.py +++ b/tests/integration/test_integration_SWING.py @@ -43,7 +43,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 +58,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 +95,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 +109,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 +124,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 +139,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,26 +149,10 @@ 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(), [ @@ -204,11 +161,6 @@ async def test_integration_SWING( # noqa: C901 58235.3, 57658.7, 57087.8, - 50500.0, - 49504.8, - 49014.6, - 48529.3, - 48048.8, ], [ 0.00168333, @@ -216,11 +168,6 @@ async def test_integration_SWING( # noqa: C901 0.00171717, 0.00173434, 0.00175168, - 0.00198019, - 0.00202, - 0.0020402, - 0.00206061, - 0.00208121, ], strict=True, ): @@ -230,24 +177,80 @@ 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 ... - await instance.on_message( - { - "channel": "ticker", - "data": [{"symbol": "BTC/USD", "last": 60000.1}], - }, - ) +# @pytest.mark.wip +# @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, +# mock_sleep_order_management: mock.Mock, +# instance: KrakenInfinityGridBot, +# caplog: pytest.LogCaptureFixture, +# ) -> None: +# """ +# Integration test for the SWING strategy using pre-generated websocket +# messages. - 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], - strict=False, - ): - assert order.userref == instance.userref - assert order.symbol == "BTCUSD" - assert order.price == price - assert order.volume == volume +# 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. +# # +# # The SWING strategy additionally starts selling the existing base currency +# # at defined intervals. +# current_orders = instance.orderbook.get_orders().all() +# assert len(current_orders) == 6 + +# # 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. We also see the first sell order using +# # 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), +# ["buy"] * 5 + ["sell"], +# strict=True, +# ): +# assert order.userref == instance.userref +# assert order.price == price +# assert order.volume == volume +# assert order.side == side + +# # ========================================================================== +# # 2. BUYING PARTLY FILLED and ensure that the unfilled surplus is handled + +# # FIXME: CONTINUE HERE 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..1fdaefe 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" @@ -474,6 +475,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,6 +490,8 @@ 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() @@ -502,6 +507,8 @@ 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() From 4dfd5037bb168bddd8ad695a3cbf51c5fe06569e Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Fri, 10 Jan 2025 20:04:00 +0100 Subject: [PATCH 3/4] [skip ci] Extend the integration tests - unfilled surplus --- src/kraken_infinity_grid/order_management.py | 11 +- .../integration/test_integration_GridSell.py | 203 +++++++++--------- tests/integration/test_integration_SWING.py | 194 ++++++++++------- 3 files changed, 229 insertions(+), 179 deletions(-) diff --git a/src/kraken_infinity_grid/order_management.py b/src/kraken_infinity_grid/order_management.py index 0bca356..0b519e3 100644 --- a/src/kraken_infinity_grid/order_management.py +++ b/src/kraken_infinity_grid/order_management.py @@ -425,6 +425,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. @@ -480,7 +482,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( @@ -491,7 +499,6 @@ def new_sell_order( # noqa: C901 pair=self.__s.symbol, ), ) - # ====================================================================== # Check if there is enough base currency available for selling. diff --git a/tests/integration/test_integration_GridSell.py b/tests/integration/test_integration_GridSell.py index d500cee..c116c49 100644 --- a/tests/integration/test_integration_GridSell.py +++ b/tests/integration/test_integration_GridSell.py @@ -271,104 +271,105 @@ async def test_integration_GridSell( # noqa: PLR0915 assert instance.orderbook.count() == 6 -# @pytest.mark.wip -# @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, -# mock_sleep_order_management: mock.Mock, -# 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) +@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 0bd18c7..2154e55 100644 --- a/tests/integration/test_integration_SWING.py +++ b/tests/integration/test_integration_SWING.py @@ -178,79 +178,121 @@ async def test_integration_SWING( assert order.side == "buy" -# @pytest.mark.wip -# @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, -# mock_sleep_order_management: mock.Mock, -# 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": "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. -# # -# # The SWING strategy additionally starts selling the existing base currency -# # at defined intervals. -# current_orders = instance.orderbook.get_orders().all() -# assert len(current_orders) == 6 - -# # 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. We also see the first sell order using -# # 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), -# ["buy"] * 5 + ["sell"], -# strict=True, -# ): -# assert order.userref == instance.userref -# assert order.price == price -# assert order.volume == volume -# assert order.side == side - -# # ========================================================================== -# # 2. BUYING PARTLY FILLED and ensure that the unfilled surplus is handled - -# # FIXME: CONTINUE HERE +@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": "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, side in zip( + instance.orderbook.get_orders().all(), + (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.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) From 8b9e8c74e312b17ed30aa2f1009882eb6cf93f9b Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Fri, 10 Jan 2025 20:38:24 +0100 Subject: [PATCH 4/4] Finish implementing integration tests --- tests/integration/README.md | 25 +++++- tests/integration/helper.py | 78 +++++++++---------- tests/integration/test_integration_DCA.py | 22 +++++- .../integration/test_integration_GridHODL.py | 27 +++++-- .../integration/test_integration_GridSell.py | 41 +++++++--- tests/integration/test_integration_SWING.py | 43 +++++----- tests/test_order_management.py | 3 + 7 files changed, 155 insertions(+), 84 deletions(-) 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 ce15c45..38ac4b3 100644 --- a/tests/integration/helper.py +++ b/tests/integration/helper.py @@ -6,6 +6,7 @@ # pylint: disable=arguments-differ """ Helper data structures used for integration testing. """ + import uuid from typing import Any, Callable, Self @@ -74,41 +75,6 @@ def create_order(self: Self, **kwargs) -> dict: # noqa: ANN003 self.__orders[txid] = order return {"txid": [txid]} - 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 fill_order(self: Self, txid: str, volume: float | None = None) -> None: """Fill an order and update balances.""" order = self.__orders.get(txid, {}) @@ -189,6 +155,41 @@ async def fill_order(txid: str) -> None: ): await fill_order(order["txid"]) + 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: @@ -200,13 +201,6 @@ def get_open_orders(self, **kwargs: Any) -> dict: # noqa: ARG002 "open": {k: v for k, v in self.__orders.items() if v["status"] == "open"}, } - # def update_order(self: Self, txid: str, **kwargs: Any) -> dict: - # """ Update an order. """ - # order = self.__orders.get(txid, {}) - # order.update(kwargs) - # self.__orders[txid] = order - # return {txid: order} - 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: diff --git a/tests/integration/test_integration_DCA.py b/tests/integration/test_integration_DCA.py index 9a179ad..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 @@ -197,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 9786e97..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 @@ -260,6 +255,26 @@ async def test_integration_GridHODL( # noqa: PLR0915 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 diff --git a/tests/integration/test_integration_GridSell.py b/tests/integration/test_integration_GridSell.py index c116c49..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 @@ -58,11 +53,15 @@ async def test_integration_GridSell( # noqa: PLR0915 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: @@ -270,6 +269,26 @@ async def test_integration_GridSell( # noqa: PLR0915 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 diff --git a/tests/integration/test_integration_SWING.py b/tests/integration/test_integration_SWING.py index 2154e55..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 @@ -155,20 +150,8 @@ async def test_integration_SWING( # 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, - ], - [ - 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.price == price @@ -296,3 +279,23 @@ async def test_integration_SWING_unfilled_surplus( 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_order_management.py b/tests/test_order_management.py index 1fdaefe..b2f2d13 100644 --- a/tests/test_order_management.py +++ b/tests/test_order_management.py @@ -50,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 @@ -498,6 +499,7 @@ def test_new_buy_order_max_invest_reached( strategy.pending_txids.add.assert_not_called() +@pytest.mark.wip def test_new_buy_order_not_enough_funds( order_manager: OrderManager, strategy: mock.Mock, @@ -513,6 +515,7 @@ def test_new_buy_order_not_enough_funds( 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() # ==============================================================================