Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions doc/05_need2knows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
21 changes: 17 additions & 4 deletions src/infinity_grid/core/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
248 changes: 237 additions & 11 deletions src/infinity_grid/infrastructure/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand All @@ -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)
Expand All @@ -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,
)

Expand All @@ -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
Loading