From 4dfd5037bb168bddd8ad695a3cbf51c5fe06569e Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Fri, 10 Jan 2025 20:04:00 +0100 Subject: [PATCH] [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)