diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..14da3b5 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,39 @@ +# 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. +- `./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. + +## 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. + +## Guidelines + +- Best Software Engineering practices like KISS, modularization, and efficiency + must be followed. 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/core/cli.py b/src/infinity_grid/core/cli.py index ab52c09..9f1f7d3 100644 --- a/src/infinity_grid/core/cli.py +++ b/src/infinity_grid/core/cli.py @@ -254,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, @@ -265,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 f7cc5d1..e8a0d6a 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 Boolean, Column, Float, Integer, String, Table, func, select from sqlalchemy.engine.result import MappingResult from sqlalchemy.engine.row import RowMapping @@ -101,7 +101,6 @@ 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, @@ -162,6 +161,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, ) @@ -349,7 +349,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, ) @@ -358,20 +358,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) @@ -383,7 +379,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, ) @@ -395,3 +391,233 @@ 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 'future_orders' table...") + self.__db = db + self.__userref = userref + self.__table = Table( + "future_orders", + self.__db.metadata, + Column("id", Integer, primary_key=True), + Column("userref", Integer, nullable=False), + Column("price", Float, 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: + 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, 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, price=price) + + 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, "price": price}, + ) + 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( + "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) + + 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, + 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, + "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.""" + 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.""" + return self.__db.get_rows( + self.__table, + filters={"userref": self.__userref, "original_buy_txid": original_buy_txid}, + ).fetchone() + + def get_by_sell_txid(self: Self, sell_txid: str) -> RowMapping | None: + """Get TSP state by current sell order TXID.""" + return self.__db.get_rows( + self.__table, + filters={"userref": self.__userref, "current_sell_order_txid": sell_txid}, + ).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/models/configuration.py b/src/infinity_grid/models/configuration.py index d763c8f..2a08866 100644 --- a/src/infinity_grid/models/configuration.py +++ b/src/infinity_grid/models/configuration.py @@ -5,7 +5,7 @@ # https://github.com/btschwertfeger # -from pydantic import BaseModel, computed_field, field_validator +from pydantic import BaseModel, RootModel, computed_field, field_validator class BotConfigDTO(BaseModel): @@ -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,23 @@ 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: + 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 + 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..9ff43f4 100644 --- a/src/infinity_grid/strategies/grid_base.py +++ b/src/infinity_grid/strategies/grid_base.py @@ -26,8 +26,10 @@ from infinity_grid.exceptions import BotStateError, UnknownOrderError from infinity_grid.infrastructure.database import ( Configuration, + FutureOrders, Orderbook, PendingTXIDs, + TSPState, UnsoldBuyOrderTXIDs, ) from infinity_grid.interfaces.exchange import ( @@ -85,6 +87,13 @@ def __init__( self._config.userref, 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 @@ -460,6 +469,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, ) @@ -516,7 +526,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( @@ -524,9 +533,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. @@ -588,6 +594,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() @@ -603,8 +617,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.") @@ -619,7 +631,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 @@ -627,7 +639,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 @@ -637,6 +649,27 @@ def __check_price_range(self: Self) -> None: # Place extra sell order (only for SWING strategy) self._check_extra_sell_order() + if self._config.trailing_stop_profit: + # Handle TSP + self.__process_future_orders() + self.__associate_sell_orders_with_tsp() + 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 + + 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: """ @@ -755,7 +788,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: """ @@ -953,8 +987,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 @@ -986,41 +1020,57 @@ 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: """ 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. @@ -1033,7 +1083,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 @@ -1066,9 +1115,18 @@ 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 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, @@ -1145,9 +1203,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( @@ -1289,10 +1345,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 + 2x TSP). """ LOG.debug("Computing the order price...") @@ -1303,10 +1362,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 +1396,301 @@ def _new_sell_order( This method should be implemented by the concrete strategy classes. """ raise NotImplementedError("This method should be implemented by subclasses.") + + # ========================================================================== + # Trailing Stop Profit + + def __check_tsp(self: Self) -> None: + """Check and manage Trailing Stop Profit for all tracked positions.""" + if ( + not self._config.trailing_stop_profit + or self._config.dry_run + or not self._ticker + ): + return + + LOG.debug("Checking TSP conditions at price: %s", self._ticker) + + 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_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)): + # 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 + + # Skip if original buy price is higher than the current price + if (original_buy_price := tsp_state["original_buy_price"]) > self._ticker: + continue + + 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", + original_buy_txid, + original_buy_price, + self._ticker, + ) + + # Activate TSP + self._tsp_state_table.activate_tsp(original_buy_txid, self._ticker) + + # Calculate new sell order price (move it up by TSP amount) + 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 + self._tsp_state_table.update_sell_order_txid_by_buy_txid( + original_buy_txid=original_buy_txid, + new_sell_txid=None, + ) + + 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"]: + # 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=original_buy_txid, + current_price=self._ticker, + ) + LOG.debug( + "Updated trailing stop for position '%s' to new level", + original_buy_txid, + ) + + # 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, + 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", + 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) + + # 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(self._ticker, 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) + + 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 new sell orders with their corresponding TSP states. + + 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() + 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: + # 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 + ) + + # 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"], + tsp_state["original_buy_txid"], + ) + self._tsp_state_table.update_sell_order_txid_by_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)", + tsp_state["original_buy_txid"], + expected_sell_price, + ) diff --git a/src/infinity_grid/strategies/grid_hodl.py b/src/infinity_grid/strategies/grid_hodl.py index e0fa7b8..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 @@ -81,11 +80,9 @@ def _new_sell_order( 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 diff --git a/src/infinity_grid/strategies/grid_sell.py b/src/infinity_grid/strategies/grid_sell.py index ab7f5e6..aae9ae3 100644 --- a/src/infinity_grid/strategies/grid_sell.py +++ b/src/infinity_grid/strategies/grid_sell.py @@ -20,29 +20,6 @@ class GridSellStrategy(GridStrategyBase): - def _get_sell_order_price( - self: Self, - last_price: float, - extra_sell: bool = False, # noqa: ARG002 - ) -> 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 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..33a40aa 100644 --- a/tests/unit/strategies/test_cdca.py +++ b/tests/unit/strategies/test_cdca.py @@ -57,12 +57,12 @@ 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) - - assert result is 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.", + ): + mock_strategy._get_sell_order_price(50000) def test_get_sell_order_price_updates_highest_buy_price( self: Self,