Skip to content

Commit

Permalink
Resolve "Extend integration tests" (#41)
Browse files Browse the repository at this point in the history
  • Loading branch information
btschwertfeger authored Jan 13, 2025
1 parent d3dcb0a commit 0a2f21f
Show file tree
Hide file tree
Showing 10 changed files with 721 additions and 410 deletions.
8 changes: 4 additions & 4 deletions src/kraken_infinity_grid/gridbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ def __init__(
)
self.database.init_db()

async def on_message( # noqa: C901,PLR0912,PLR0911
async def on_message( # noqa: C901, PLR0912
self: Self,
message: dict | list,
) -> None:
Expand Down Expand Up @@ -281,10 +281,10 @@ async def on_message( # noqa: C901,PLR0912,PLR0911
# 108297.6, 'change': -2800.0, 'change_pct': -2.62}]}
self.configuration.update({"last_price_time": datetime.now()})

last_price = self.ticker.last
# last_price = self.ticker.last
self.ticker = SimpleNamespace(last=float(data[0]["last"]))
if last_price == self.ticker.last:
return
# if last_price == self.ticker.last:
# return

if self.unsold_buy_order_txids.count() != 0:
self.om.add_missed_sell_orders()
Expand Down
15 changes: 13 additions & 2 deletions src/kraken_infinity_grid/order_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@ def new_buy_order(
if txid_to_delete is not None:
self.__s.orderbook.remove(filters={"txid": txid_to_delete})

if len(self.__s.get_active_buy_orders().all()) >= self.__s.n_open_buy_orders: # type: ignore[no-untyped-call]
return

# Check if algorithm reached the max_investment value
if self.__s.max_investment_reached:
return
Expand Down Expand Up @@ -421,6 +424,8 @@ def new_sell_order( # noqa: C901

# ======================================================================
if txid_id_to_delete is not None: # If corresponding buy order filled
# GridSell always has txid_id_to_delete set.

# Add the txid of the corresponding buy order to the unsold buy
# order txids in order to ensure that the corresponding sell order
# will be placed - even if placing now fails.
Expand Down Expand Up @@ -476,7 +481,13 @@ def new_sell_order( # noqa: C901
),
)

if self.__s.strategy in {"GridHODL", "SWING"}:
if self.__s.strategy in {"GridHODL", "SWING"} or (
self.__s.strategy == "GridSell" and volume is None
):
# For GridSell: This is only the case if there is no corresponding
# buy order and the sell order was placed, e.g. due to an extra sell
# order via selling of partially filled buy orders.

# Respect the fee to not reduce the quote currency over time, while
# accumulating the base currency.
volume = float(
Expand All @@ -487,7 +498,6 @@ def new_sell_order( # noqa: C901
pair=self.__s.symbol,
),
)

# ======================================================================

# Check if there is enough base currency available for selling.
Expand Down Expand Up @@ -674,6 +684,7 @@ def handle_cancel_order(self: OrderManager, txid: str) -> None:
if self.__s.dry_run:
LOG.info("DRY RUN: Not cancelling order: %s", txid)
return

self.__s.trade.cancel_order(txid=txid)
self.__s.orderbook.remove(filters={"txid": txid})

Expand Down
25 changes: 21 additions & 4 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
189 changes: 159 additions & 30 deletions tests/integration/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
""" Helper data structures used for integration testing. """

import uuid
from typing import Self
from typing import Any, Callable, Self

from kraken.spot import Trade, User

Expand All @@ -32,52 +32,181 @@ def __init__(self: Self, **kwargs) -> None: # noqa: ANN003
"XXBT": {"balance": "100.0", "hold_trade": "0.0"},
"ZUSD": {"balance": "1000000.0", "hold_trade": "0.0"},
}

def create_order(self: Self, **kwargs) -> None: # noqa: ANN003
self.__orders |= {
(txid := str(uuid.uuid4()).upper()): {
"userref": kwargs["userref"],
"descr": {
"pair": "BTCUSD",
"type": kwargs["side"],
"ordertype": kwargs["ordertype"],
"price": kwargs["price"],
},
"status": "open",
"vol": kwargs["volume"],
"vol_exec": "0.0",
"cost": "0.0",
"fee": "0.0",
self.__fee = 0.0025

def create_order(self: Self, **kwargs) -> dict: # noqa: ANN003
"""Create a new order and update balances if needed."""
txid = str(uuid.uuid4()).upper()
order = {
"userref": kwargs["userref"],
"descr": {
"pair": "BTCUSD",
"type": kwargs["side"],
"ordertype": kwargs["ordertype"],
"price": kwargs["price"],
},
"status": "open",
"vol": kwargs["volume"],
"vol_exec": "0.0",
"cost": "0.0",
"fee": "0.0",
}

if kwargs["side"] == "buy":
required_balance = float(kwargs["price"]) * float(kwargs["volume"])
if float(self.__balances["ZUSD"]["balance"]) < required_balance:
raise ValueError("Insufficient balance to create buy order")
self.__balances["ZUSD"]["balance"] = str(
float(self.__balances["ZUSD"]["balance"]) - required_balance,
)
self.__balances["ZUSD"]["hold_trade"] = str(
float(self.__balances["ZUSD"]["hold_trade"]) + required_balance,
)
elif kwargs["side"] == "sell":
if float(self.__balances["XXBT"]["balance"]) < float(kwargs["volume"]):
raise ValueError("Insufficient balance to create sell order")
self.__balances["XXBT"]["balance"] = str(
float(self.__balances["XXBT"]["balance"]) - float(kwargs["volume"]),
)
self.__balances["XXBT"]["hold_trade"] = str(
float(self.__balances["XXBT"]["hold_trade"]) + float(kwargs["volume"]),
)

self.__orders[txid] = order
return {"txid": [txid]}

def cancel_order(self: Self, txid: str) -> None:
self.__orders.get(txid, {}).update({"status": "canceled"})

def fill_order(self: Self, txid: str) -> None:
def fill_order(self: Self, txid: str, volume: float | None = None) -> None:
"""Fill an order and update balances."""
order = self.__orders.get(txid, {})
order["fee"] = str(float(order["vol"]) * 0.0025)
order |= {
"status": "closed",
"vol_exec": order["vol"],
"cost": str(float(order["vol"]) + float(order["fee"])),
}
if not order:
return

if volume is None:
volume = float(order["vol"])

if volume > float(order["vol"]) - float(order["vol_exec"]):
raise ValueError(
"Cannot fill order with volume higher than remaining order volume",
)

executed_volume = float(order["vol_exec"]) + volume
remaining_volume = float(order["vol"]) - executed_volume

order["fee"] = str(float(order["vol_exec"]) * self.__fee)
order["vol_exec"] = str(executed_volume)
order["cost"] = str(
executed_volume * float(order["descr"]["price"]) + float(order["fee"]),
)

if remaining_volume <= 0:
order["status"] = "closed"
else:
order["status"] = "open"

self.__orders[txid] = order

if order["descr"]["type"] == "buy":
self.__balances["XXBT"]["balance"] = str(
float(self.__balances["XXBT"]["balance"]) + volume,
)
self.__balances["ZUSD"]["balance"] = str(
float(self.__balances["ZUSD"]["balance"]) - float(order["cost"]),
)
self.__balances["ZUSD"]["hold_trade"] = str(
float(self.__balances["ZUSD"]["hold_trade"]) - float(order["cost"]),
)
elif order["descr"]["type"] == "sell":
self.__balances["XXBT"]["balance"] = str(
float(self.__balances["XXBT"]["balance"]) - volume,
)
self.__balances["XXBT"]["hold_trade"] = str(
float(self.__balances["XXBT"]["hold_trade"]) - volume,
)
self.__balances["ZUSD"]["balance"] = str(
float(self.__balances["ZUSD"]["balance"]) + float(order["cost"]),
)

async def on_ticker_update(self: Self, callback: Callable, last: float) -> None:
"""Update the ticker and fill orders if needed."""
await callback(
{
"channel": "ticker",
"data": [{"symbol": "BTC/USD", "last": last}],
},
)

async def fill_order(txid: str) -> None:
self.fill_order(txid)
await callback(
{
"channel": "executions",
"type": "update",
"data": [{"exec_type": "filled", "order_id": txid}],
},
)

for order in self.get_open_orders()["open"].values():
if (
order["descr"]["type"] == "buy"
and float(order["descr"]["price"]) >= last
) or (
order["descr"]["type"] == "sell"
and float(order["descr"]["price"]) <= last
):
await fill_order(order["txid"])

def cancel_all_orders(self, **kwargs) -> None: # noqa: ARG002,ANN003
def cancel_order(self: Self, txid: str) -> None:
"""Cancel an order and update balances if needed."""
order = self.__orders.get(txid, {})
if not order:
return

order.update({"status": "canceled"})
self.__orders[txid] = order

if order["descr"]["type"] == "buy":
executed_cost = float(order["vol_exec"]) * float(order["descr"]["price"])
remaining_cost = (
float(order["vol"]) * float(order["descr"]["price"]) - executed_cost
)
self.__balances["ZUSD"]["balance"] = str(
float(self.__balances["ZUSD"]["balance"]) + remaining_cost,
)
self.__balances["ZUSD"]["hold_trade"] = str(
float(self.__balances["ZUSD"]["hold_trade"]) - remaining_cost,
)
self.__balances["XXBT"]["balance"] = str(
float(self.__balances["XXBT"]["balance"]) - float(order["vol_exec"]),
)
elif order["descr"]["type"] == "sell":
remaining_volume = float(order["vol"]) - float(order["vol_exec"])
self.__balances["XXBT"]["balance"] = str(
float(self.__balances["XXBT"]["balance"]) + remaining_volume,
)
self.__balances["XXBT"]["hold_trade"] = str(
float(self.__balances["XXBT"]["hold_trade"]) - remaining_volume,
)
self.__balances["ZUSD"]["balance"] = str(
float(self.__balances["ZUSD"]["balance"]) - float(order["cost"]),
)

def cancel_all_orders(self: Self, **kwargs: Any) -> None: # noqa: ARG002
"""Cancel all open orders."""
for txid in self.__orders:
self.cancel_order(txid)

def get_open_orders(self, **kwargs) -> dict: # noqa: ARG002,ANN003
def get_open_orders(self, **kwargs: Any) -> dict: # noqa: ARG002
"""Get all open orders."""
return {
"open": {k: v for k, v in self.__orders.items() if v["status"] == "open"},
}

def get_orders_info(self: Self, txid: str) -> dict:
"""Get information about a specific order."""
if (order := self.__orders.get(txid, None)) is not None:
return {txid: order}
return {}

def get_balances(self, **kwargs) -> dict: # noqa: ARG002, ANN003
def get_balances(self: Self, **kwargs: Any) -> dict: # noqa: ARG002
"""Get the user's current balances."""
return self.__balances
Loading

0 comments on commit 0a2f21f

Please sign in to comment.