Skip to content

Commit

Permalink
Resolve "Hitting the max_investment ends in infinite loop" (#38)
Browse files Browse the repository at this point in the history
  • Loading branch information
btschwertfeger authored Jan 8, 2025
1 parent a120bb7 commit 843c731
Show file tree
Hide file tree
Showing 6 changed files with 142 additions and 103 deletions.
12 changes: 10 additions & 2 deletions src/kraken_infinity_grid/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def get_rows(
self: Self,
table: Table,
filters: dict | None = None,
exclude: dict | None = None,
) -> MappingResult:
"""Fetch rows from the specified table with optional filters."""
LOG.debug(
Expand All @@ -88,7 +89,9 @@ def get_rows(
query = select(table)
if filters:
for column, value in filters.items():
query = query.where(table.c[column] == value)
query = query.where(
table.c[column] == value and table.c[column] != exclude,
)
return self.session.execute(query).mappings()

def update_row(
Expand Down Expand Up @@ -150,14 +153,19 @@ def add(self: Self, order: dict) -> None:
volume=order["vol"],
)

def get_orders(self: Self, filters: dict | None = None) -> MappingResult:
def get_orders(
self: Self,
filters: dict | None = None,
exclude: dict | None = None,
) -> MappingResult:
"""Get orders from the orderbook."""
LOG.debug("Getting orders from the orderbook with filters: %s", filters)
if not filters:
filters = {}
return self.__db.get_rows(
self.__table,
filters=filters | {"userref": self.__userref},
exclude=exclude,
)

def remove(self: Self, filters: dict) -> None:
Expand Down
25 changes: 20 additions & 5 deletions src/kraken_infinity_grid/gridbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,6 @@ def __init__(
self.amount_per_grid: float = float(config["amount_per_grid"])
self.max_investment: float = config["max_investment"]
self.n_open_buy_orders: int = config["n_open_buy_orders"]
self.max_invest_reached: bool = False
self.investment: float = 0
self.fee: float | None = None
self.base_currency: str = config["base_currency"]
self.quote_currency: str = config["quote_currency"]
Expand Down Expand Up @@ -558,8 +556,25 @@ def get_order_price(
def get_value_of_orders(self: Self, orders: Iterable) -> float:
"""Returns the overall invested quote that is invested"""
LOG.debug("Getting value of open orders...")
investment = 0.0
for order in orders:
investment += float(order["price"]) * float(order["volume"])
investment = sum(
float(order["price"]) * float(order["volume"]) for order in orders
)
LOG.debug("Value of open orders: %d %s", investment, self.quote_currency)
return investment

@property
def investment(self: Self) -> float:
"""Returns the current investment based on open orders."""
return self.get_value_of_orders(
orders=self.orderbook.get_orders(),
)

@property
def max_investment_reached(self: Self) -> bool:
"""Returns True if the maximum investment is reached."""
# TODO: put this as class variable
new_position_value = self.amount_per_grid + self.amount_per_grid * self.fee

return (self.max_investment <= self.investment + new_position_value) or (
self.max_investment <= self.investment
)
104 changes: 38 additions & 66 deletions src/kraken_infinity_grid/order_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,6 @@ def assign_order_by_txid(self: Self, txid: str, tries: int = 1) -> None:
self.__s.orderbook.update(order_to_assign, filters={"txid": txid})
LOG.info("%s: Updated order in orderbook.", txid)

self.__s.investment = self.__s.get_value_of_orders(
orders=self.__s.orderbook.get_orders().all(), # type: ignore[no-untyped-call]
)
LOG.info(
"Current invested value: %f / %d %s",
self.__s.investment,
Expand Down Expand Up @@ -159,7 +156,7 @@ def __check_n_open_buy_orders(self: OrderManager) -> None:
len(active_buy_orders) < self.__s.n_open_buy_orders
and can_place_buy_order
and self.__s.pending_txids.count() == 0
and self.__s.max_investment > self.__s.investment
and not self.__s.max_investment_reached
):

fetched_balances: dict[str, float] = self.__s.get_balances()
Expand Down Expand Up @@ -343,6 +340,13 @@ def new_buy_order(
# FIXME: do proper dryrun
return

if txid_to_delete is not None:
self.__s.orderbook.remove(filters={"txid": txid_to_delete})

# Check if algorithm reached the max_investment value
if self.__s.max_investment_reached:
return

current_balances = self.__s.get_balances()

# Compute the target price for the upcoming buy order.
Expand All @@ -368,76 +372,44 @@ def new_buy_order(
self.__s.amount_per_grid + self.__s.amount_per_grid * self.__s.fee
)

# Compute the value of all open orders.
value_of_open_orders = self.__s.get_value_of_orders(
orders=self.__s.orderbook.get_orders(),
)

# ======================================================================
# Check if there is enough quote balance available to place a buy order.
if current_balances["quote_available"] > new_position_value:
# Check if algorithm reached the max_investment value
if self.__s.max_investment <= value_of_open_orders + new_position_value:
if not self.__s.max_invest_reached:
# Ensuring the message below is only sent once
self.__s.max_invest_reached = True
# FIXME: is txid_to_delete even possible with buy orders?
if txid_to_delete is not None:
self.__s.orderbook.remove(filters={"txid": txid_to_delete})

message = str(
"Bot reached maximum value, not buying. (max value:"
f" {self.__s.max_investment} {self.__s.quote_currency},"
f" current invested value: {value_of_open_orders}"
f" {self.__s.quote_currency})",
)
self.__s.t.send_to_telegram(message=message)
return

# ==============================================================
# Algorithm has not reached max_investment yet and is ready to buy.
else:
self.__s.max_invest_reached = False
LOG.info(
"Placing order to buy %s %s @ %s %s.",
volume,
self.__s.base_currency,
order_price,
self.__s.quote_currency,
)
LOG.info(
"Placing order to buy %s %s @ %s %s.",
volume,
self.__s.base_currency,
order_price,
self.__s.quote_currency,
)

# Place new buy order, append id to pending list and delete
# corresponding sell order from local orderbook
placed_order = self.__s.trade.create_order(
ordertype="limit",
side="buy",
volume=volume,
pair=self.__s.symbol,
price=order_price,
userref=self.__s.userref,
validate=self.__s.dry_run,
)
# Place a new buy order, append txid to pending list and delete
# corresponding sell order from local orderbook.
placed_order = self.__s.trade.create_order(
ordertype="limit",
side="buy",
volume=volume,
pair=self.__s.symbol,
price=order_price,
userref=self.__s.userref,
validate=self.__s.dry_run,
)

self.__s.pending_txids.add(placed_order["txid"][0])
if txid_to_delete is not None:
self.__s.orderbook.remove(filters={"txid": txid_to_delete})
self.__s.om.assign_order_by_txid(placed_order["txid"][0])
return
self.__s.pending_txids.add(placed_order["txid"][0])
# if txid_to_delete is not None:
# self.__s.orderbook.remove(filters={"txid": txid_to_delete})
self.__s.om.assign_order_by_txid(placed_order["txid"][0])
return

# ======================================================================
# Not enough available funds to place a buy order.
else:
if txid_to_delete is not None:
# FIXME: does that make sense in buy orders?
self.__s.orderbook.remove(filters={"txid": txid_to_delete})

message = f"⚠️ {self.__s.symbol}"
message += f"├ Not enough {self.__s.quote_currency}"
message += f"├ to buy {volume} {self.__s.base_currency}"
message += f"└ for {order_price} {self.__s.quote_currency}"
self.__s.t.send_to_telegram(message)
LOG.warning("Current balances: %s", current_balances)
return
message = f"⚠️ {self.__s.symbol}"
message += f"├ Not enough {self.__s.quote_currency}"
message += f"├ to buy {volume} {self.__s.base_currency}"
message += f"└ for {order_price} {self.__s.quote_currency}"
self.__s.t.send_to_telegram(message)
LOG.warning("Current balances: %s", current_balances)
return

def new_sell_order( # noqa: C901
self: OrderManager,
Expand Down
7 changes: 0 additions & 7 deletions src/kraken_infinity_grid/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,6 @@ def prepare_for_trading(self: Self) -> None:

self.__s.ticker = SimpleNamespace(last=float(ticker["c"][0]))

# Check how much is actual invested in this asset pair based on the open
# orders of this instance.
##
self.__s.investment = self.__s.get_value_of_orders(
orders=self.__s.orderbook.get_orders(),
)

# Update the orderbook, check for closed, filled, cancelled trades,
# and submit new orders if necessary.
##
Expand Down
33 changes: 33 additions & 0 deletions tests/test_gridbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,39 @@ def test_get_value_of_orders(instance: KrakenInfinityGridBot) -> None:
assert value == 0


def test_investment(instance: KrakenInfinityGridBot) -> None:
"""Test the investment property."""
instance.orderbook.get_orders.return_value = [
{"price": 50000.0, "volume": 0.1},
{"price": 49000.0, "volume": 0.2},
]
assert instance.investment == 14800.0

instance.orderbook.get_orders.return_value = []
assert instance.investment == 0.0


def test_max_investment_reached(instance: KrakenInfinityGridBot) -> None:
"""Test the max_investment_reached property."""
instance.amount_per_grid = 1000.0
instance.fee = 0.01
instance.max_investment = 20000.0

# Case where max investment is not reached
instance.orderbook.get_orders.return_value = [
{"price": 50000.0, "volume": 0.1},
{"price": 49000.0, "volume": 0.2},
]
assert not instance.max_investment_reached

# Case where max investment is reached
instance.orderbook.get_orders.return_value = [
{"price": 50000.0, "volume": 0.3},
{"price": 49000.0, "volume": 0.2},
]
assert instance.max_investment_reached


# ==============================================================================
# on_message
##
Expand Down
64 changes: 41 additions & 23 deletions tests/test_order_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ def test_check_pending_txids(
mock_assign_all_pending_transactions.assert_called_once()


# ==============================================================================
# check_near_buy_orders
##


@mock.patch.object(OrderManager, "handle_cancel_order")
def test_check_near_buy_orders_cancel(
mock_handle_cancel_order: mock.Mock, # noqa: ARG001
Expand Down Expand Up @@ -194,9 +199,6 @@ def test_check_near_buy_orders_good_distance(
strategy.get_active_buy_orders.assert_not_called()


# ==============================================================================
# check_n_open_buy_orders
##
@mock.patch.object(OrderManager, "handle_cancel_order")
def test_check_near_buy_orders_cancel_no_buys(
mock_handle_cancel_order: mock.Mock, # noqa: ARG001
Expand All @@ -211,6 +213,11 @@ def test_check_near_buy_orders_cancel_no_buys(
strategy.get_active_buy_orders.assert_not_called()


# ==============================================================================
# check_n_open_buy_orders
##


@mock.patch.object(OrderManager, "handle_arbitrage")
def test_check_n_open_buy_orders(
mock_handle_arbitrage: mock.Mock,
Expand All @@ -227,6 +234,7 @@ def test_check_n_open_buy_orders(
strategy.n_open_buy_orders = 5
# Current investment
strategy.investment = 1000.0
strategy.max_investment_reached = False
# The currently available quote currency
strategy.get_balances.return_value = {"quote_available": 10000.0}
# No pending transactions
Expand Down Expand Up @@ -279,6 +287,23 @@ def test_check_n_open_buy_orders(
assert mock_handle_arbitrage.call_count == 4


@mock.patch.object(OrderManager, "handle_arbitrage")
def test_check_n_open_buy_orders_max_investment_reached(
mock_handle_arbitrage: mock.Mock,
strategy: mock.Mock,
) -> None:
"""
Test checking that the function does not place any order if the maximum
investment is reached.
"""
strategy.max_investment_reached = True

# Ensure no API call is made in order to avoid rate-limiting
strategy.get_balances.assert_not_called()
# Ensure no buy orders are placed
mock_handle_arbitrage.assert_not_called()


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


Expand Down Expand Up @@ -435,25 +460,6 @@ def test_handle_arbitrage(
# ==============================================================================
# new_buy_order
##
def test_new_buy_order_max_invest_reached(
order_manager: OrderManager,
strategy: mock.Mock,
) -> None:
"""
Test placing a new buy order, that exceeds the maximum investment defined.
"""
strategy.get_balances.return_value = {"quote_available": 1000.0}
strategy.max_investment = 4999.0
strategy.max_invest_reached = False
strategy.get_value_of_orders.return_value = 5000.0
strategy.trade.create_order.return_value = {"txid": ["txid1"]}
strategy.trade.truncate.side_effect = [50000.0, 100.0] # price, volume

order_manager.new_buy_order(order_price=50000.0)

assert strategy.max_invest_reached
strategy.trade.create_order.assert_not_called()
strategy.pending_txids.add.assert_not_called()


def test_new_buy_order(
Expand All @@ -463,17 +469,29 @@ def test_new_buy_order(
"""Test placing a new buy order successfully."""
strategy.get_balances.return_value = {"quote_available": 1000.0}
strategy.max_investment = 6000.0
strategy.max_investment_reached = False
strategy.get_value_of_orders.return_value = 5000.0
strategy.trade.create_order.return_value = {"txid": ["txid1"]}
strategy.trade.truncate.side_effect = [50000.0, 100.0] # price, volume

order_manager.new_buy_order(order_price=50000.0)
assert not strategy.max_invest_reached
strategy.pending_txids.add.assert_called_once_with("txid1")
strategy.trade.create_order.assert_called_once()
strategy.om.assign_order_by_txid.assert_called_once_with("txid1")


def test_new_buy_order_max_invest_reached(
order_manager: OrderManager,
strategy: mock.Mock,
) -> None:
"""Test placing a new buy order without sufficient funds."""
strategy.max_investment_reached = True

order_manager.new_buy_order(order_price=50000.0)
strategy.trade.create_order.assert_not_called()
strategy.pending_txids.add.assert_not_called()


def test_new_buy_order_not_enough_funds(
order_manager: OrderManager,
strategy: mock.Mock,
Expand Down

0 comments on commit 843c731

Please sign in to comment.