Skip to content
Merged
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
11 changes: 6 additions & 5 deletions .github/workflows/staging-deploy.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
name: Deploy to Staging (home server)
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_run:
workflows: ["Deployment checks"]
types: [completed]
workflow_dispatch:

env:
ACTIONS_RUNNER_DEBUG: false

jobs:
full-stack-deploy:
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 20

Expand All @@ -21,8 +25,6 @@ jobs:
tags: tag:ci

- name: Install SSH key
id: install-ssh-key
continue-on-error: true
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
Expand All @@ -33,7 +35,6 @@ jobs:
ssh-keyscan -p 2222 100.64.83.67 >> ~/.ssh/known_hosts

- name: Deploy stack
if: steps.install-ssh-key.outcome == 'success'
run: |
ssh -i ~/.ssh/home_server_key \
-p 2222 \
Expand Down
64 changes: 64 additions & 0 deletions api/alembic/versions/a3b4c5d6e7f8_add_bot_recovery_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""add bot recovery params

Revision ID: a3b4c5d6e7f8
Revises: f2a3b4c5d6e7
Create Date: 2026-06-06 00:00:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "a3b4c5d6e7f8"
down_revision: Union[str, Sequence[str], None] = "f2a3b4c5d6e7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"recovery_bot_table",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("reversal_path", sa.String(), nullable=False),
sa.Column("source_contracts", sa.Float(), nullable=False),
sa.Column("source_loss_fiat", sa.Float(), nullable=False),
sa.Column("stop_loss_pct", sa.Float(), nullable=False),
sa.Column("created_at", sa.Float(), nullable=False),
sa.Column("updated_at", sa.Float(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_recovery_bot_table_id",
"recovery_bot_table",
["id"],
unique=True,
)
op.add_column("bot", sa.Column("recovery_mode_id", sa.Uuid(), nullable=True))
op.create_foreign_key(
"fk_bot_recovery_mode_id_recovery_bot_table",
"bot",
"recovery_bot_table",
["recovery_mode_id"],
["id"],
)
op.create_index(
"ix_bot_recovery_mode_id",
"bot",
["recovery_mode_id"],
unique=True,
)


def downgrade() -> None:
op.drop_index("ix_bot_recovery_mode_id", table_name="bot")
op.drop_constraint(
"fk_bot_recovery_mode_id_recovery_bot_table",
"bot",
type_="foreignkey",
)
op.drop_column("bot", "recovery_mode_id")
op.drop_index("ix_recovery_bot_table_id", table_name="recovery_bot_table")
op.drop_table("recovery_bot_table")
36 changes: 31 additions & 5 deletions api/bots/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
from typing import List, Optional
from uuid import uuid4, UUID
from pybinbot import DealBase as DealModel, BotBase, OrderBase
from pydantic import BaseModel, Field, field_validator, model_validator
from tools.handle_error import IResponseBase
from uuid import UUID, uuid4

from databases.tables.bot_table import BotTable, PaperTradingTable
from databases.tables.deal_table import DealTable
from databases.tables.order_table import ExchangeOrderTable
from pybinbot import BotBase, RecoveryParams
from pybinbot import DealBase as DealModel
from pybinbot import OrderBase
from pydantic import BaseModel, Field, field_validator, model_validator
from tools.handle_error import IResponseBase


class RecoveryBotModel(RecoveryParams):
id: UUID
created_at: float
updated_at: float

model_config = {"from_attributes": True}


class OrderModel(OrderBase):
Expand Down Expand Up @@ -45,6 +56,8 @@ class BotModel(BotBase):
id: UUID = Field(default_factory=uuid4)
deal: DealModel = Field(default_factory=DealModel)
orders: List[OrderModel] = Field(default_factory=list)
recovery_mode_id: UUID | None = None
recovery_params: RecoveryBotModel | None = None

@model_validator(mode="before")
@classmethod
Expand Down Expand Up @@ -111,7 +124,10 @@ def deserialize_id(cls, v):
return v

@classmethod
def dump_from_table(cls, bot: BotTable | PaperTradingTable) -> "BotModel":
def dump_from_table(
cls,
bot: "BotTable | PaperTradingTable",
) -> "BotModel":
"""
Same as model_dump() but from
BotTable
Expand All @@ -125,6 +141,16 @@ def dump_from_table(cls, bot: BotTable | PaperTradingTable) -> "BotModel":
]
model.deal = deal_model
model.orders = order_models
if isinstance(bot, BotTable):
model.recovery_mode_id = bot.recovery_mode_id
model.recovery_params = (
RecoveryBotModel.model_validate(bot.recovery_params)
if bot.recovery_params is not None
else None
)
else:
model.recovery_mode_id = None
model.recovery_params = None
return model

@classmethod
Expand Down
11 changes: 7 additions & 4 deletions api/bots/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlmodel import Session
from pybinbot import Status, BinbotErrors, BinanceErrors, MarketType
from pybinbot import BotBase, Status, BinbotErrors, BinanceErrors, MarketType
from user.models.user import UserTokenData
from bots.models import (
BotBase,
BotResponse,
BotListResponse,
BulkDeleteRequest,
Expand Down Expand Up @@ -204,8 +203,12 @@ def edit_bot(
):
crud = BotTableCrud(session)
bot_row = crud.get_one(bot_id=bot_id)
bot_row.sqlmodel_update(bot_item.model_dump())
updated_row = crud.save(bot_row)
bot_row.sqlmodel_update(bot_item.model_dump(exclude={"recovery_params"}))
updated_row = crud.save(
bot_row,
recovery_params_submitted="recovery_params" in bot_item.model_fields_set,
recovery_params=bot_item.recovery_params,
)
bot_model = BotModel.dump_from_table(updated_row)
return BotResponse(message="Successfully edited bot.", data=bot_model)

Expand Down
66 changes: 59 additions & 7 deletions api/databases/crud/bot_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
from sqlalchemy.orm import QueryableAttribute, selectinload
from sqlalchemy.orm.attributes import flag_modified

from bots.models import BotModel, OrderModel, AlgoRankingItem
from bots.models import (
AlgoRankingItem,
BotModel,
OrderModel,
)
from databases.tables.bot_table import BotTable
from databases.tables.deal_table import DealTable
from databases.tables.order_table import ExchangeOrderTable
from databases.tables.recovery_bot_table import RecoveryBotTable
from databases.utils import detach_bot_graph, get_db_session

from pybinbot import (
Expand All @@ -20,12 +25,14 @@
BinbotErrors,
BotBase,
Position,
RecoveryParams,
)


# Deal with SQLModel vs mypy issues
BOT_DEAL_REL = cast(QueryableAttribute[Any], BotTable.deal)
BOT_ORDERS_REL = cast(QueryableAttribute[Any], BotTable.orders)
BOT_RECOVERY_REL = cast(QueryableAttribute[Any], BotTable.recovery_params)
ACTIVE_BOT_STATUSES = (Status.active, Status.pending)


Expand Down Expand Up @@ -85,7 +92,12 @@ def update_table(self, bot: BotModel | BotTable) -> BotTable:
# Step 1: Copy BotModel fields (except relationships)
bot_table = BotTable()
for field_name in BotTable.model_fields.keys():
if field_name in {"deal", "orders"}:
if field_name in {
"deal",
"orders",
"recovery_mode_id",
"recovery_params",
}:
continue
if hasattr(bot, field_name):
setattr(bot_table, field_name, getattr(bot, field_name))
Expand Down Expand Up @@ -148,6 +160,7 @@ def get(
stmt = select(BotTable).options(
selectinload(BOT_DEAL_REL),
selectinload(BOT_ORDERS_REL),
selectinload(BOT_RECOVERY_REL),
)

if status and status != Status.all:
Expand Down Expand Up @@ -191,6 +204,7 @@ def get_one(
stmt = select(BotTable).options(
selectinload(BOT_DEAL_REL),
selectinload(BOT_ORDERS_REL),
selectinload(BOT_RECOVERY_REL),
)

if bot_id:
Expand Down Expand Up @@ -221,20 +235,30 @@ def get_one(

def create(self, data: BotBase) -> BotTable:
new_bot = BotTable(
**data.model_dump(),
**data.model_dump(exclude={"recovery_params"}),
deal=DealTable(),
orders=[],
)
if data.recovery_params is not None:
new_bot.recovery_params = RecoveryBotTable(
**data.recovery_params.model_dump()
)

with get_db_session(self._external_session) as s:
s.add(new_bot)
s.commit()
s.refresh(new_bot)
s.expunge(new_bot)
detach_bot_graph(s, new_bot)

return new_bot

def save(self, data: BotModel | BotTable) -> BotTable:
def save(
self,
data: BotModel | BotTable,
*,
recovery_params_submitted: bool = False,
recovery_params: RecoveryParams | None = None,
) -> BotTable:
with get_db_session(self._external_session) as s:
# Fetch the existing bot from DB (already attached to session)
bot_row = s.get(BotTable, UUID(str(data.id)))
Expand All @@ -249,11 +273,38 @@ def save(self, data: BotModel | BotTable) -> BotTable:

# Update scalar fields on the managed bot_row
for field_name in BotTable.model_fields.keys():
if field_name in {"id", "deal", "orders", "deal_id"}:
if field_name in {
"id",
"deal",
"orders",
"deal_id",
"recovery_mode_id",
"recovery_params",
}:
continue
if hasattr(data_table, field_name):
setattr(bot_row, field_name, getattr(data_table, field_name))

if recovery_params_submitted:
if recovery_params is None:
recovery_row = bot_row.recovery_params
bot_row.recovery_params = None
bot_row.recovery_mode_id = None
if recovery_row is not None:
s.delete(recovery_row)
elif bot_row.recovery_params is None:
bot_row.recovery_params = RecoveryBotTable(
**recovery_params.model_dump()
)
else:
for field_name in RecoveryParams.model_fields:
setattr(
bot_row.recovery_params,
field_name,
getattr(recovery_params, field_name),
)
bot_row.recovery_params.updated_at = timestamp()

# Update deal fields (preserve existing deal.id)
for field_name in DealTable.model_fields.keys():
if field_name in {"id", "bot", "paper_trading"}:
Expand All @@ -280,7 +331,7 @@ def save(self, data: BotModel | BotTable) -> BotTable:
s.add(bot_row)
s.commit()
s.refresh(bot_row)
s.expunge(bot_row)
detach_bot_graph(s, bot_row)

return bot_row

Expand Down Expand Up @@ -402,6 +453,7 @@ def get_active_for_symbol(
.where(col(BotTable.status).in_(ACTIVE_BOT_STATUSES))
.options(selectinload(BOT_DEAL_REL))
.options(selectinload(BOT_ORDERS_REL))
.options(selectinload(BOT_RECOVERY_REL))
)
if market_type is not None:
stmt = stmt.where(BotTable.market_type == market_type)
Expand Down
12 changes: 12 additions & 0 deletions api/databases/crud/symbols_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ def get_symbol(self, symbol: str) -> SymbolModel:
else:
raise BinbotErrors("Symbol not found")

def start_cooldown(self, symbol: str, cooldown_seconds: int) -> None:
with get_db_session() as session:
symbol_table = session.get(SymbolTable, symbol)
if symbol_table is None:
raise BinbotErrors("Symbol not found")

now_ms = int(time() * 1000)
symbol_table.cooldown = max(int(cooldown_seconds), 0)
symbol_table.cooldown_start_ts = now_ms
symbol_table.updated_at = now_ms
session.add(symbol_table)

def edit_symbol_item(self, data: SymbolRequestPayload) -> SymbolModel:
with get_db_session() as s:
statement = select(SymbolTable).where(SymbolTable.id == data.symbol)
Expand Down
1 change: 1 addition & 0 deletions api/databases/tables/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .user_table import * # noqa
from .order_table import * # noqa
from .deal_table import * # noqa
from .recovery_bot_table import * # noqa
from .bot_table import * # noqa
from .autotrade_table import * # noqa
from .account_balances import * # noqa
Expand Down
Loading
Loading