From 2d2fd927083e488e383606e58499d65573fe2f5e Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Sat, 6 Sep 2025 15:01:13 +0200 Subject: [PATCH 1/8] [skip ci] Resolve "Implement Trailing take profit orders" --- .github/copilot-instructions.md | 27 +++ src/infinity_grid/core/cli.py | 13 ++ src/infinity_grid/infrastructure/database.py | 1 + src/infinity_grid/models/configuration.py | 11 ++ src/infinity_grid/strategies/c_dca.py | 1 + src/infinity_grid/strategies/grid_base.py | 181 ++++++++++++++++++- src/infinity_grid/strategies/grid_sell.py | 17 +- 7 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fb4a283 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,27 @@ +# Project Overview + +This project is named infinity-grid and is a Python-based trading bot allowing +to run one of many tradings strategies on an exchange of a choice. The trading +bot is designed to run in a containerized environment. + +## Folder Structure + +- `./github`: Contains GitHub Actions specific files as well as repository + configuration. +- `./doc`: Contains documentation for the project, including how to set it up, + develop with, and concepts to extend the project. +- `./src`: Contains the source code for the trading bot. +- `./tests`: Contains the unit, integration, acceptance, etc tests for this + project. + +## Libraries and Frameworks + +- Docker and Docker Compose is used for running the trading bot +- The project uses interfaces, adapters, and models to realize an extensible + framework for allowing to extend and add new strategies and exchanges to the + project. + +## Coding Standards + +- Use black for formatting Python code. +- Pre-commit hooks must pass before committing diff --git a/src/infinity_grid/core/cli.py b/src/infinity_grid/core/cli.py index ab52c09..72a1cf4 100644 --- a/src/infinity_grid/core/cli.py +++ b/src/infinity_grid/core/cli.py @@ -243,6 +243,19 @@ def cli(ctx: Context, **kwargs: dict) -> None: can be caught immediately. """, ), + option( + "--trailing-stop-profit", + type=FLOAT, + required=False, + callback=ensure_larger_than_zero, + help=""" + The trailing stop profit percentage, e.g. '0.01' for 1%. When enabled, + allows profits to run beyond the defined interval and locks in profits + when price reverses. The mechanism activates when price reaches + (interval + TSP) and dynamically adjusts both stop level and target + sell price as price moves favorably. + """, + ), constraint=If( # Useless if no further strategies are implemented Equal("strategy", "cDCA") | Equal("strategy", "GridHODL") diff --git a/src/infinity_grid/infrastructure/database.py b/src/infinity_grid/infrastructure/database.py index f7cc5d1..92a8cf1 100644 --- a/src/infinity_grid/infrastructure/database.py +++ b/src/infinity_grid/infrastructure/database.py @@ -162,6 +162,7 @@ def __init__(self: Self, userref: int, db: DBConnect) -> None: Column("price_of_highest_buy", Float, nullable=False, default=0), Column("amount_per_grid", Float), Column("interval", Float), + Column("trailing_stop_profit", Float, nullable=True), extend_existing=True, ) diff --git a/src/infinity_grid/models/configuration.py b/src/infinity_grid/models/configuration.py index d763c8f..f89d0cf 100644 --- a/src/infinity_grid/models/configuration.py +++ b/src/infinity_grid/models/configuration.py @@ -36,6 +36,9 @@ class BotConfigDTO(BaseModel): interval: float n_open_buy_orders: int + # Optional trailing stop profit configuration + trailing_stop_profit: float | None = None + @field_validator("strategy") @classmethod def validate_strategy(cls, value: str) -> str: @@ -100,6 +103,14 @@ def validate_fee(cls, value: float | None) -> float | None: raise ValueError("fee must be between 0 and 1 (inclusive)") return value + @field_validator("trailing_stop_profit") + @classmethod + def validate_trailing_stop_profit(cls, value: float | None) -> float | None: + """Validate trailing_stop_profit is between 0 and 1 if provided.""" + if value is not None and (value <= 0 or value >= 1): + raise ValueError("trailing_stop_profit must be between 0 and 1 (exclusive)") + return value + class DBConfigDTO(BaseModel): sqlite_file: str | None = None diff --git a/src/infinity_grid/strategies/c_dca.py b/src/infinity_grid/strategies/c_dca.py index 664d286..9fcc9b1 100644 --- a/src/infinity_grid/strategies/c_dca.py +++ b/src/infinity_grid/strategies/c_dca.py @@ -18,6 +18,7 @@ class CDCAStrategy(GridStrategyBase): def _get_sell_order_price( self: Self, last_price: float, + buy_txid: str | None = None, ) -> float: """Returns the order price for the next sell order.""" LOG.debug("cDCA strategy does not place sell orders.") diff --git a/src/infinity_grid/strategies/grid_base.py b/src/infinity_grid/strategies/grid_base.py index 1647da3..19bdc92 100644 --- a/src/infinity_grid/strategies/grid_base.py +++ b/src/infinity_grid/strategies/grid_base.py @@ -460,6 +460,7 @@ def __update_order_book_handle_closed_order( side=self._exchange_domain.SELL, order_price=self._get_sell_order_price( last_price=closed_order.price, + buy_txid=closed_order.txid, ), txid_to_delete=closed_order.txid, ) @@ -588,6 +589,14 @@ def __check_configuration_changes(self: Self) -> None: self._configuration_table.update({"interval": self._config.interval}) cancel_all_orders = True + # Check if trailing stop profit configuration changed + current_tsp = self._configuration_table.get().get("trailing_stop_profit") + if self._config.trailing_stop_profit != current_tsp: + LOG.info(" - Trailing stop profit changed => updating configuration...") + self._configuration_table.update( + {"trailing_stop_profit": self._config.trailing_stop_profit}, + ) + if cancel_all_orders: self.__cancel_all_open_buy_orders() @@ -637,6 +646,10 @@ def __check_price_range(self: Self) -> None: # Place extra sell order (only for SWING strategy) self._check_extra_sell_order() + # Check TSP conditions if enabled + if self._config.trailing_stop_profit: + self.__check_tsp() + # ========================================================================== def __add_missed_sell_orders(self: Self) -> None: """ @@ -1289,10 +1302,13 @@ def _get_buy_order_price(self: Self, last_price: float) -> float: def _get_sell_order_price( self: Self, last_price: float, + buy_txid: str | None = None, # Keep for API compatibility but not used ) -> float: """ Returns the order price. Also assigns a new highest buy price to configuration if there was a new highest buy. + + If TSP is enabled, sets initial sell price higher (interval + 2×TSP). """ LOG.debug("Computing the order price...") @@ -1303,10 +1319,18 @@ def _get_sell_order_price( if last_price > price_of_highest_buy: self._configuration_table.update({"price_of_highest_buy": last_price}) - # Sell price 1x interval above buy price - factor = 1 + self._config.interval + # Check if TSP is enabled + if self._config.trailing_stop_profit: + # For TSP: Initial sell target is interval + 2×TSP + factor = 1 + self._config.interval + (2 * self._config.trailing_stop_profit) + LOG.debug("TSP enabled: using factor %s for initial sell price", factor) + else: + # Standard sell price: 1x interval above buy price + factor = 1 + self._config.interval + if (order_price := last_price * factor) < self._ticker: order_price = self._ticker * factor + return order_price # ========================================================================== @@ -1329,3 +1353,156 @@ def _new_sell_order( This method should be implemented by the concrete strategy classes. """ raise NotImplementedError("This method should be implemented by subclasses.") + + def __check_tsp(self: Self) -> None: + """ + Check TSP conditions for all open sell orders. + + For each sell order that was placed with TSP enabled: + 1. Calculate if TSP should be activated (price >= buy_price * (1 + + interval + TSP)) + 2. If activated and price moved up, cancel current sell order and place + higher + 3. If activated and price moved down to stop level, place stop-loss sell + order + + NOTE: for cDCA, there are no sell orders, so this must be skipped + """ + if not self._ticker or self._config.dry_run: + return + + current_price = self._ticker + + # Get all open sell orders + sell_orders = list( + self._orderbook_table.get_orders( + filters={"side": self._exchange_domain.SELL}, + ) + ) + + for sell_order in sell_orders: + sell_price = sell_order["price"] + sell_txid = sell_order["txid"] + + # Calculate the original buy price from the sell price + # For TSP orders: sell_price = buy_price * (1 + interval + 2×TSP) + # For normal orders: sell_price = buy_price * (1 + interval) + + # We need to determine if this was a TSP order or normal order + # We can do this by checking if the sell price fits the TSP pattern + tsp_factor = ( + 1 + self._config.interval + (2 * self._config.trailing_stop_profit) + ) + normal_factor = 1 + self._config.interval + + # Try to back-calculate buy price assuming TSP + potential_buy_price_tsp = sell_price / tsp_factor + potential_buy_price_normal = sell_price / normal_factor + + # Check which scenario is more likely based on current price patterns + # If the sell order is much higher than normal, it's likely a TSP order + price_diff_tsp = abs( + current_price + - potential_buy_price_tsp + * (1 + self._config.interval + self._config.trailing_stop_profit) + ) + price_diff_normal = abs( + current_price - potential_buy_price_normal * (1 + self._config.interval) + ) + + # If TSP pattern fits better, treat as TSP order + if price_diff_tsp < price_diff_normal and tsp_factor > normal_factor: + buy_price = potential_buy_price_tsp + activation_price = buy_price * ( + 1 + self._config.interval + self._config.trailing_stop_profit + ) + + LOG.debug( + "Checking TSP for sell order %s: buy_price=%s, activation_price=%s, current_price=%s", + sell_txid, + buy_price, + activation_price, + current_price, + ) + + # Case 1: TSP should be activated (price reached activation level) + if current_price >= activation_price: + LOG.info( + "TSP activation for sell order %s: price %s >= activation %s", + sell_txid, + current_price, + activation_price, + ) + + # Cancel current sell order and place new higher one + try: + self._rest_api.cancel_order(txid=sell_txid) + self._orderbook_table.remove(filters={"txid": sell_txid}) + + # Calculate new sell price based on how much higher the price went + # New sell price = buy_price * (1 + interval + (N+2)×TSP) + # where N is how many TSP increments above activation we are + tsp_increments = ( + int( + (current_price - activation_price) + / (buy_price * self._config.trailing_stop_profit) + ) + + 1 + ) + new_sell_factor = ( + 1 + + self._config.interval + + ((tsp_increments + 2) * self._config.trailing_stop_profit) + ) + new_sell_price = buy_price * new_sell_factor + + LOG.info( + "Placing new TSP sell order at %s (was %s)", + new_sell_price, + sell_price, + ) + + # Place new sell order via strategy's method + self._handle_arbitrage( + side=self._exchange_domain.SELL, + order_price=new_sell_price, + ) + + except Exception as exc: + LOG.error( + "Failed to update TSP sell order %s: %s", sell_txid, exc + ) + + # Case 2: Check if price dropped to trailing stop level + elif current_price <= buy_price * (1 + self._config.interval): + LOG.info( + "TSP stop triggered for sell order %s: price %s <= stop level %s", + sell_txid, + current_price, + buy_price * (1 + self._config.interval), + ) + + # Cancel current sell order and place stop-loss order + try: + self._rest_api.cancel_order(txid=sell_txid) + self._orderbook_table.remove(filters={"txid": sell_txid}) + + # Place stop-loss sell order at minimum profit level + stop_price = buy_price * (1 + self._config.interval) + + LOG.info( + "Placing TSP stop-loss sell order at %s", + stop_price, + ) + + self._handle_arbitrage( + side=self._exchange_domain.SELL, + order_price=stop_price, + ) + + except Exception as exc: + LOG.error( + "Failed to place TSP stop-loss order for %s: %s", + sell_txid, + exc, + ) diff --git a/src/infinity_grid/strategies/grid_sell.py b/src/infinity_grid/strategies/grid_sell.py index ab7f5e6..c6ff821 100644 --- a/src/infinity_grid/strategies/grid_sell.py +++ b/src/infinity_grid/strategies/grid_sell.py @@ -24,25 +24,14 @@ def _get_sell_order_price( self: Self, last_price: float, extra_sell: bool = False, # noqa: ARG002 + buy_txid: str | None = None, ) -> float: """ Returns the sell order price depending. Also assigns a new highest buy price to configuration if there was a new highest buy. """ - LOG.debug("Computing the order price...") - - order_price: float - price_of_highest_buy = self._configuration_table.get()["price_of_highest_buy"] - last_price = float(last_price) - - if last_price > price_of_highest_buy: - self._configuration_table.update({"price_of_highest_buy": last_price}) - - # Sell price 1x interval above buy price - factor = 1 + self._config.interval - if (order_price := last_price * factor) < self._ticker: - order_price = self._ticker * factor - return order_price + # Use the base class implementation which handles TSP + return super()._get_sell_order_price(last_price, buy_txid) def _check_extra_sell_order(self: Self) -> None: """Not applicable for GridSell strategy.""" From 973da9b63369df3f6f71f94bc1c6ca4ca6ac5be5 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Sat, 6 Sep 2025 17:26:15 +0200 Subject: [PATCH 2/8] [skip ci] stash --- src/infinity_grid/core/cli.py | 3 ++- src/infinity_grid/models/configuration.py | 12 ++++++++++-- src/infinity_grid/strategies/grid_base.py | 14 ++++++-------- src/infinity_grid/strategies/grid_sell.py | 12 ------------ tests/unit/strategies/test_cdca.py | 8 +++----- 5 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/infinity_grid/core/cli.py b/src/infinity_grid/core/cli.py index 72a1cf4..2ab55fa 100644 --- a/src/infinity_grid/core/cli.py +++ b/src/infinity_grid/core/cli.py @@ -253,7 +253,8 @@ def cli(ctx: Context, **kwargs: dict) -> None: allows profits to run beyond the defined interval and locks in profits when price reverses. The mechanism activates when price reaches (interval + TSP) and dynamically adjusts both stop level and target - sell price as price moves favorably. + sell price as price moves favorably. It is recommended to set a TSP to + half an interval, e.g., '0.01' in case the interval is '0.02'. """, ), constraint=If( # Useless if no further strategies are implemented diff --git a/src/infinity_grid/models/configuration.py b/src/infinity_grid/models/configuration.py index f89d0cf..1cb4f08 100644 --- a/src/infinity_grid/models/configuration.py +++ b/src/infinity_grid/models/configuration.py @@ -6,6 +6,7 @@ # from pydantic import BaseModel, computed_field, field_validator +from pydantic import RootModel class BotConfigDTO(BaseModel): @@ -107,8 +108,15 @@ def validate_fee(cls, value: float | None) -> float | None: @classmethod def validate_trailing_stop_profit(cls, value: float | None) -> float | None: """Validate trailing_stop_profit is between 0 and 1 if provided.""" - if value is not None and (value <= 0 or value >= 1): - raise ValueError("trailing_stop_profit must be between 0 and 1 (exclusive)") + if value is not None: + if value <= 0 or value >= 1: + raise ValueError("trailing_stop_profit must be between 0 and 1 (exclusive)") + # The trailing stop profit should be smaller than the interval + # to ensure it triggers before the next grid level + root = RootModel.model_validate(cls.__pydantic_parent_namespace__) + interval = root.interval + if interval is not None and value >= interval: + raise ValueError("trailing_stop_profit must be smaller than interval") return value diff --git a/src/infinity_grid/strategies/grid_base.py b/src/infinity_grid/strategies/grid_base.py index 19bdc92..f6a9025 100644 --- a/src/infinity_grid/strategies/grid_base.py +++ b/src/infinity_grid/strategies/grid_base.py @@ -1308,7 +1308,7 @@ def _get_sell_order_price( Returns the order price. Also assigns a new highest buy price to configuration if there was a new highest buy. - If TSP is enabled, sets initial sell price higher (interval + 2×TSP). + If TSP is enabled, sets initial sell price higher (interval + 2x TSP). """ LOG.debug("Computing the order price...") @@ -1366,19 +1366,17 @@ def __check_tsp(self: Self) -> None: 3. If activated and price moved down to stop level, place stop-loss sell order - NOTE: for cDCA, there are no sell orders, so this must be skipped + # FIXME: we might need a new table for tracking orders that were + cancelled by TSP and need a new placement """ if not self._ticker or self._config.dry_run: return current_price = self._ticker - # Get all open sell orders - sell_orders = list( - self._orderbook_table.get_orders( - filters={"side": self._exchange_domain.SELL}, - ) - ) + sell_orders = self._orderbook_table.get_orders( + filters={"side": self._exchange_domain.SELL}, + ).all() for sell_order in sell_orders: sell_price = sell_order["price"] diff --git a/src/infinity_grid/strategies/grid_sell.py b/src/infinity_grid/strategies/grid_sell.py index c6ff821..aae9ae3 100644 --- a/src/infinity_grid/strategies/grid_sell.py +++ b/src/infinity_grid/strategies/grid_sell.py @@ -20,18 +20,6 @@ class GridSellStrategy(GridStrategyBase): - def _get_sell_order_price( - self: Self, - last_price: float, - extra_sell: bool = False, # noqa: ARG002 - buy_txid: str | None = None, - ) -> float: - """ - Returns the sell order price depending. Also assigns a new highest buy - price to configuration if there was a new highest buy. - """ - # Use the base class implementation which handles TSP - return super()._get_sell_order_price(last_price, buy_txid) def _check_extra_sell_order(self: Self) -> None: """Not applicable for GridSell strategy.""" diff --git a/tests/unit/strategies/test_cdca.py b/tests/unit/strategies/test_cdca.py index ff16e70..9822cbf 100644 --- a/tests/unit/strategies/test_cdca.py +++ b/tests/unit/strategies/test_cdca.py @@ -57,12 +57,10 @@ def test_get_sell_order_price_returns_none( self: Self, mock_strategy: mock.MagicMock, ) -> None: - """Test that sell order price always returns None for cDCA strategy.""" - last_price = 50000.0 - - result = mock_strategy._get_sell_order_price(last_price) + """Test that the cDCA strategy does not provide a sell order price""" + with pytest.raises(RuntimeError, match="cDCA strategy does not place sell orders."): + mock_strategy._get_sell_order_price(50000) - assert result is None def test_get_sell_order_price_updates_highest_buy_price( self: Self, From 123cb245337c6eaee925e98149fb1ebe1c27be0c Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Sat, 6 Sep 2025 20:14:32 +0200 Subject: [PATCH 3/8] [skip ci] first steps towards trailing stop profit --- doc/05_need2knows.rst | 3 + src/infinity_grid/infrastructure/database.py | 78 +++++++-- src/infinity_grid/strategies/grid_base.py | 174 +++++-------------- src/infinity_grid/strategies/grid_hodl.py | 10 +- 4 files changed, 112 insertions(+), 153 deletions(-) diff --git a/doc/05_need2knows.rst b/doc/05_need2knows.rst index 5e4c69d..f28904a 100644 --- a/doc/05_need2knows.rst +++ b/doc/05_need2knows.rst @@ -79,6 +79,9 @@ higher price in the future. the bot to identify which orders belong to him. Using the same userref for different assets or running multiple bot instances for the same or different asset pairs using the same userref will result in errors. +- It is recommended to not trade the same asset pair by hand or running multiple + instances of the infinity-grid bot on the same asset pair, otherwise there + will be conflicts rising raise conditions. 🐙 Kraken Crypto Asset Exchange ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/infinity_grid/infrastructure/database.py b/src/infinity_grid/infrastructure/database.py index 92a8cf1..05e47bf 100644 --- a/src/infinity_grid/infrastructure/database.py +++ b/src/infinity_grid/infrastructure/database.py @@ -12,7 +12,7 @@ from logging import getLogger from typing import Any, Self -from sqlalchemy import Column, Float, Integer, String, Table, func, select +from sqlalchemy import Column, Float, Integer, String, Table, func, select, Boolean from sqlalchemy.engine.result import MappingResult from sqlalchemy.engine.row import RowMapping @@ -39,6 +39,7 @@ def __init__(self: Self, userref: int, db: DBConnect) -> None: Column("side", String, nullable=False), Column("price", Float, nullable=False), Column("volume", Float, nullable=False), + Column("tsp_active", Boolean, default=False), ) def add(self: Self, order: OrderInfoSchema) -> None: @@ -101,13 +102,22 @@ def update(self: Self, updates: OrderInfoSchema) -> None: self.__table, filters={"userref": self.__userref, "txid": updates.txid}, updates={ - "symbol": updates.pair, "side": updates.side, "price": updates.price, "volume": updates.vol, }, ) + def enable_tsp(self: Self, txid: str) -> None: + """Enabling trailing stop profit for a specific order.""" + LOG.debug("Enabling trailing stop profit for order '%s'", txid) + + self.__db.update_row( + self.__table, + filters={"userref": self.__userref, "txid": txid}, + updates={"tsp_active": True}, + ) + def count( self: Self, filters: dict | None = None, @@ -350,7 +360,7 @@ def get(self: Self, filters: dict | None = None) -> MappingResult: filters |= {"userref": self.__userref} LOG.debug( - "Getting pending orders from the 'pending_txids' table with filter: %s", + "Getting orders from the 'pending_txids' table with filter: %s", filters, ) @@ -359,20 +369,16 @@ def get(self: Self, filters: dict | None = None) -> MappingResult: def add(self: Self, txid: str) -> None: """Add a pending order to the table.""" LOG.debug( - "Adding a pending txid to the 'pending_txids' table: '%s'", + "Adding an order to the 'pending_txids' table: '%s'", txid, ) - self.__db.add_row( - self.__table, - userref=self.__userref, - txid=txid, - ) + self.__db.add_row(self.__table, userref=self.__userref, txid=txid) def remove(self: Self, txid: str) -> None: """Remove a pending order from the table.""" LOG.debug( - "Removing pending txid from the 'pending_txids' table with filters: %s", + "Removing order from the 'pending_txids' table with filters: %s", filters := {"userref": self.__userref, "txid": txid}, ) self.__db.delete_row(self.__table, filters=filters) @@ -384,7 +390,7 @@ def count(self: Self, filters: dict | None = None) -> int: filters |= {"userref": self.__userref} LOG.debug( - "Counting pending txids of the 'pending_txids' table with filter: %s", + "Counting orders in 'pending_txids' table with filter: %s", filters, ) @@ -396,3 +402,53 @@ def count(self: Self, filters: dict | None = None) -> int: ) ) return self.__db.session.execute(query).scalar() # type: ignore[no-any-return] + + +class FutureOrders: + """ + Table containing orders that need to be placed as soon as possible. + """ + + def __init__(self: Self, userref: int, db: DBConnect) -> None: + LOG.debug("Initializing the FutureOrders table...") + self.__db = db + self.__userref = userref + self.__table = Table( + "future_orders", + self.__db.metadata, + Column("id", Integer, primary_key=True), + Column("previous_txid", String, nullable=False), + Column("price", Float, nullable=False), + ) + + def get(self: Self, filters: dict | None = None) -> MappingResult: + """Get row from the table.""" + if not filters: + filters = {} + filters |= {"userref": self.__userref} + + LOG.debug( + "Getting rows from the 'future_orders' table with filter: %s", + filters, + ) + + return self.__db.get_rows(self.__table, filters=filters) + + def add(self: Self, previous_txid: str, price: float) -> None: + """Add an order to the table.""" + LOG.debug("Adding a order to the 'future_orders' table: price: %s", price) + self.__db.add_row( + self.__table, + userref=self.__userref, + previous_txid=previous_txid, + price=price, + ) + + def remove(self: Self, previous_txid: str) -> None: + """Remove a row from the table.""" + + LOG.debug( + "Removing row from the 'future_orders' table with filters: %s", + filters := {"userref": self.__userref, "previous_txid": previous_txid}, + ) + self.__db.delete_row(self.__table, filters=filters) diff --git a/src/infinity_grid/strategies/grid_base.py b/src/infinity_grid/strategies/grid_base.py index f6a9025..77ae5ea 100644 --- a/src/infinity_grid/strategies/grid_base.py +++ b/src/infinity_grid/strategies/grid_base.py @@ -29,6 +29,7 @@ Orderbook, PendingTXIDs, UnsoldBuyOrderTXIDs, + FutureOrders, ) from infinity_grid.interfaces.exchange import ( IExchangeRESTService, @@ -85,6 +86,7 @@ def __init__( self._config.userref, db, ) + self._future_orders_table: FutureOrders = FutureOrders(self._config.userref, db) db.init_db() # Tracks the last time a ticker message was received for checking @@ -1158,9 +1160,7 @@ def _assign_order_by_txid(self: Self, txid: str) -> None: self._orderbook_table.add(order_details) self._pending_txids_table.remove(order_details.txid) else: - self._orderbook_table.update( - order_details, - ) + self._orderbook_table.update(order_details) LOG.info("Updated order '%s' in orderbook.", order_details.txid) LOG.info( @@ -1358,149 +1358,53 @@ def __check_tsp(self: Self) -> None: """ Check TSP conditions for all open sell orders. - For each sell order that was placed with TSP enabled: - 1. Calculate if TSP should be activated (price >= buy_price * (1 + - interval + TSP)) - 2. If activated and price moved up, cancel current sell order and place - higher - 3. If activated and price moved down to stop level, place stop-loss sell - order - - # FIXME: we might need a new table for tracking orders that were - cancelled by TSP and need a new placement + FIXME: Set tsp_active to orders placed from future_orders table """ + if not self._ticker or self._config.dry_run: return - current_price = self._ticker - sell_orders = self._orderbook_table.get_orders( filters={"side": self._exchange_domain.SELL}, ).all() + for order in self._future_orders_table.get(): + # create a new sell order + # self._new_sell_order cannot be used! + pass + for sell_order in sell_orders: sell_price = sell_order["price"] - sell_txid = sell_order["txid"] - - # Calculate the original buy price from the sell price - # For TSP orders: sell_price = buy_price * (1 + interval + 2×TSP) - # For normal orders: sell_price = buy_price * (1 + interval) - - # We need to determine if this was a TSP order or normal order - # We can do this by checking if the sell price fits the TSP pattern - tsp_factor = ( - 1 + self._config.interval + (2 * self._config.trailing_stop_profit) - ) - normal_factor = 1 + self._config.interval - - # Try to back-calculate buy price assuming TSP - potential_buy_price_tsp = sell_price / tsp_factor - potential_buy_price_normal = sell_price / normal_factor - - # Check which scenario is more likely based on current price patterns - # If the sell order is much higher than normal, it's likely a TSP order - price_diff_tsp = abs( - current_price - - potential_buy_price_tsp - * (1 + self._config.interval + self._config.trailing_stop_profit) - ) - price_diff_normal = abs( - current_price - potential_buy_price_normal * (1 + self._config.interval) + reference_price = sell_price / ( # buy price or last shift-up price + 1 + self._config.interval + 2 * self._config.trailing_stop_profit, ) + if self._ticker < reference_price: # only interested in profitable things + # If market price is below buy/reference price + continue - # If TSP pattern fits better, treat as TSP order - if price_diff_tsp < price_diff_normal and tsp_factor > normal_factor: - buy_price = potential_buy_price_tsp - activation_price = buy_price * ( - 1 + self._config.interval + self._config.trailing_stop_profit - ) + tsp_price = reference_price + sell_price * ( + 2 * self._config.trailing_stop_profit + ) - LOG.debug( - "Checking TSP for sell order %s: buy_price=%s, activation_price=%s, current_price=%s", - sell_txid, - buy_price, - activation_price, - current_price, + if ( + sell_order["tsp_active"] # TSP must be activated + and self._ticker <= tsp_price # Price must be lower or equal than TSP + and self._ticker + > reference_price * (1 + 2 * self._config.fee) # Must be profitable + ): + # FIXME: place market sell order + # Ensure that sell is profitable + self._rest_api.cancel_order(txid=sell_order["txid"]) + continue + if self._ticker >= reference_price * ( + 1 + self._config.interval + self._config.trailing_stop_profit + ): + # FIXME: only shift up in case the filled volume is 0.0 + # Shift up the leading limit order + self._future_orders_table.add( + sell_order["txid"], + price=sell_price + + reference_price * self._config.trailing_stop_profit, ) - - # Case 1: TSP should be activated (price reached activation level) - if current_price >= activation_price: - LOG.info( - "TSP activation for sell order %s: price %s >= activation %s", - sell_txid, - current_price, - activation_price, - ) - - # Cancel current sell order and place new higher one - try: - self._rest_api.cancel_order(txid=sell_txid) - self._orderbook_table.remove(filters={"txid": sell_txid}) - - # Calculate new sell price based on how much higher the price went - # New sell price = buy_price * (1 + interval + (N+2)×TSP) - # where N is how many TSP increments above activation we are - tsp_increments = ( - int( - (current_price - activation_price) - / (buy_price * self._config.trailing_stop_profit) - ) - + 1 - ) - new_sell_factor = ( - 1 - + self._config.interval - + ((tsp_increments + 2) * self._config.trailing_stop_profit) - ) - new_sell_price = buy_price * new_sell_factor - - LOG.info( - "Placing new TSP sell order at %s (was %s)", - new_sell_price, - sell_price, - ) - - # Place new sell order via strategy's method - self._handle_arbitrage( - side=self._exchange_domain.SELL, - order_price=new_sell_price, - ) - - except Exception as exc: - LOG.error( - "Failed to update TSP sell order %s: %s", sell_txid, exc - ) - - # Case 2: Check if price dropped to trailing stop level - elif current_price <= buy_price * (1 + self._config.interval): - LOG.info( - "TSP stop triggered for sell order %s: price %s <= stop level %s", - sell_txid, - current_price, - buy_price * (1 + self._config.interval), - ) - - # Cancel current sell order and place stop-loss order - try: - self._rest_api.cancel_order(txid=sell_txid) - self._orderbook_table.remove(filters={"txid": sell_txid}) - - # Place stop-loss sell order at minimum profit level - stop_price = buy_price * (1 + self._config.interval) - - LOG.info( - "Placing TSP stop-loss sell order at %s", - stop_price, - ) - - self._handle_arbitrage( - side=self._exchange_domain.SELL, - order_price=stop_price, - ) - - except Exception as exc: - LOG.error( - "Failed to place TSP stop-loss order for %s: %s", - sell_txid, - exc, - ) + self._rest_api.cancel_order(txid=sell_order["txid"]) + continue diff --git a/src/infinity_grid/strategies/grid_hodl.py b/src/infinity_grid/strategies/grid_hodl.py index e0fa7b8..144baef 100644 --- a/src/infinity_grid/strategies/grid_hodl.py +++ b/src/infinity_grid/strategies/grid_hodl.py @@ -77,15 +77,11 @@ def _new_sell_order( ) sleep(1) self._new_sell_order( - order_price=order_price, - txid_to_delete=txid_to_delete, + order_price=order_price, txid_to_delete=txid_to_delete ) return order_price = float( - self._rest_api.truncate( - amount=order_price, - amount_type="price", - ), + self._rest_api.truncate(amount=order_price, amount_type="price"), ) # Respect the fee to not reduce the quote currency over time, while @@ -142,7 +138,7 @@ def _new_sell_order( self._event_bus.publish( "notification", - data={"message": message}, + data={"message": message} ) LOG.warning("Current balances: %s", fetched_balances) From 2b5bbea274e68e24c80a69269755bab235c3b68a Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Sun, 7 Sep 2025 09:13:47 +0200 Subject: [PATCH 4/8] [skip ci] stash --- src/infinity_grid/infrastructure/database.py | 26 ++++---- src/infinity_grid/strategies/grid_base.py | 67 +++++++++++--------- src/infinity_grid/strategies/grid_hodl.py | 7 +- 3 files changed, 54 insertions(+), 46 deletions(-) diff --git a/src/infinity_grid/infrastructure/database.py b/src/infinity_grid/infrastructure/database.py index 05e47bf..55984e8 100644 --- a/src/infinity_grid/infrastructure/database.py +++ b/src/infinity_grid/infrastructure/database.py @@ -12,7 +12,7 @@ from logging import getLogger from typing import Any, Self -from sqlalchemy import Column, Float, Integer, String, Table, func, select, Boolean +from sqlalchemy import Boolean, Column, Float, Integer, String, Table, func, select from sqlalchemy.engine.result import MappingResult from sqlalchemy.engine.row import RowMapping @@ -410,15 +410,15 @@ class FutureOrders: """ def __init__(self: Self, userref: int, db: DBConnect) -> None: - LOG.debug("Initializing the FutureOrders table...") + LOG.debug("Initializing the 'future_orders' table...") self.__db = db self.__userref = userref self.__table = Table( "future_orders", self.__db.metadata, Column("id", Integer, primary_key=True), - Column("previous_txid", String, nullable=False), Column("price", Float, nullable=False), + Column("placed", Boolean, default=False, nullable=False), ) def get(self: Self, filters: dict | None = None) -> MappingResult: @@ -434,21 +434,19 @@ def get(self: Self, filters: dict | None = None) -> MappingResult: return self.__db.get_rows(self.__table, filters=filters) - def add(self: Self, previous_txid: str, price: float) -> None: + def add(self: Self, price: float) -> None: """Add an order to the table.""" LOG.debug("Adding a order to the 'future_orders' table: price: %s", price) - self.__db.add_row( - self.__table, - userref=self.__userref, - previous_txid=previous_txid, - price=price, - ) + self.__db.add_row(self.__table, userref=self.__userref, price=price) - def remove(self: Self, previous_txid: str) -> None: - """Remove a row from the table.""" + def set_placed(self: Self, price: float) -> None: + LOG.debug("Setting order with price %s as placed", price) + self.__db.add_row(self.__table, userref=self.__userref, price=price) + def remove_placed_orders(self: Self) -> None: + """Remove a row from the table.""" LOG.debug( - "Removing row from the 'future_orders' table with filters: %s", - filters := {"userref": self.__userref, "previous_txid": previous_txid}, + "Removing rows from the 'future_orders' table with filters: %s", + filters := {"userref": self.__userref, "placed": True}, ) self.__db.delete_row(self.__table, filters=filters) diff --git a/src/infinity_grid/strategies/grid_base.py b/src/infinity_grid/strategies/grid_base.py index 77ae5ea..7721644 100644 --- a/src/infinity_grid/strategies/grid_base.py +++ b/src/infinity_grid/strategies/grid_base.py @@ -26,10 +26,10 @@ from infinity_grid.exceptions import BotStateError, UnknownOrderError from infinity_grid.infrastructure.database import ( Configuration, + FutureOrders, Orderbook, PendingTXIDs, UnsoldBuyOrderTXIDs, - FutureOrders, ) from infinity_grid.interfaces.exchange import ( IExchangeRESTService, @@ -1035,7 +1035,8 @@ def handle_filled_order_event(self: Self, txid: str) -> None: def _handle_cancel_order(self: Self, txid: str) -> None: """ Cancels an order by txid, removes it from the orderbook, and checks if - there there was some volume executed which can be sold later. + there there was some volume executed which can be sold later in case of + a buy order. NOTE: The orderbook is the "gate keeper" of this function. If the order is not present in the local orderbook, nothing will happen. @@ -1048,7 +1049,6 @@ def _handle_cancel_order(self: Self, txid: str) -> None: via API and removed from the orderbook. The incoming "canceled" message by the websocket will be ignored, as the order is already removed from the orderbook. - """ if self._orderbook_table.count(filters={"txid": txid}) == 0: return @@ -1083,7 +1083,10 @@ def _handle_cancel_order(self: Self, txid: str) -> None: # Check if the order has some vol_exec to sell ## - if order_details.vol_exec != 0.0: + if ( + order_details.vol_exec != 0.0 + and order_details.side == self._exchange_domain.BUY + ): LOG.info( "Order '%s' is partly filled - saving those funds.", txid, @@ -1361,30 +1364,48 @@ def __check_tsp(self: Self) -> None: FIXME: Set tsp_active to orders placed from future_orders table """ - if not self._ticker or self._config.dry_run: + if ( + not self._ticker + or self._config.dry_run + or not self._config.trailing_stop_profit + ): return - sell_orders = self._orderbook_table.get_orders( - filters={"side": self._exchange_domain.SELL}, - ).all() - for order in self._future_orders_table.get(): # create a new sell order # self._new_sell_order cannot be used! + # 1. place a new sell order + # 2. ensure that it as tsp active + pass - for sell_order in sell_orders: + tsp_p = self._config.trailing_stop_profit + interval = self._config.interval + + for sell_order in self._orderbook_table.get_orders( + filters={"side": self._exchange_domain.SELL}, + ).all(): sell_price = sell_order["price"] - reference_price = sell_price / ( # buy price or last shift-up price - 1 + self._config.interval + 2 * self._config.trailing_stop_profit, - ) + + # buy price or last shift-up price + reference_price = sell_price / (1 + interval + 2 * tsp_p) + if self._ticker < reference_price: # only interested in profitable things # If market price is below buy/reference price continue - tsp_price = reference_price + sell_price * ( - 2 * self._config.trailing_stop_profit - ) + tsp_price = reference_price + sell_price * (2 * tsp_p) + + # 1. Cancel leading sell order in case of shifting up sell order due + # to reached threshold + if self._ticker >= reference_price * (1 + self._config.interval + tsp_p): + # FIXME: only shift up in case the filled volume is 0.0 + # Shift up the leading limit order + self._future_orders_table.add( + price=sell_price + reference_price * tsp_p, + ) + self._handle_cancel_order(txid=sell_order["txid"]) + continue if ( sell_order["tsp_active"] # TSP must be activated @@ -1394,17 +1415,5 @@ def __check_tsp(self: Self) -> None: ): # FIXME: place market sell order # Ensure that sell is profitable - self._rest_api.cancel_order(txid=sell_order["txid"]) - continue - if self._ticker >= reference_price * ( - 1 + self._config.interval + self._config.trailing_stop_profit - ): - # FIXME: only shift up in case the filled volume is 0.0 - # Shift up the leading limit order - self._future_orders_table.add( - sell_order["txid"], - price=sell_price - + reference_price * self._config.trailing_stop_profit, - ) - self._rest_api.cancel_order(txid=sell_order["txid"]) + self._handle_cancel_order(txid=sell_order["txid"]) continue diff --git a/src/infinity_grid/strategies/grid_hodl.py b/src/infinity_grid/strategies/grid_hodl.py index 144baef..72462de 100644 --- a/src/infinity_grid/strategies/grid_hodl.py +++ b/src/infinity_grid/strategies/grid_hodl.py @@ -42,7 +42,6 @@ def _new_sell_order( # ====================================================================== volume: float | None = None if txid_to_delete is not None: # If corresponding buy order filled - # GridSell always has txid_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 @@ -77,9 +76,11 @@ def _new_sell_order( ) sleep(1) self._new_sell_order( - order_price=order_price, txid_to_delete=txid_to_delete + order_price=order_price, + txid_to_delete=txid_to_delete, ) return + order_price = float( self._rest_api.truncate(amount=order_price, amount_type="price"), ) @@ -138,7 +139,7 @@ def _new_sell_order( self._event_bus.publish( "notification", - data={"message": message} + data={"message": message}, ) LOG.warning("Current balances: %s", fetched_balances) From 63e24ee588f661c1d10bbb51956190a05bb58dcc Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Sun, 7 Sep 2025 11:23:45 +0200 Subject: [PATCH 5/8] [skip ci] stash --- .github/copilot-instructions.md | 18 +- src/infinity_grid/infrastructure/database.py | 188 ++++++++- src/infinity_grid/strategies/grid_base.py | 393 ++++++++++++++++--- 3 files changed, 522 insertions(+), 77 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fb4a283..14da3b5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,6 +11,18 @@ bot is designed to run in a containerized environment. - `./doc`: Contains documentation for the project, including how to set it up, develop with, and concepts to extend the project. - `./src`: Contains the source code for the trading bot. +- `./src/infinity_grid/adapters`: Contains exchange and notification adapters +- `./src/infinity_grid/core`: Contains the CLI, bot engine, state machine and + event bus +- `./src/infinity_grid/infrastructure`: Contains the database table classes +- `./src/infinity_grid/interfaces`: Contains the interfaces for exchanges, + notification channels, and strategies +- `./src/infinity_grid/models`: Contains schemas, models, and data transfer + objects +- `./src/infinity_grid/services`: Contains services like Notification service + and database connectors +- `./src/infinity_grid/strategies`: Contains the implementations of the + strategies - `./tests`: Contains the unit, integration, acceptance, etc tests for this project. @@ -21,7 +33,7 @@ bot is designed to run in a containerized environment. framework for allowing to extend and add new strategies and exchanges to the project. -## Coding Standards +## Guidelines -- Use black for formatting Python code. -- Pre-commit hooks must pass before committing +- Best Software Engineering practices like KISS, modularization, and efficiency + must be followed. diff --git a/src/infinity_grid/infrastructure/database.py b/src/infinity_grid/infrastructure/database.py index 55984e8..b8f292e 100644 --- a/src/infinity_grid/infrastructure/database.py +++ b/src/infinity_grid/infrastructure/database.py @@ -39,7 +39,6 @@ def __init__(self: Self, userref: int, db: DBConnect) -> None: Column("side", String, nullable=False), Column("price", Float, nullable=False), Column("volume", Float, nullable=False), - Column("tsp_active", Boolean, default=False), ) def add(self: Self, order: OrderInfoSchema) -> None: @@ -108,16 +107,6 @@ def update(self: Self, updates: OrderInfoSchema) -> None: }, ) - def enable_tsp(self: Self, txid: str) -> None: - """Enabling trailing stop profit for a specific order.""" - LOG.debug("Enabling trailing stop profit for order '%s'", txid) - - self.__db.update_row( - self.__table, - filters={"userref": self.__userref, "txid": txid}, - updates={"tsp_active": True}, - ) - def count( self: Self, filters: dict | None = None, @@ -417,10 +406,14 @@ def __init__(self: Self, userref: int, db: DBConnect) -> None: "future_orders", self.__db.metadata, Column("id", Integer, primary_key=True), + Column("userref", Integer, nullable=False), Column("price", Float, nullable=False), Column("placed", Boolean, default=False, nullable=False), ) + # Create the table if it doesn't exist + self.__table.create(bind=self.__db.engine, checkfirst=True) + def get(self: Self, filters: dict | None = None) -> MappingResult: """Get row from the table.""" if not filters: @@ -441,7 +434,11 @@ def add(self: Self, price: float) -> None: def set_placed(self: Self, price: float) -> None: LOG.debug("Setting order with price %s as placed", price) - self.__db.add_row(self.__table, userref=self.__userref, price=price) + self.__db.update_row( + self.__table, + filters={"userref": self.__userref, "price": price, "placed": False}, + updates={"placed": True}, + ) def remove_placed_orders(self: Self) -> None: """Remove a row from the table.""" @@ -450,3 +447,170 @@ def remove_placed_orders(self: Self) -> None: filters := {"userref": self.__userref, "placed": True}, ) self.__db.delete_row(self.__table, filters=filters) + + +class TSPState: + """ + Table for tracking Trailing Stop Profit state independently of orders. + This table maintains TSP state even when orders are canceled and replaced, + ensuring continuity of TSP tracking. + """ + + def __init__( + self: Self, userref: int, db: DBConnect, tsp_percentage: float = 0.01 + ) -> None: + LOG.debug("Initializing the 'tsp_state' table...") + self.__db = db + self.__userref = userref + self.__tsp_percentage = tsp_percentage + self.__table = Table( + "tsp_state", + self.__db.metadata, + Column("id", Integer, primary_key=True), + Column("userref", Integer, nullable=False), + Column("original_buy_txid", String, nullable=False), # UNIQUE KEY per position + Column("original_buy_price", Float, nullable=False), # Never changes + Column("current_stop_price", Float, nullable=False), # Updates as trailing stop moves + Column("highest_price_reached", Float, nullable=False), # Updates as price rises + Column("tsp_active", Boolean, default=False), # Whether TSP is currently active + Column("current_sell_order_txid", String, nullable=True), # Updates when orders shift + ) + + # Create the table if it doesn't exist + self.__table.create(bind=self.__db.engine, checkfirst=True) + + def add( + self: Self, + original_buy_txid: str, + original_buy_price: float, + initial_stop_price: float, + sell_order_txid: str, + ) -> None: + """Add a new TSP tracking entry.""" + LOG.debug( + "Adding TSP state: buy_txid=%s, buy_price=%s, stop_price=%s, sell_txid=%s", + original_buy_txid, + original_buy_price, + initial_stop_price, + sell_order_txid, + ) + self.__db.add_row( + self.__table, + userref=self.__userref, + original_buy_txid=original_buy_txid, + original_buy_price=original_buy_price, + current_stop_price=initial_stop_price, + highest_price_reached=original_buy_price, + tsp_active=False, + current_sell_order_txid=sell_order_txid, + ) + + def update_sell_order_txid(self: Self, old_txid: str | None, new_txid: str) -> None: + """Update the sell order TXID when order is replaced.""" + LOG.debug("Updating TSP sell order TXID from %s to %s", old_txid, new_txid) + + if old_txid is None: + # Special case: updating from None (unlinked state) + # We need to find the record and update it, but we can't filter by None + # This is handled in the calling code with a direct update + return + + self.__db.update_row( + self.__table, + filters={"userref": self.__userref, "current_sell_order_txid": old_txid}, + updates={"current_sell_order_txid": new_txid}, + ) + + def update_sell_order_txid_by_buy_txid(self: Self, original_buy_txid: str, new_sell_txid: str) -> None: + """Update sell order TXID for a specific buy TXID.""" + LOG.debug("Updating sell order TXID for buy %s to %s", original_buy_txid, new_sell_txid) + self.__db.update_row( + self.__table, + filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, + updates={"current_sell_order_txid": new_sell_txid}, + ) + + def activate_tsp(self: Self, original_buy_txid: str, current_price: float) -> None: + """Activate TSP for a specific position.""" + LOG.debug( + "Activating TSP for buy_txid %s at current price %s", + original_buy_txid, + current_price, + ) + self.__db.update_row( + self.__table, + filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, + updates={ + "tsp_active": True, + "highest_price_reached": current_price, + "current_stop_price": current_price * (1 - self.__get_tsp_percentage()), + }, + ) + + def update_trailing_stop( + self: Self, original_buy_txid: str, current_price: float + ) -> None: + """Update trailing stop level if price has moved higher.""" + current_state = self.get_by_buy_txid(original_buy_txid) + if not current_state or not current_state["tsp_active"]: + return + + if current_price > current_state["highest_price_reached"]: + new_stop_price = current_price * (1 - self.__get_tsp_percentage()) + LOG.debug( + "Updating trailing stop for buy_txid=%s: new_stop=%s, highest=%s", + original_buy_txid, + new_stop_price, + current_price, + ) + self.__db.update_row( + self.__table, + filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, + updates={ + "highest_price_reached": current_price, + "current_stop_price": new_stop_price, + }, + ) + + def get_by_buy_txid(self: Self, original_buy_txid: str) -> RowMapping | None: + """Get TSP state for a specific buy TXID.""" + result = self.__db.get_rows( + self.__table, + filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, + ) + return result.fetchone() + + def get_by_sell_txid(self: Self, sell_txid: str) -> RowMapping | None: + """Get TSP state by current sell order TXID.""" + result = self.__db.get_rows( + self.__table, + filters={"userref": self.__userref, "current_sell_order_txid": sell_txid}, + ) + return result.fetchone() + + def get_all_active(self: Self) -> MappingResult: + """Get all active TSP states.""" + return self.__db.get_rows( + self.__table, + filters={"userref": self.__userref, "tsp_active": True}, + ) + + def remove_by_buy_txid(self: Self, original_buy_txid: str) -> None: + """Remove TSP state when position is closed.""" + LOG.debug("Removing TSP state for buy TXID %s", original_buy_txid) + self.__db.delete_row( + self.__table, + filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, + ) + + def remove_by_txid(self: Self, txid: str) -> None: + """Remove TSP state by sell order TXID.""" + LOG.debug("Removing TSP state for sell order %s", txid) + self.__db.delete_row( + self.__table, + filters={"userref": self.__userref, "current_sell_order_txid": txid}, + ) + + def __get_tsp_percentage(self: Self) -> float: + """Get TSP percentage from configuration.""" + return self.__tsp_percentage diff --git a/src/infinity_grid/strategies/grid_base.py b/src/infinity_grid/strategies/grid_base.py index 7721644..03e6d6a 100644 --- a/src/infinity_grid/strategies/grid_base.py +++ b/src/infinity_grid/strategies/grid_base.py @@ -29,6 +29,7 @@ FutureOrders, Orderbook, PendingTXIDs, + TSPState, UnsoldBuyOrderTXIDs, ) from infinity_grid.interfaces.exchange import ( @@ -87,6 +88,10 @@ def __init__( db, ) self._future_orders_table: FutureOrders = FutureOrders(self._config.userref, db) + # FIXME: not needed if tsp not activated + self._tsp_state_table: TSPState = TSPState( + self._config.userref, db, tsp_percentage=self._config.trailing_stop_profit + ) db.init_db() # Tracks the last time a ticker message was received for checking @@ -650,8 +655,40 @@ def __check_price_range(self: Self) -> None: # Check TSP conditions if enabled if self._config.trailing_stop_profit: + # Process future orders (TSP shifted orders) + self.__process_future_orders() + # First associate any unlinked sell orders with TSP states + self.__associate_sell_orders_with_tsp() + # Then run the main TSP logic self.__check_tsp() + def __process_future_orders(self: Self) -> None: + """ + Process pending future orders (mainly from TSP shifts). + + This creates actual sell orders from the future_orders table entries. + """ + if self._config.dry_run: + LOG.debug("Dry run, not processing future orders.") + return + + future_orders = self._future_orders_table.get(filters={"placed": False}) + + for future_order in future_orders: + price = future_order["price"] + + LOG.info("Processing future order at price %s", price) + + # Create the sell order using the standard mechanism + # The volume calculation will be handled by _new_sell_order + self._new_sell_order(order_price=price, txid_to_delete=None) + + # Mark this future order as placed + self._future_orders_table.set_placed(price=price) + + # Clean up placed orders (must be done immediately) + self._future_orders_table.remove_placed_orders() + # ========================================================================== def __add_missed_sell_orders(self: Self) -> None: """ @@ -968,8 +1005,8 @@ def handle_filled_order_event(self: Self, txid: str) -> None: LOG.warning( "Can not handle filled order, since the fetched order is not" " closed in upstream!" - " This may happen due to Kraken's websocket API being faster" - " than their REST backend. Retrying in a few seconds...", + " This may happen due to websocket API being faster" + " than the REST backend. Retrying in a few seconds...", ) self.handle_filled_order_event(txid=txid) return @@ -1001,36 +1038,51 @@ def handle_filled_order_event(self: Self, txid: str) -> None: # Create a sell order for the executed buy order. ## if order_details.side == self._exchange_domain.BUY: + sell_price = self._get_sell_order_price(last_price=order_details.price) + self._handle_arbitrage( side=self._exchange_domain.SELL, - order_price=self._get_sell_order_price(last_price=order_details.price), + order_price=sell_price, txid_to_delete=txid, ) - # ====================================================================== + # Initialize TSP state if TSP is enabled + if self._config.trailing_stop_profit: + self._initialize_tsp_for_new_position( + original_buy_txid=txid, + buy_price=order_details.price, + sell_price=sell_price, + ) + + # ================================================================== # Create a buy order for the executed sell order. ## - elif ( - self._orderbook_table.count( - filters={"side": self._exchange_domain.SELL}, - exclude={"txid": txid}, - ) - != 0 - ): - # A new buy order will only be placed if there is another sell - # order, because if the last sell order was filled, the price is so - # high, that all buy orders will be canceled anyway and new buy - # orders will be placed in ``check_price_range`` during shift-up. - self._handle_arbitrage( - side=self._exchange_domain.BUY, - order_price=self._get_buy_order_price( - last_price=order_details.price, - ), - txid_to_delete=txid, - ) - else: - # Remove filled order from list of all orders - self._orderbook_table.remove(filters={"txid": txid}) + elif order_details.side == self._exchange_domain.SELL: + # Clean up TSP state if a sell order was filled + if self._config.trailing_stop_profit: + self._cleanup_tsp_state_for_filled_sell_order(txid) + + if ( + self._orderbook_table.count( + filters={"side": self._exchange_domain.SELL}, + exclude={"txid": txid}, + ) + != 0 + ): + # A new buy order will only be placed if there is another sell + # order, because if the last sell order was filled, the price is so + # high, that all buy orders will be canceled anyway and new buy + # orders will be placed in ``check_price_range`` during shift-up. + self._handle_arbitrage( + side=self._exchange_domain.BUY, + order_price=self._get_buy_order_price( + last_price=order_details.price, + ), + txid_to_delete=txid, + ) + else: + # Remove filled order from list of all orders + self._orderbook_table.remove(filters={"txid": txid}) def _handle_cancel_order(self: Self, txid: str) -> None: """ @@ -1081,6 +1133,12 @@ def _handle_cancel_order(self: Self, txid: str) -> None: self._orderbook_table.remove(filters={"txid": txid}) + # Clean up TSP state if this was a sell order being canceled + if order_details.side == self._exchange_domain.SELL: + # Don't remove TSP state here as the order might be replaced + # TSP state cleanup happens when position is actually closed + pass + # Check if the order has some vol_exec to sell ## if ( @@ -1357,13 +1415,11 @@ def _new_sell_order( """ raise NotImplementedError("This method should be implemented by subclasses.") - def __check_tsp(self: Self) -> None: - """ - Check TSP conditions for all open sell orders. - - FIXME: Set tsp_active to orders placed from future_orders table - """ + # ========================================================================== + # Trailing Stop Profit + def __check_tsp(self: Self) -> None: + """Check and manage Trailing Stop Profit for all tracked positions.""" if ( not self._ticker or self._config.dry_run @@ -1371,49 +1427,262 @@ def __check_tsp(self: Self) -> None: ): return - for order in self._future_orders_table.get(): - # create a new sell order - # self._new_sell_order cannot be used! - # 1. place a new sell order - # 2. ensure that it as tsp active - - pass + LOG.debug("Checking TSP conditions at price: %s", self._ticker) - tsp_p = self._config.trailing_stop_profit + tsp_percentage = self._config.trailing_stop_profit interval = self._config.interval + # Process each sell order and match with TSP state for sell_order in self._orderbook_table.get_orders( filters={"side": self._exchange_domain.SELL}, ).all(): sell_price = sell_order["price"] + sell_txid = sell_order["txid"] + + # Try to find existing TSP state for this sell order + if not (tsp_state := self._tsp_state_table.get_by_sell_txid(sell_txid)): + # This sell order doesn't have TSP state yet + # This can happen for: + # 1. Sell orders from shift-up operations + # 2. Extra sell orders from SWING strategy + LOG.debug( + "No TSP state found for sell order '%s', skipping TSP check", + sell_txid, + ) + continue - # buy price or last shift-up price - reference_price = sell_price / (1 + interval + 2 * tsp_p) + original_buy_price = tsp_state["original_buy_price"] + original_buy_txid = tsp_state["original_buy_txid"] - if self._ticker < reference_price: # only interested in profitable things - # If market price is below buy/reference price + # Skip if current price is below original buy price (not profitable) + if self._ticker < original_buy_price: continue - tsp_price = reference_price + sell_price * (2 * tsp_p) + # Check if TSP should be activated + tsp_activation_price = original_buy_price * (1 + interval + tsp_percentage) - # 1. Cancel leading sell order in case of shifting up sell order due - # to reached threshold - if self._ticker >= reference_price * (1 + self._config.interval + tsp_p): - # FIXME: only shift up in case the filled volume is 0.0 - # Shift up the leading limit order - self._future_orders_table.add( - price=sell_price + reference_price * tsp_p, + if not tsp_state["tsp_active"] and self._ticker >= tsp_activation_price: + LOG.info( + "Activating TSP for position %s (buy_price=%s) at current price %s", + original_buy_txid, + original_buy_price, + self._ticker, ) - self._handle_cancel_order(txid=sell_order["txid"]) - continue - if ( - sell_order["tsp_active"] # TSP must be activated - and self._ticker <= tsp_price # Price must be lower or equal than TSP - and self._ticker - > reference_price * (1 + 2 * self._config.fee) # Must be profitable - ): - # FIXME: place market sell order - # Ensure that sell is profitable - self._handle_cancel_order(txid=sell_order["txid"]) + # Activate TSP + self._tsp_state_table.activate_tsp(original_buy_txid, self._ticker) + + # Cancel current sell order and place new one higher + self._handle_cancel_order(txid=sell_txid) + + # Calculate new sell order price (move it up by TSP amount) + new_sell_price = sell_price + (original_buy_price * tsp_percentage) + + # Use future orders to place the new sell order + self._future_orders_table.add(price=new_sell_price) + + # Update the TSP state to clear the old sell order TXID + # The new sell order will get associated later in __associate_sell_orders_with_tsp + self._tsp_state_table.update_sell_order_txid_by_buy_txid( + original_buy_txid, "" + ) + + LOG.info( + "Shifted sell order from %s to %s (TSP activation)", + sell_price, + new_sell_price, + ) continue + + # For active TSP positions, check for trailing stop updates and triggers + if tsp_state["tsp_active"]: + current_stop_price = tsp_state["current_stop_price"] + highest_price = tsp_state["highest_price_reached"] + + # Update trailing stop if price has moved higher + if self._ticker > highest_price: + self._tsp_state_table.update_trailing_stop( + original_buy_txid, self._ticker + ) + LOG.debug( + "Updated trailing stop for position %s to new level", + original_buy_txid, + ) + + # Shift the leading sell order up further + new_sell_price = sell_price + (original_buy_price * tsp_percentage) + self._handle_cancel_order(txid=sell_txid) + self._future_orders_table.add(price=new_sell_price) + + # Update the TSP state to clear the old sell order TXID + self._tsp_state_table.update_sell_order_txid_by_buy_txid( + original_buy_txid, "" + ) + + LOG.debug("Shifted leading sell order up to %s", new_sell_price) + + # Check if trailing stop should trigger + elif self._ticker <= current_stop_price: + LOG.info( + "TSP triggered! Selling position %s at trailing stop level %s", + original_buy_txid, + current_stop_price, + ) + + # Cancel the leading sell order + self._handle_cancel_order(txid=sell_txid) + + # Create sell order at the trailing stop level + # Ensure the sale is profitable (above minimum) + min_profitable_price = original_buy_price * ( + 1 + interval + 2 * self._config.fee + ) + actual_sell_price = max(current_stop_price, min_profitable_price) + + # Place immediate sell order + self._place_tsp_sell_order(original_buy_txid, actual_sell_price) + + # Clean up TSP state + self._tsp_state_table.remove_by_buy_txid(original_buy_txid) + + LOG.info( + "TSP sell executed at %s for position %s", + actual_sell_price, + original_buy_txid, + ) + + def _place_tsp_sell_order( + self: Self, original_buy_txid: str, sell_price: float + ) -> None: + """ + Place a TSP-triggered sell order. + + This uses the existing arbitrage mechanism to place an immediate sell order. + """ + LOG.info( + "Placing TSP sell order at price %s for position %s", + sell_price, + original_buy_txid, + ) + + # Use the standard arbitrage mechanism to place the sell order + # This will call _new_sell_order which is implemented by subclasses + self._handle_arbitrage( + side=self._exchange_domain.SELL, + order_price=sell_price, + txid_to_delete=None, # No specific buy order to delete for TSP + ) + + def _cleanup_tsp_state_for_filled_sell_order(self: Self, sell_txid: str) -> None: + """ + Clean up TSP state when a sell order is filled. + + This is crucial to prevent orphaned TSP states. + """ + LOG.debug("Cleaning up TSP state for filled sell order: %s", sell_txid) + + # Find and remove TSP state associated with this sell order + if tsp_state := self._tsp_state_table.get_by_sell_txid(sell_txid): + LOG.info( + "Removing TSP state for position %s after sell order %s filled", + original_buy_txid := tsp_state["original_buy_txid"], + sell_txid, + ) + self._tsp_state_table.remove_by_buy_txid(original_buy_txid) + else: + LOG.debug("No TSP state found for sell order %s", sell_txid) + + def _initialize_tsp_for_new_position( + self: Self, original_buy_txid: str, buy_price: float, sell_price: float + ) -> None: + """ + Initialize TSP state when a new position is created (buy order filled + + sell order placed). + + This sets up TSP tracking from the beginning of the position lifecycle. + We store the buy order information and will link it to the sell order + later when we process the TSP check loop. + """ + LOG.debug( + "Initializing TSP for position: buy_txid=%s, buy_price=%s, sell_price=%s", + original_buy_txid, + buy_price, + sell_price, + ) + + interval = self._config.interval + initial_stop_price = buy_price * (1 + interval) # Minimum profit level + + # Store the buy information with a placeholder for sell TXID + # The sell TXID will be updated in the next TSP check cycle + self._tsp_state_table.add( + original_buy_txid=original_buy_txid, + original_buy_price=buy_price, + initial_stop_price=initial_stop_price, + sell_order_txid=None, # Will be updated in __associate_sell_orders_with_tsp() + ) + + def __associate_sell_orders_with_tsp(self: Self) -> None: + """ + Associate sell orders with their corresponding TSP states. + + This solves the timing issue where TSP state is created before + the sell order TXID is available. + """ + # Get TSP states that don't have sell orders associated yet + if not ( + unlinked_states := [ + state + for state in self._tsp_state_table.get_all_active().all() + if state["current_sell_order_txid"] is None + ] + ): + return + + sell_orders = self._orderbook_table.get_orders( + filters={"side": self._exchange_domain.SELL}, + ).all() + + for tsp_state in unlinked_states: + original_buy_price = tsp_state["original_buy_price"] + original_buy_txid = tsp_state["original_buy_txid"] + + # Find sell order that matches this position + # We calculate what the sell price should be based on original buy price + expected_sell_price = original_buy_price * ( + 1 + self._config.interval + 2 * self._config.trailing_stop_profit + ) + + # Find closest matching sell order (within tolerance) + tolerance = 0.01 # 1% tolerance for price matching + matching_sell_order = None + + for sell_order in sell_orders: + price_diff = ( + abs(sell_order["price"] - expected_sell_price) / expected_sell_price + ) + if price_diff <= tolerance: + # Check if this sell order is already associated with another TSP state + existing_tsp = self._tsp_state_table.get_by_sell_txid( + sell_order["txid"] + ) + if not existing_tsp: + matching_sell_order = sell_order + break + + if matching_sell_order: + LOG.debug( + "Associating sell order %s with TSP state for buy %s", + matching_sell_order["txid"], + original_buy_txid, + ) + self._tsp_state_table.update_sell_order_txid_by_buy_txid( + original_buy_txid=original_buy_txid, + new_sell_txid=matching_sell_order["txid"], + ) + else: + LOG.warning( + "Could not find matching sell order for TSP state with buy TXID %s (expected price: %s)", + original_buy_txid, + expected_sell_price, + ) From dc46ae08a40e67aeb4bbe8267c78481a0264ccb4 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Sun, 7 Sep 2025 19:44:56 +0200 Subject: [PATCH 6/8] [skip ci] some progress --- src/infinity_grid/core/cli.py | 35 ++-- src/infinity_grid/infrastructure/database.py | 61 +++---- src/infinity_grid/strategies/grid_base.py | 162 +++++++++---------- 3 files changed, 118 insertions(+), 140 deletions(-) diff --git a/src/infinity_grid/core/cli.py b/src/infinity_grid/core/cli.py index 2ab55fa..9f1f7d3 100644 --- a/src/infinity_grid/core/cli.py +++ b/src/infinity_grid/core/cli.py @@ -243,20 +243,6 @@ def cli(ctx: Context, **kwargs: dict) -> None: can be caught immediately. """, ), - option( - "--trailing-stop-profit", - type=FLOAT, - required=False, - callback=ensure_larger_than_zero, - help=""" - The trailing stop profit percentage, e.g. '0.01' for 1%. When enabled, - allows profits to run beyond the defined interval and locks in profits - when price reverses. The mechanism activates when price reaches - (interval + TSP) and dynamically adjusts both stop level and target - sell price as price moves favorably. It is recommended to set a TSP to - half an interval, e.g., '0.01' in case the interval is '0.02'. - """, - ), constraint=If( # Useless if no further strategies are implemented Equal("strategy", "cDCA") | Equal("strategy", "GridHODL") @@ -268,6 +254,13 @@ def cli(ctx: Context, **kwargs: dict) -> None: ) @option_group( "Additional options", + option( + "--dry-run", + required=False, + is_flag=True, + default=False, + help="Enable dry-run mode which do not execute trades.", + ), option( "--skip-price-timeout", is_flag=True, @@ -279,11 +272,17 @@ def cli(ctx: Context, **kwargs: dict) -> None: """, ), option( - "--dry-run", + "--trailing-stop-profit", + type=FLOAT, required=False, - is_flag=True, - default=False, - help="Enable dry-run mode which do not execute trades.", + help=""" + The trailing stop profit percentage, e.g. '0.01' for 1%. When enabled, + allows profits to run beyond the defined interval and locks in profits + when price reverses. The mechanism activates when price reaches + (interval + TSP) and dynamically adjusts both stop level and target + sell price as price moves favorably. It is recommended to set a TSP to + half an interval, e.g., '0.01' in case the interval is '0.02'. + """, ), ) @option_group( diff --git a/src/infinity_grid/infrastructure/database.py b/src/infinity_grid/infrastructure/database.py index b8f292e..7103b91 100644 --- a/src/infinity_grid/infrastructure/database.py +++ b/src/infinity_grid/infrastructure/database.py @@ -408,7 +408,6 @@ def __init__(self: Self, userref: int, db: DBConnect) -> None: Column("id", Integer, primary_key=True), Column("userref", Integer, nullable=False), Column("price", Float, nullable=False), - Column("placed", Boolean, default=False, nullable=False), ) # Create the table if it doesn't exist @@ -432,19 +431,12 @@ def add(self: Self, price: float) -> None: LOG.debug("Adding a order to the 'future_orders' table: price: %s", price) self.__db.add_row(self.__table, userref=self.__userref, price=price) - def set_placed(self: Self, price: float) -> None: - LOG.debug("Setting order with price %s as placed", price) - self.__db.update_row( - self.__table, - filters={"userref": self.__userref, "price": price, "placed": False}, - updates={"placed": True}, - ) - def remove_placed_orders(self: Self) -> None: + def remove_by_price(self: Self, price: float) -> None: """Remove a row from the table.""" LOG.debug( "Removing rows from the 'future_orders' table with filters: %s", - filters := {"userref": self.__userref, "placed": True}, + filters := {"userref": self.__userref, "price": price}, ) self.__db.delete_row(self.__table, filters=filters) @@ -471,12 +463,10 @@ def __init__( Column("original_buy_txid", String, nullable=False), # UNIQUE KEY per position Column("original_buy_price", Float, nullable=False), # Never changes Column("current_stop_price", Float, nullable=False), # Updates as trailing stop moves - Column("highest_price_reached", Float, nullable=False), # Updates as price rises Column("tsp_active", Boolean, default=False), # Whether TSP is currently active Column("current_sell_order_txid", String, nullable=True), # Updates when orders shift ) - # Create the table if it doesn't exist self.__table.create(bind=self.__db.engine, checkfirst=True) def add( @@ -500,7 +490,6 @@ def add( original_buy_txid=original_buy_txid, original_buy_price=original_buy_price, current_stop_price=initial_stop_price, - highest_price_reached=original_buy_price, tsp_active=False, current_sell_order_txid=sell_order_txid, ) @@ -542,51 +531,41 @@ def activate_tsp(self: Self, original_buy_txid: str, current_price: float) -> No filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, updates={ "tsp_active": True, - "highest_price_reached": current_price, "current_stop_price": current_price * (1 - self.__get_tsp_percentage()), }, ) def update_trailing_stop( - self: Self, original_buy_txid: str, current_price: float + self: Self, original_buy_txid: str, current_price: float, ) -> None: """Update trailing stop level if price has moved higher.""" - current_state = self.get_by_buy_txid(original_buy_txid) - if not current_state or not current_state["tsp_active"]: - return - - if current_price > current_state["highest_price_reached"]: - new_stop_price = current_price * (1 - self.__get_tsp_percentage()) - LOG.debug( - "Updating trailing stop for buy_txid=%s: new_stop=%s, highest=%s", - original_buy_txid, - new_stop_price, - current_price, - ) - self.__db.update_row( - self.__table, - filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, - updates={ - "highest_price_reached": current_price, - "current_stop_price": new_stop_price, - }, - ) + LOG.debug( + "Updating trailing stop for buy_txid=%s: new_stop=%s, highest=%s", + original_buy_txid, + new_stop_price:= current_price * (1 - self.__get_tsp_percentage()), + current_price, + ) + self.__db.update_row( + self.__table, + filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, + updates={ + "current_stop_price": new_stop_price + }, + ) def get_by_buy_txid(self: Self, original_buy_txid: str) -> RowMapping | None: """Get TSP state for a specific buy TXID.""" - result = self.__db.get_rows( + return self.__db.get_rows( self.__table, filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, - ) - return result.fetchone() + ).fetchone() def get_by_sell_txid(self: Self, sell_txid: str) -> RowMapping | None: """Get TSP state by current sell order TXID.""" - result = self.__db.get_rows( + return self.__db.get_rows( self.__table, filters={"userref": self.__userref, "current_sell_order_txid": sell_txid}, - ) - return result.fetchone() + ).fetchone() def get_all_active(self: Self) -> MappingResult: """Get all active TSP states.""" diff --git a/src/infinity_grid/strategies/grid_base.py b/src/infinity_grid/strategies/grid_base.py index 03e6d6a..9e1b07c 100644 --- a/src/infinity_grid/strategies/grid_base.py +++ b/src/infinity_grid/strategies/grid_base.py @@ -524,7 +524,6 @@ def __sync_order_book(self: Self) -> None: local_txids: set[str] = { order["txid"] for order in self._orderbook_table.get_orders() } - something_changed = False for order in open_orders: if order.txid not in local_txids: LOG.info( @@ -532,9 +531,6 @@ def __sync_order_book(self: Self) -> None: order.txid, ) self._orderbook_table.add(order) - something_changed = True - if not something_changed: - LOG.info(" - Nothing changed!") # ====================================================================== # Check all orders of the local orderbook against those from upstream. @@ -619,8 +615,6 @@ def __check_price_range(self: Self) -> None: If the price (``self.ticker``) raises to high, the open buy orders will be canceled and new buy orders below the price respecting the interval will be placed. - - FIXME: Does it makes sens to use events for all these checks? """ if self._config.dry_run: LOG.debug("Dry run, not checking price range.") @@ -635,7 +629,7 @@ def __check_price_range(self: Self) -> None: # Remove orders that are next to each other self.__check_near_buy_orders() - # Ensure n open buy orders + # Ensure $n$ open buy orders self.__check_n_open_buy_orders() # Return if some newly placed order is still pending and not in the @@ -643,7 +637,7 @@ def __check_price_range(self: Self) -> None: if self._pending_txids_table.count() != 0: return - # Check if there are more than n buy orders and cancel the lowest + # Check if there are more than $n$ buy orders and cancel the lowest self.__check_lowest_cancel_of_more_than_n_buy_orders() # Check the price range and shift the orders up if required @@ -653,13 +647,10 @@ def __check_price_range(self: Self) -> None: # Place extra sell order (only for SWING strategy) self._check_extra_sell_order() - # Check TSP conditions if enabled if self._config.trailing_stop_profit: - # Process future orders (TSP shifted orders) + # Handle TSP self.__process_future_orders() - # First associate any unlinked sell orders with TSP states self.__associate_sell_orders_with_tsp() - # Then run the main TSP logic self.__check_tsp() def __process_future_orders(self: Self) -> None: @@ -672,22 +663,10 @@ def __process_future_orders(self: Self) -> None: LOG.debug("Dry run, not processing future orders.") return - future_orders = self._future_orders_table.get(filters={"placed": False}) - - for future_order in future_orders: - price = future_order["price"] - - LOG.info("Processing future order at price %s", price) - - # Create the sell order using the standard mechanism - # The volume calculation will be handled by _new_sell_order - self._new_sell_order(order_price=price, txid_to_delete=None) - - # Mark this future order as placed - self._future_orders_table.set_placed(price=price) - - # Clean up placed orders (must be done immediately) - self._future_orders_table.remove_placed_orders() + for future_order in self._future_orders_table.get(): + LOG.info("Processing future order at price %s", future_order["price"]) + self._new_sell_order(order_price=future_order["price"]) + self._future_orders_table.remove_by_price(price=future_order["price"]) # ========================================================================== def __add_missed_sell_orders(self: Self) -> None: @@ -1421,9 +1400,9 @@ def _new_sell_order( def __check_tsp(self: Self) -> None: """Check and manage Trailing Stop Profit for all tracked positions.""" if ( - not self._ticker + not self._config.trailing_stop_profit or self._config.dry_run - or not self._config.trailing_stop_profit + or not self._ticker ): return @@ -1436,8 +1415,7 @@ def __check_tsp(self: Self) -> None: for sell_order in self._orderbook_table.get_orders( filters={"side": self._exchange_domain.SELL}, ).all(): - sell_price = sell_order["price"] - sell_txid = sell_order["txid"] + sell_price, sell_txid = sell_order["price"], sell_order["txid"] # Try to find existing TSP state for this sell order if not (tsp_state := self._tsp_state_table.get_by_sell_txid(sell_txid)): @@ -1451,16 +1429,15 @@ def __check_tsp(self: Self) -> None: ) continue - original_buy_price = tsp_state["original_buy_price"] - original_buy_txid = tsp_state["original_buy_txid"] - - # Skip if current price is below original buy price (not profitable) - if self._ticker < original_buy_price: + # Skip if original buy price is higher than the current price + if (original_buy_price := tsp_state["original_buy_price"]) > self._ticker: continue - # Check if TSP should be activated + original_buy_txid = tsp_state["original_buy_txid"] + current_stop_price = tsp_state["current_stop_price"] tsp_activation_price = original_buy_price * (1 + interval + tsp_percentage) + # Check if TSP should be activated if not tsp_state["tsp_active"] and self._ticker >= tsp_activation_price: LOG.info( "Activating TSP for position %s (buy_price=%s) at current price %s", @@ -1472,62 +1449,88 @@ def __check_tsp(self: Self) -> None: # Activate TSP self._tsp_state_table.activate_tsp(original_buy_txid, self._ticker) - # Cancel current sell order and place new one higher - self._handle_cancel_order(txid=sell_txid) - # Calculate new sell order price (move it up by TSP amount) - new_sell_price = sell_price + (original_buy_price * tsp_percentage) + LOG.info( + "Try shifting sell order from %s to %s (TSP activation)", + sell_price, + new_sell_price := sell_price + + (original_buy_price * tsp_percentage), + ) + + # Cancel current sell order + self._handle_cancel_order(txid=sell_txid) # Use future orders to place the new sell order self._future_orders_table.add(price=new_sell_price) - # Update the TSP state to clear the old sell order TXID - # The new sell order will get associated later in __associate_sell_orders_with_tsp + # Update the TSP state to clear the old sell order TXID The new + # sell order will get associated later in + # __associate_sell_orders_with_tsp self._tsp_state_table.update_sell_order_txid_by_buy_txid( - original_buy_txid, "" + original_buy_txid=original_buy_txid, + new_sell_txid=None, ) - LOG.info( - "Shifted sell order from %s to %s (TSP activation)", - sell_price, - new_sell_price, + self._event_bus.publish( + "notification", + data={ + "message": "↗️ Shifting up sell order from" + f" {sell_price} {self._config.quote_currency}" + f" to {new_sell_price} {self._config.quote_currency}" + f" due to activated TSP at {current_stop_price} {self._config.quote_currency}" + }, ) + continue # For active TSP positions, check for trailing stop updates and triggers if tsp_state["tsp_active"]: - current_stop_price = tsp_state["current_stop_price"] - highest_price = tsp_state["highest_price_reached"] - - # Update trailing stop if price has moved higher - if self._ticker > highest_price: + # Update trailing stop if price has moved higher than threshold + if self._ticker >= sell_price - (original_buy_price * tsp_percentage): self._tsp_state_table.update_trailing_stop( - original_buy_txid, self._ticker + original_buy_txid=original_buy_txid, + current_price=self._ticker, ) LOG.debug( - "Updated trailing stop for position %s to new level", + "Updated trailing stop for position '%s' to new level", original_buy_txid, ) - # Shift the leading sell order up further + # Shift the leading sell order further up new_sell_price = sell_price + (original_buy_price * tsp_percentage) self._handle_cancel_order(txid=sell_txid) self._future_orders_table.add(price=new_sell_price) # Update the TSP state to clear the old sell order TXID self._tsp_state_table.update_sell_order_txid_by_buy_txid( - original_buy_txid, "" + original_buy_txid=original_buy_txid, + new_sell_txid=None, ) - LOG.debug("Shifted leading sell order up to %s", new_sell_price) + self._event_bus.publish( + "notification", + data={ + "message": "↗️ Shifting up sell order from" + f" {sell_price} {self._config.quote_currency}" + f" to {new_sell_price} {self._config.quote_currency}" + f" new trailing stop at {self._ticker * (1-tsp_percentage)}" + f" {self._config.quote_currency}" + }, + ) # Check if trailing stop should trigger elif self._ticker <= current_stop_price: LOG.info( - "TSP triggered! Selling position %s at trailing stop level %s", + "TSP triggered! Selling position '%s' at trailing stop level %s", original_buy_txid, current_stop_price, ) + self._event_bus.publish( + "notification", + data={ + "message": f"⚠️ Trailing stop profit triggered at {current_stop_price}" + }, + ) # Cancel the leading sell order self._handle_cancel_order(txid=sell_txid) @@ -1537,7 +1540,7 @@ def __check_tsp(self: Self) -> None: min_profitable_price = original_buy_price * ( 1 + interval + 2 * self._config.fee ) - actual_sell_price = max(current_stop_price, min_profitable_price) + actual_sell_price = max(self._ticker, min_profitable_price) # Place immediate sell order self._place_tsp_sell_order(original_buy_txid, actual_sell_price) @@ -1552,7 +1555,7 @@ def __check_tsp(self: Self) -> None: ) def _place_tsp_sell_order( - self: Self, original_buy_txid: str, sell_price: float + self: Self, original_buy_txid: str, sell_price: float, ) -> None: """ Place a TSP-triggered sell order. @@ -1567,11 +1570,7 @@ def _place_tsp_sell_order( # Use the standard arbitrage mechanism to place the sell order # This will call _new_sell_order which is implemented by subclasses - self._handle_arbitrage( - side=self._exchange_domain.SELL, - order_price=sell_price, - txid_to_delete=None, # No specific buy order to delete for TSP - ) + self._handle_arbitrage(side=self._exchange_domain.SELL, order_price=sell_price) def _cleanup_tsp_state_for_filled_sell_order(self: Self, sell_txid: str) -> None: """ @@ -1624,16 +1623,19 @@ def _initialize_tsp_for_new_position( def __associate_sell_orders_with_tsp(self: Self) -> None: """ - Associate sell orders with their corresponding TSP states. + Associate new sell orders with their corresponding TSP states. - This solves the timing issue where TSP state is created before - the sell order TXID is available. + These sell orders are either placed because of an executed buy order and + or a TSP entry of which the sell order is cleared due to shifting up. + + This solves the timing issue where TSP state is created before the sell + order TXID is available. """ # Get TSP states that don't have sell orders associated yet if not ( unlinked_states := [ state - for state in self._tsp_state_table.get_all_active().all() + for state in self._tsp_state_table.get_all_active() if state["current_sell_order_txid"] is None ] ): @@ -1644,12 +1646,9 @@ def __associate_sell_orders_with_tsp(self: Self) -> None: ).all() for tsp_state in unlinked_states: - original_buy_price = tsp_state["original_buy_price"] - original_buy_txid = tsp_state["original_buy_txid"] - - # Find sell order that matches this position - # We calculate what the sell price should be based on original buy price - expected_sell_price = original_buy_price * ( + # Find sell order that matches this position. We calculate what the + # sell price should be based on original buy price. + expected_sell_price = tsp_state["original_buy_price"] * ( 1 + self._config.interval + 2 * self._config.trailing_stop_profit ) @@ -1662,7 +1661,8 @@ def __associate_sell_orders_with_tsp(self: Self) -> None: abs(sell_order["price"] - expected_sell_price) / expected_sell_price ) if price_diff <= tolerance: - # Check if this sell order is already associated with another TSP state + # Check if this sell order is already associated with + # another TSP state. existing_tsp = self._tsp_state_table.get_by_sell_txid( sell_order["txid"] ) @@ -1674,15 +1674,15 @@ def __associate_sell_orders_with_tsp(self: Self) -> None: LOG.debug( "Associating sell order %s with TSP state for buy %s", matching_sell_order["txid"], - original_buy_txid, + tsp_state["original_buy_txid"], ) self._tsp_state_table.update_sell_order_txid_by_buy_txid( - original_buy_txid=original_buy_txid, + original_buy_txid=tsp_state["original_buy_txid"], new_sell_txid=matching_sell_order["txid"], ) else: LOG.warning( "Could not find matching sell order for TSP state with buy TXID %s (expected price: %s)", - original_buy_txid, + tsp_state["original_buy_txid"], expected_sell_price, ) From 02a62fc097bfbbfeffae631b1d31869424cfe618 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Sun, 7 Sep 2025 19:58:44 +0200 Subject: [PATCH 7/8] [skip ci] some progress --- src/infinity_grid/strategies/grid_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/infinity_grid/strategies/grid_base.py b/src/infinity_grid/strategies/grid_base.py index 9e1b07c..0b13802 100644 --- a/src/infinity_grid/strategies/grid_base.py +++ b/src/infinity_grid/strategies/grid_base.py @@ -786,7 +786,8 @@ def __cancel_all_open_buy_orders(self: Self) -> None: self._handle_cancel_order(txid=order.txid) sleep(0.2) # Avoid rate limiting - self._orderbook_table.remove(filters={"side": self._exchange_domain.BUY}) + # FIXME: Check if not needed, handle_cancel_order should take care of it + # self._orderbook_table.remove(filters={"side": self._exchange_domain.BUY}) def __shift_buy_orders_up(self: Self) -> bool: """ From 18ffc8c45ba18b1e82df3df81c548293eacb9243 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Schwertfeger Date: Thu, 11 Sep 2025 18:01:47 +0200 Subject: [PATCH 8/8] [skip ci] stash --- src/infinity_grid/infrastructure/database.py | 50 +++++++++++++++----- src/infinity_grid/models/configuration.py | 7 +-- src/infinity_grid/strategies/grid_base.py | 23 +++++---- tests/unit/strategies/test_cdca.py | 6 ++- 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/infinity_grid/infrastructure/database.py b/src/infinity_grid/infrastructure/database.py index 7103b91..e8a0d6a 100644 --- a/src/infinity_grid/infrastructure/database.py +++ b/src/infinity_grid/infrastructure/database.py @@ -431,7 +431,6 @@ def add(self: Self, price: float) -> None: LOG.debug("Adding a order to the 'future_orders' table: price: %s", price) self.__db.add_row(self.__table, userref=self.__userref, price=price) - def remove_by_price(self: Self, price: float) -> None: """Remove a row from the table.""" LOG.debug( @@ -449,7 +448,10 @@ class TSPState: """ def __init__( - self: Self, userref: int, db: DBConnect, tsp_percentage: float = 0.01 + self: Self, + userref: int, + db: DBConnect, + tsp_percentage: float = 0.01, ) -> None: LOG.debug("Initializing the 'tsp_state' table...") self.__db = db @@ -460,11 +462,27 @@ def __init__( self.__db.metadata, Column("id", Integer, primary_key=True), Column("userref", Integer, nullable=False), - Column("original_buy_txid", String, nullable=False), # UNIQUE KEY per position + Column( + "original_buy_txid", + String, + nullable=False, + ), # UNIQUE KEY per position Column("original_buy_price", Float, nullable=False), # Never changes - Column("current_stop_price", Float, nullable=False), # Updates as trailing stop moves - Column("tsp_active", Boolean, default=False), # Whether TSP is currently active - Column("current_sell_order_txid", String, nullable=True), # Updates when orders shift + Column( + "current_stop_price", + Float, + nullable=False, + ), # Updates as trailing stop moves + Column( + "tsp_active", + Boolean, + default=False, + ), # Whether TSP is currently active + Column( + "current_sell_order_txid", + String, + nullable=True, + ), # Updates when orders shift ) self.__table.create(bind=self.__db.engine, checkfirst=True) @@ -510,9 +528,17 @@ def update_sell_order_txid(self: Self, old_txid: str | None, new_txid: str) -> N updates={"current_sell_order_txid": new_txid}, ) - def update_sell_order_txid_by_buy_txid(self: Self, original_buy_txid: str, new_sell_txid: str) -> None: + def update_sell_order_txid_by_buy_txid( + self: Self, + original_buy_txid: str, + new_sell_txid: str, + ) -> None: """Update sell order TXID for a specific buy TXID.""" - LOG.debug("Updating sell order TXID for buy %s to %s", original_buy_txid, new_sell_txid) + LOG.debug( + "Updating sell order TXID for buy %s to %s", + original_buy_txid, + new_sell_txid, + ) self.__db.update_row( self.__table, filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, @@ -536,20 +562,22 @@ def activate_tsp(self: Self, original_buy_txid: str, current_price: float) -> No ) def update_trailing_stop( - self: Self, original_buy_txid: str, current_price: float, + self: Self, + original_buy_txid: str, + current_price: float, ) -> None: """Update trailing stop level if price has moved higher.""" LOG.debug( "Updating trailing stop for buy_txid=%s: new_stop=%s, highest=%s", original_buy_txid, - new_stop_price:= current_price * (1 - self.__get_tsp_percentage()), + new_stop_price := current_price * (1 - self.__get_tsp_percentage()), current_price, ) self.__db.update_row( self.__table, filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, updates={ - "current_stop_price": new_stop_price + "current_stop_price": new_stop_price, }, ) diff --git a/src/infinity_grid/models/configuration.py b/src/infinity_grid/models/configuration.py index 1cb4f08..2a08866 100644 --- a/src/infinity_grid/models/configuration.py +++ b/src/infinity_grid/models/configuration.py @@ -5,8 +5,7 @@ # https://github.com/btschwertfeger # -from pydantic import BaseModel, computed_field, field_validator -from pydantic import RootModel +from pydantic import BaseModel, RootModel, computed_field, field_validator class BotConfigDTO(BaseModel): @@ -110,7 +109,9 @@ def validate_trailing_stop_profit(cls, value: float | None) -> float | None: """Validate trailing_stop_profit is between 0 and 1 if provided.""" if value is not None: if value <= 0 or value >= 1: - raise ValueError("trailing_stop_profit must be between 0 and 1 (exclusive)") + raise ValueError( + "trailing_stop_profit must be between 0 and 1 (exclusive)", + ) # The trailing stop profit should be smaller than the interval # to ensure it triggers before the next grid level root = RootModel.model_validate(cls.__pydantic_parent_namespace__) diff --git a/src/infinity_grid/strategies/grid_base.py b/src/infinity_grid/strategies/grid_base.py index 0b13802..9ff43f4 100644 --- a/src/infinity_grid/strategies/grid_base.py +++ b/src/infinity_grid/strategies/grid_base.py @@ -90,7 +90,9 @@ def __init__( self._future_orders_table: FutureOrders = FutureOrders(self._config.userref, db) # FIXME: not needed if tsp not activated self._tsp_state_table: TSPState = TSPState( - self._config.userref, db, tsp_percentage=self._config.trailing_stop_profit + self._config.userref, + db, + tsp_percentage=self._config.trailing_stop_profit, ) db.init_db() @@ -1478,7 +1480,7 @@ def __check_tsp(self: Self) -> None: "message": "↗️ Shifting up sell order from" f" {sell_price} {self._config.quote_currency}" f" to {new_sell_price} {self._config.quote_currency}" - f" due to activated TSP at {current_stop_price} {self._config.quote_currency}" + f" due to activated TSP at {current_stop_price} {self._config.quote_currency}", }, ) @@ -1514,8 +1516,8 @@ def __check_tsp(self: Self) -> None: "message": "↗️ Shifting up sell order from" f" {sell_price} {self._config.quote_currency}" f" to {new_sell_price} {self._config.quote_currency}" - f" new trailing stop at {self._ticker * (1-tsp_percentage)}" - f" {self._config.quote_currency}" + f" new trailing stop at {self._ticker * (1 - tsp_percentage)}" + f" {self._config.quote_currency}", }, ) @@ -1529,7 +1531,7 @@ def __check_tsp(self: Self) -> None: self._event_bus.publish( "notification", data={ - "message": f"⚠️ Trailing stop profit triggered at {current_stop_price}" + "message": f"⚠️ Trailing stop profit triggered at {current_stop_price}", }, ) @@ -1556,7 +1558,9 @@ def __check_tsp(self: Self) -> None: ) def _place_tsp_sell_order( - self: Self, original_buy_txid: str, sell_price: float, + self: Self, + original_buy_txid: str, + sell_price: float, ) -> None: """ Place a TSP-triggered sell order. @@ -1593,7 +1597,10 @@ def _cleanup_tsp_state_for_filled_sell_order(self: Self, sell_txid: str) -> None LOG.debug("No TSP state found for sell order %s", sell_txid) def _initialize_tsp_for_new_position( - self: Self, original_buy_txid: str, buy_price: float, sell_price: float + self: Self, + original_buy_txid: str, + buy_price: float, + sell_price: float, ) -> None: """ Initialize TSP state when a new position is created (buy order filled + @@ -1665,7 +1672,7 @@ def __associate_sell_orders_with_tsp(self: Self) -> None: # Check if this sell order is already associated with # another TSP state. existing_tsp = self._tsp_state_table.get_by_sell_txid( - sell_order["txid"] + sell_order["txid"], ) if not existing_tsp: matching_sell_order = sell_order diff --git a/tests/unit/strategies/test_cdca.py b/tests/unit/strategies/test_cdca.py index 9822cbf..33a40aa 100644 --- a/tests/unit/strategies/test_cdca.py +++ b/tests/unit/strategies/test_cdca.py @@ -58,10 +58,12 @@ def test_get_sell_order_price_returns_none( mock_strategy: mock.MagicMock, ) -> None: """Test that the cDCA strategy does not provide a sell order price""" - with pytest.raises(RuntimeError, match="cDCA strategy does not place sell orders."): + with pytest.raises( + RuntimeError, + match="cDCA strategy does not place sell orders.", + ): mock_strategy._get_sell_order_price(50000) - def test_get_sell_order_price_updates_highest_buy_price( self: Self, mock_strategy: mock.MagicMock,