diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 08b69b630..f156a8aa4 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -1,7 +1,8 @@ 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: @@ -9,6 +10,9 @@ env: jobs: full-stack-deploy: + if: >- + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest timeout-minutes: 20 @@ -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 @@ -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 \ diff --git a/api/alembic/versions/a3b4c5d6e7f8_add_bot_recovery_params.py b/api/alembic/versions/a3b4c5d6e7f8_add_bot_recovery_params.py new file mode 100644 index 000000000..278681944 --- /dev/null +++ b/api/alembic/versions/a3b4c5d6e7f8_add_bot_recovery_params.py @@ -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") diff --git a/api/bots/models.py b/api/bots/models.py index 964a66259..113752a15 100644 --- a/api/bots/models.py +++ b/api/bots/models.py @@ -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): @@ -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 @@ -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 @@ -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 diff --git a/api/bots/routes.py b/api/bots/routes.py index fcc870b7c..eafd91aae 100644 --- a/api/bots/routes.py +++ b/api/bots/routes.py @@ -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, @@ -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) diff --git a/api/databases/crud/bot_crud.py b/api/databases/crud/bot_crud.py index 8b0a6ae75..f68c7af24 100644 --- a/api/databases/crud/bot_crud.py +++ b/api/databases/crud/bot_crud.py @@ -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 ( @@ -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) @@ -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)) @@ -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: @@ -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: @@ -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))) @@ -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"}: @@ -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 @@ -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) diff --git a/api/databases/crud/symbols_crud.py b/api/databases/crud/symbols_crud.py index 49ed541b7..c2f94b380 100644 --- a/api/databases/crud/symbols_crud.py +++ b/api/databases/crud/symbols_crud.py @@ -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) diff --git a/api/databases/tables/__init__.py b/api/databases/tables/__init__.py index 8c51d70a3..0cef4d2fa 100644 --- a/api/databases/tables/__init__.py +++ b/api/databases/tables/__init__.py @@ -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 diff --git a/api/databases/tables/bot_table.py b/api/databases/tables/bot_table.py index 289d8d681..4d012d234 100644 --- a/api/databases/tables/bot_table.py +++ b/api/databases/tables/bot_table.py @@ -14,6 +14,7 @@ from sqlmodel import Relationship, SQLModel, Field from databases.tables.order_table import ExchangeOrderTable, FakeOrderTable from databases.tables.deal_table import DealTable +from databases.tables.recovery_bot_table import RecoveryBotTable # avoids circular imports # https://sqlmodel.tiangolo.com/tutorial/code-structure/#hero-model-file @@ -91,6 +92,20 @@ class BotTable(SQLModel, table=True): deal: DealTable = Relationship( sa_relationship_kwargs={"lazy": "joined", "single_parent": True} ) + recovery_mode_id: Optional[UUID] = Field( + default=None, + foreign_key="recovery_bot_table.id", + index=True, + unique=True, + ) + recovery_params: Optional[RecoveryBotTable] = Relationship( + sa_relationship_kwargs={ + "lazy": "joined", + "single_parent": True, + "cascade": "all, delete-orphan", + "uselist": False, + } + ) model_config = { "from_attributes": True, diff --git a/api/databases/tables/recovery_bot_table.py b/api/databases/tables/recovery_bot_table.py new file mode 100644 index 000000000..1858e8ab3 --- /dev/null +++ b/api/databases/tables/recovery_bot_table.py @@ -0,0 +1,19 @@ +from typing import Optional +from uuid import UUID, uuid4 + +from pybinbot import timestamp +from sqlmodel import Field, SQLModel + + +class RecoveryBotTable(SQLModel, table=True): + __tablename__ = "recovery_bot_table" + + id: Optional[UUID] = Field( + default_factory=uuid4, primary_key=True, index=True, nullable=False, unique=True + ) + reversal_path: str = Field(default="source") + source_contracts: float = Field(default=0) + source_loss_fiat: float = Field(default=0) + stop_loss_pct: float = Field(default=0) + created_at: float = Field(default_factory=timestamp) + updated_at: float = Field(default_factory=timestamp) diff --git a/api/databases/utils.py b/api/databases/utils.py index 14f1a73f6..0b90deb0c 100644 --- a/api/databases/utils.py +++ b/api/databases/utils.py @@ -63,10 +63,11 @@ def get_db_session(session: Session | None = None) -> Generator[Session, None, N def detach_bot_graph(session: Session, bot: Any) -> None: """ - Detach a bot with its eagerly loaded deal and orders from a session. + Detach a bot with its eagerly loaded relationships from a session. """ deal = bot.deal orders = list(bot.orders) + recovery_params = getattr(bot, "recovery_params", None) if deal is not None and object_session(deal) is session: session.expunge(deal) @@ -75,5 +76,8 @@ def detach_bot_graph(session: Session, bot: Any) -> None: if object_session(order) is session: session.expunge(order) + if recovery_params is not None and object_session(recovery_params) is session: + session.expunge(recovery_params) + if object_session(bot) is session: session.expunge(bot) diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index 30e93c01f..573bdd3f9 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -17,10 +17,10 @@ OrderBase, OrderStatus, OrderType, + Position, Status, convert_to_kucoin_symbol, round_numbers, - Position, ) from streaming.base import BaseStreaming @@ -64,7 +64,8 @@ def __init__( else: self.controller = BotTableCrud() - self.symbol_info = SymbolsCrud().get_symbol(bot.pair) + self.symbols_crud = SymbolsCrud() + self.symbol_info = self.symbols_crud.get_symbol(bot.pair) self.kucoin_futures_api.DEFAULT_LEVERAGE = self.symbol_info.futures_leverage self.kucoin_symbol = convert_to_kucoin_symbol(bot) self.kucoin_symbol_data = self.kucoin_futures_api.get_symbol_info( @@ -75,6 +76,12 @@ def __init__( def _direction_multiplier(self) -> int: return -1 if self.active_bot.position == Position.short else 1 + def _is_recovery_bot(self) -> bool: + recovery_params = self.active_bot.recovery_params + return ( + recovery_params is not None and recovery_params.reversal_path == "recovery" + ) + def create_controller(self) -> PaperTradingTableCrud | BotTableCrud: """ Separate sessions to avoid locking database @@ -618,6 +625,19 @@ def base_order(self) -> BotModel: f"because required margin exceeded available balance." ) + recovery_params = self.active_bot.recovery_params + if ( + self._is_recovery_bot() + and recovery_params is not None + and recovery_params.source_contracts > 0 + and contracts < recovery_params.source_contracts * 0.60 + ): + self.active_bot.add_log( + "underpowered_recovery: " + f"opening {contracts} contracts, below 60% of source " + f"{recovery_params.source_contracts} contracts." + ) + self.active_bot.add_log( f"Futures activation sizing: contracts={contracts}, notional={notional} {self.fiat}, " f"leverage={self.symbol_info.futures_leverage}x, required_margin={required_margin} {self.fiat}, " diff --git a/api/exchange_apis/kucoin/futures/position_deal.py b/api/exchange_apis/kucoin/futures/position_deal.py index 835c0578a..96273c532 100644 --- a/api/exchange_apis/kucoin/futures/position_deal.py +++ b/api/exchange_apis/kucoin/futures/position_deal.py @@ -1,3 +1,4 @@ +from math import ceil from time import time from typing import Type, Union @@ -10,7 +11,10 @@ from kucoin_universal_sdk.model.common import RestError from pybinbot import ( BotBase, + Candles, DealType, + ExchangeId, + Indicators, KucoinApi, KucoinFutures, MarketType, @@ -19,6 +23,7 @@ OrderStatus, OrderType, Position, + RecoveryParams, Status, convert_to_kucoin_symbol, round_numbers, @@ -41,6 +46,15 @@ class PositionDeal(KucoinPositionDeal): """ TRAILING_STOP_REFRESH_MIN_IMPROVEMENT_RATIO = 0.002 + RECOVERY_ATR_WINDOW = 14 + RECOVERY_STRUCTURE_WINDOW = 4 + RECOVERY_STOP_CAP_PCT = 6.5 + RECOVERY_STRUCTURE_ATR_BUFFER = 0.5 + RECOVERY_ATR_FLOOR_MULTIPLIER = 1.5 + RECOVERY_FALLBACK_BUFFER_PCT = 0.75 + RECOVERY_TRAILING_PROFIT_CAP_PCT = 6.0 + RECOVERY_TRAILING_MIN_GAP_PCT = 0.35 + RECOVERY_COOLDOWN_MINUTES = 240 def __init__( self, @@ -459,6 +473,170 @@ def reconcile_trailing_stop_loss(self) -> None: "bb_extreme_reversion", } + def _recovery_atr_pct(self, reference_price: float) -> float | None: + if reference_price <= 0 or self.klines is None: + return None + + closed_candles = self.klines[:-1] + if len(closed_candles) < self.RECOVERY_ATR_WINDOW + 1: + return None + + atr_candles = closed_candles[-(self.RECOVERY_ATR_WINDOW + 1) :] + atr_df = Candles( + exchange=ExchangeId.KUCOIN, + candles=atr_candles, + ).pre_process() + atr_df = Indicators.atr(atr_df, window=self.RECOVERY_ATR_WINDOW) + atr = float(atr_df["ATR"].iloc[-1]) + if atr != atr: + return None + + return (atr / reference_price) * 100 + + def compute_recovery_stop_loss_pct( + self, + reference_price: float, + target_position: Position, + ) -> float | None: + if reference_price <= 0 or self.klines is None: + self.active_bot.add_log( + "Recovery skipped: no valid reference price or kline structure." + ) + return None + + closed_candles = self.klines[:-1] + if len(closed_candles) < self.RECOVERY_STRUCTURE_WINDOW: + self.active_bot.add_log( + "Recovery skipped: fewer than four closed candles available for structure invalidation." + ) + return None + + structure_candles = closed_candles[-self.RECOVERY_STRUCTURE_WINDOW :] + if target_position == Position.short: + structure_price = max(float(candle[2]) for candle in structure_candles) + structure_distance_pct = ( + max(structure_price - reference_price, 0) / reference_price * 100 + ) + else: + structure_price = min(float(candle[3]) for candle in structure_candles) + structure_distance_pct = ( + max(reference_price - structure_price, 0) / reference_price * 100 + ) + + atr_pct = self._recovery_atr_pct(reference_price) + if atr_pct is None: + buffered_structure_pct = ( + structure_distance_pct + self.RECOVERY_FALLBACK_BUFFER_PCT + ) + recovery_stop_pct = max( + float(self.active_bot.stop_loss), + buffered_structure_pct, + ) + self.active_bot.add_log( + "Recovery ATR unavailable; using four-candle structure plus " + f"{self.RECOVERY_FALLBACK_BUFFER_PCT:.2f}% fixed buffer." + ) + else: + buffered_structure_pct = ( + structure_distance_pct + self.RECOVERY_STRUCTURE_ATR_BUFFER * atr_pct + ) + recovery_stop_pct = max( + float(self.active_bot.stop_loss), + buffered_structure_pct, + self.RECOVERY_ATR_FLOOR_MULTIPLIER * atr_pct, + ) + + if buffered_structure_pct > self.RECOVERY_STOP_CAP_PCT: + self.active_bot.add_log( + "Recovery skipped: structure invalidation requires " + f"{buffered_structure_pct:.2f}%, above " + f"{self.RECOVERY_STOP_CAP_PCT:.2f}% cap." + ) + return None + + recovery_stop_pct = min(recovery_stop_pct, self.RECOVERY_STOP_CAP_PCT) + self.active_bot.add_log( + "Recovery hybrid stop computed at " + f"{recovery_stop_pct:.2f}% " + f"(structure distance {structure_distance_pct:.2f}%)." + ) + return round_numbers(recovery_stop_pct, 2) + + def _start_recovery_cooldown(self) -> None: + configured_symbol_cooldown = int(getattr(self.symbol_info, "cooldown", 0) or 0) + bot_cooldown_seconds = int(self.active_bot.cooldown or 0) * 60 + cooldown_seconds = max( + configured_symbol_cooldown, + bot_cooldown_seconds, + self.RECOVERY_COOLDOWN_MINUTES * 60, + ) + + try: + self.symbols_crud.start_cooldown( + symbol=self.active_bot.pair, + cooldown_seconds=cooldown_seconds, + ) + self.active_bot.add_log( + f"Recovery cooldown started for {cooldown_seconds // 60} minutes." + ) + except Exception as exc: + self.active_bot.add_log(f"Failed to start recovery symbol cooldown: {exc}") + + def _source_loss_fiat( + self, + source_bot: BotModel, + closing_price: float, + contracts: float, + ) -> float: + entry_price = float(source_bot.deal.opening_price) + if entry_price <= 0 or closing_price <= 0 or contracts <= 0: + return 0 + + multiplier = float( + getattr(self.kucoin_symbol_data, "multiplier", 0) + or getattr(self.kucoin_futures_api, "DEFAULT_MULTIPLIER", 1) + or 1 + ) + direction = 1 if source_bot.position == Position.long else -1 + price_pnl = (closing_price - entry_price) * contracts * multiplier * direction + loss = max(-price_pnl, 0) + float(source_bot.deal.total_commissions) + return round_numbers(loss, 8) + + def _recovery_trailing_params( + self, + source_bot: BotModel, + recovery_stop_pct: float, + ) -> tuple[float, float]: + trailing_profit = min( + ceil( + max( + float(source_bot.trailing_profit), + 0.9 * recovery_stop_pct, + ) + * 100 + ) + / 100, + self.RECOVERY_TRAILING_PROFIT_CAP_PCT, + ) + trailing_deviation = min( + ceil( + max( + float(source_bot.trailing_deviation), + 0.45 * recovery_stop_pct, + ) + * 100 + ) + / 100, + trailing_profit - self.RECOVERY_TRAILING_MIN_GAP_PCT, + ) + return ( + trailing_profit, + max( + round_numbers(trailing_deviation, 2), + 0, + ), + ) + def _prior_leg_was_loss(self) -> bool: """ True when the most recent completed bot for this pair+name (within the @@ -517,6 +695,16 @@ def reverse_position(self, reference_price: float | None = None) -> BotModel: into a wick. The new bot's re-entry (open_deal) always uses fresh market price and is unaffected. """ + if self._is_recovery_bot(): + self.active_bot.add_log( + "Recovery stop loss reached; closing without another reversal." + ) + self.active_bot = self.execute_stop_loss(reference_price=reference_price) + if self.active_bot.status == Status.completed: + self._start_recovery_cooldown() + self.controller.save(self.active_bot) + return self.active_bot + source_bot = self.active_bot target_position = ( Position.short if source_bot.position == Position.long else Position.long @@ -576,34 +764,89 @@ def reverse_position(self, reference_price: float | None = None) -> BotModel: source_bot.deal.closing_timestamp = closing_order.timestamp source_bot.status = Status.completed source_bot.add_log( - f"Reversal: reduce_only close placed; creating pending {target_position.value} bot." + "Reversal: reduce_only source close placed; evaluating opposite " + f"{target_position.value} entry." ) - self.controller.save(source_bot) + source_recovery_params = source_bot.recovery_params + recovery_stop_pct: float | None = None + recovery_params: RecoveryParams | None = None + recovery_fiat_order_size = float(source_bot.fiat_order_size) + recovery_trailing_profit = float(source_bot.trailing_profit) + recovery_trailing_deviation = float(source_bot.trailing_deviation) + recovery_margin_short_reversal = source_bot.margin_short_reversal + + if ( + source_recovery_params is not None + and source_recovery_params.reversal_path == "source" + ): + recovery_stop_pct = self.compute_recovery_stop_loss_pct( + reference_price=float(closing_order.price), + target_position=target_position, + ) + if recovery_stop_pct is None: + source_bot.add_log("Source position closed without recovery entry.") + self._start_recovery_cooldown() + self.controller.save(source_bot) + self.active_bot = source_bot + return source_bot + + recovery_fiat_order_size = self.contracts_to_fiat_order_size( + current_contracts, + float(closing_order.price), + ) + if recovery_fiat_order_size <= 0: + recovery_fiat_order_size = float(source_bot.fiat_order_size) + + ( + recovery_trailing_profit, + recovery_trailing_deviation, + ) = self._recovery_trailing_params(source_bot, recovery_stop_pct) + recovery_margin_short_reversal = False + recovery_params = RecoveryParams( + reversal_path="recovery", + source_contracts=current_contracts, + source_loss_fiat=self._source_loss_fiat( + source_bot, + float(closing_order.price), + current_contracts, + ), + stop_loss_pct=recovery_stop_pct, + ) + source_bot.add_log( + "Recovery entry approved; creating one opposite pending bot." + ) + + self.controller.save(source_bot) new_bot = BotBase( pair=source_bot.pair, fiat=source_bot.fiat, - fiat_order_size=source_bot.fiat_order_size, + fiat_order_size=recovery_fiat_order_size, quote_asset=source_bot.quote_asset, candlestick_interval=source_bot.candlestick_interval, market_type=source_bot.market_type, close_condition=source_bot.close_condition, cooldown=source_bot.cooldown, dynamic_trailing=source_bot.dynamic_trailing, - margin_short_reversal=source_bot.margin_short_reversal, + margin_short_reversal=recovery_margin_short_reversal, name=source_bot.name, position=target_position, mode=source_bot.mode, status=Status.pending, - stop_loss=source_bot.stop_loss, + stop_loss=( + recovery_stop_pct + if recovery_stop_pct is not None + else source_bot.stop_loss + ), take_profit=source_bot.take_profit, trailing=source_bot.trailing, - trailing_deviation=source_bot.trailing_deviation, - trailing_profit=source_bot.trailing_profit, + trailing_deviation=recovery_trailing_deviation, + trailing_profit=recovery_trailing_profit, logs=[], + recovery_params=recovery_params, ) created_bot = self.controller.create(new_bot) - reversed_bot = BotModel(**created_bot.model_dump()) + reversed_bot = BotModel.dump_from_table(created_bot) self.active_bot = reversed_bot return reversed_bot @@ -623,7 +866,11 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: return self.active_bot direction = self._direction_multiplier() - position_name = self.active_bot.position.value + position_name = getattr( + self.active_bot.position, + "value", + self.active_bot.position, + ) # --------------------------------------------------------------------------- # Reference price for anti-wick exit execution (Phase 1). @@ -658,14 +905,25 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: self.close_all() return self.active_bot + recovery_params = self.active_bot.recovery_params + sl_pct = float(self.active_bot.stop_loss) + is_recovery_bot = self._is_recovery_bot() + if ( + is_recovery_bot + and recovery_params is not None + and recovery_params.stop_loss_pct > 0 + ): + sl_pct = float(recovery_params.stop_loss_pct) + self.active_bot.stop_loss = sl_pct + if self.active_bot.deal.stop_loss_price == 0: entry_price = float(self.active_bot.deal.opening_price) - sl_pct = float(self.active_bot.stop_loss) # ATR-equivalent floor for low-priced perpetuals: tick-noise on # sub-$0.05 contracts routinely exceeds the configured 2.5% SL, # so we widen the band to 4% to avoid pure-noise stop-outs. if ( - self.active_bot.market_type == MarketType.FUTURES + not is_recovery_bot + and self.active_bot.market_type == MarketType.FUTURES and 0 < entry_price < 0.05 and sl_pct < 4.0 ): @@ -681,12 +939,27 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: ) if ( - self.active_bot.stop_loss > 0 + sl_pct > 0 and ((current_price - self.active_bot.deal.stop_loss_price) * direction) < 0 ): - if self.active_bot.margin_short_reversal and not self._prior_leg_was_loss(): + recovery_source_enabled = ( + recovery_params is not None + and recovery_params.reversal_path == "source" + ) + if is_recovery_bot: self.controller.update_logs( - f"Margin short reversal enabled, opening {self.active_bot.position.value} position after stop loss...", + "Recovery stop loss reached; closing and starting symbol cooldown.", + self.active_bot, + ) + self.active_bot = self.reverse_position( + reference_price=exit_reference_price + ) + elif self.active_bot.margin_short_reversal and ( + recovery_source_enabled or not self._prior_leg_was_loss() + ): + self.controller.update_logs( + "Margin short reversal enabled; closing source position and " + "opening the opposite position.", self.active_bot, ) self.active_bot = self.reverse_position( @@ -703,7 +976,10 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: f"Executing futures {position_name} stop_loss after hitting {self.active_bot.deal.stop_loss_price}", self.active_bot, ) - self.execute_stop_loss(reference_price=exit_reference_price) + self.active_bot = self.execute_stop_loss( + reference_price=exit_reference_price + ) + return self.active_bot # Trailing profit (price going down) if self.active_bot.trailing and self.active_bot.deal.opening_price > 0: diff --git a/api/exchange_apis/kucoin/futures/position_market.py b/api/exchange_apis/kucoin/futures/position_market.py index bfc41c562..1a5d6d074 100644 --- a/api/exchange_apis/kucoin/futures/position_market.py +++ b/api/exchange_apis/kucoin/futures/position_market.py @@ -1,6 +1,7 @@ from copy import deepcopy from time import time from typing import Type, Union + from bots.models import BotModel from databases.tables.bot_table import BotTable, PaperTradingTable from exchange_apis.kucoin.futures.futures_deal import KucoinPositionDeal @@ -21,9 +22,9 @@ convert_to_kucoin_symbol, round_numbers, ) -from tools.utils import clamp from streaming.apex_flow_closing import ApexFlowClose from streaming.base import BaseStreaming +from tools.utils import clamp class PositionMarket(KucoinPositionDeal): @@ -434,6 +435,9 @@ def market_trailing_analytics( - trailing_deviation = active stop after trailing - trailing_profit = trigger, never exit """ + if self._is_recovery_bot(): + return + self.apex_flow_closing = ApexFlowClose(self.df, self.btc_df) # Strategy-specific dispatch: bb_extreme_reversion bots use ATR-based diff --git a/api/pyproject.toml b/api/pyproject.toml index 900cfc223..284538720 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "alembic>=1.18.4", "alembic-postgresql-enum", "pydantic-settings>=2.10.1", - "pybinbot>=1.9.25", + "pybinbot>=1.9.27", ] [project.urls] @@ -33,6 +33,7 @@ package = false [project.optional-dependencies] dev = [ + "aiohttp>=3.13.3,<3.14", "pytest-asyncio>=1.2.0", "pytest>=9.0.2", "coverage", diff --git a/api/tests/test_bots.py b/api/tests/test_bots.py index d4f283510..32b355e1a 100644 --- a/api/tests/test_bots.py +++ b/api/tests/test_bots.py @@ -1,8 +1,9 @@ from unittest.mock import patch from fastapi.testclient import TestClient -from pytest import fixture +from pytest import fixture, raises from pybinbot import ExchangeId, GridLadderStatus, MarketType -from sqlmodel import Session +from sqlmodel import Session, select +from sqlalchemy.exc import IntegrityError from tests.fixtures.mock_bot_table import ( mock_bot_data, mock_bot_data_superusdt, @@ -11,8 +12,10 @@ make_mock_bot_superusdt_model, ) from databases.crud.grid_ladder_crud import GridLadderCrud +from databases.tables.bot_table import BotTable from databases.tables.grid_ladder_table import GridLadderTable -from uuid import UUID +from databases.tables.recovery_bot_table import RecoveryBotTable +from uuid import UUID, uuid4 @fixture() @@ -162,6 +165,148 @@ def test_create_bot(client: TestClient): ) +def test_create_bot_without_recovery_params_creates_no_recovery_row( + client: TestClient, create_test_tables +): + with Session(create_test_tables) as session: + recovery_count_before = len(session.exec(select(RecoveryBotTable)).all()) + + response = client.post( + "/bot", + json={ + **mock_bot_data_superusdt, + "pair": "NO-RECOVERY-USDT", + "name": "Bot without recovery params", + }, + ) + + assert response.status_code == 200 + content = response.json()["data"] + assert content["recovery_mode_id"] is None + assert content["recovery_params"] is None + + with Session(create_test_tables) as session: + recovery_count_after = len(session.exec(select(RecoveryBotTable)).all()) + bot = session.get(BotTable, UUID(content["id"])) + assert bot is not None + assert bot.recovery_mode_id is None + assert recovery_count_after == recovery_count_before + + +def test_recovery_params_api_lifecycle(client: TestClient, create_test_tables): + create_payload = { + **mock_bot_data_superusdt, + "pair": "RECOVERY-LIFECYCLE-USDT", + "name": "Recovery lifecycle bot", + "recovery_params": { + "reversal_path": "source", + "source_contracts": 12, + "source_loss_fiat": 4.75, + "stop_loss_pct": 6.5, + }, + } + create_response = client.post("/bot", json=create_payload) + + assert create_response.status_code == 200 + created = create_response.json()["data"] + bot_id = created["id"] + recovery_id = created["recovery_mode_id"] + assert recovery_id is not None + assert created["recovery_params"] == { + "id": recovery_id, + "reversal_path": "source", + "source_contracts": 12, + "source_loss_fiat": 4.75, + "stop_loss_pct": 6.5, + "created_at": created["recovery_params"]["created_at"], + "updated_at": created["recovery_params"]["updated_at"], + } + + get_response = client.get(f"/bot/{bot_id}") + assert get_response.status_code == 200 + fetched = get_response.json()["data"] + assert fetched["recovery_mode_id"] == recovery_id + assert fetched["recovery_params"]["source_loss_fiat"] == 4.75 + + omitted_payload = { + **mock_bot_data_superusdt, + "pair": "RECOVERY-LIFECYCLE-USDT", + "name": "Recovery lifecycle bot edited", + } + omitted_response = client.put(f"/bot/{bot_id}", json=omitted_payload) + assert omitted_response.status_code == 200 + omitted = omitted_response.json()["data"] + assert omitted["recovery_mode_id"] == recovery_id + assert omitted["recovery_params"]["source_loss_fiat"] == 4.75 + + update_payload = { + **omitted_payload, + "recovery_params": { + "reversal_path": "recovery", + "source_contracts": 9, + "source_loss_fiat": 3.25, + "stop_loss_pct": 8, + }, + } + update_response = client.put(f"/bot/{bot_id}", json=update_payload) + assert update_response.status_code == 200 + updated = update_response.json()["data"] + assert updated["recovery_mode_id"] == recovery_id + assert updated["recovery_params"]["id"] == recovery_id + assert updated["recovery_params"]["reversal_path"] == "recovery" + assert updated["recovery_params"]["source_contracts"] == 9 + assert updated["recovery_params"]["source_loss_fiat"] == 3.25 + assert updated["recovery_params"]["stop_loss_pct"] == 8 + + clear_response = client.put( + f"/bot/{bot_id}", + json={**omitted_payload, "recovery_params": None}, + ) + assert clear_response.status_code == 200 + cleared = clear_response.json()["data"] + assert cleared["recovery_mode_id"] is None + assert cleared["recovery_params"] is None + + with Session(create_test_tables) as session: + assert session.get(RecoveryBotTable, UUID(recovery_id)) is None + + +def test_recovery_row_cannot_be_shared_by_two_bots(create_test_tables): + recovery_id = uuid4() + first_bot_id = uuid4() + second_bot_id = uuid4() + + with Session(create_test_tables) as session: + session.add(RecoveryBotTable(id=recovery_id)) + session.add( + BotTable( + id=first_bot_id, + pair="UNIQUE-RECOVERY-ONE-USDT", + recovery_mode_id=recovery_id, + ) + ) + session.commit() + + session.add( + BotTable( + id=second_bot_id, + pair="UNIQUE-RECOVERY-TWO-USDT", + recovery_mode_id=recovery_id, + ) + ) + with raises(IntegrityError): + session.commit() + session.rollback() + + first_bot = session.get(BotTable, first_bot_id) + recovery = session.get(RecoveryBotTable, recovery_id) + assert first_bot is not None + assert recovery is not None + session.delete(first_bot) + session.commit() + assert session.get(RecoveryBotTable, recovery_id) is None + + def test_create_bot_rejects_active_grid_ladder_for_same_symbol_and_logs( client: TestClient, create_test_tables ): diff --git a/api/tests/test_futures_reversal_integration.py b/api/tests/test_futures_reversal_integration.py index 642870b74..d17832623 100644 --- a/api/tests/test_futures_reversal_integration.py +++ b/api/tests/test_futures_reversal_integration.py @@ -1,6 +1,10 @@ from typing import Any, cast +from uuid import uuid4 -from bots.models import BotModel, OrderModel +from bots.models import BotModel, OrderModel, RecoveryBotModel +from databases.tables.bot_table import BotTable +from databases.tables.deal_table import DealTable +from databases.tables.recovery_bot_table import RecoveryBotTable from exchange_apis.kucoin.futures.position_deal import PositionDeal from kucoin_universal_sdk.model.common import RestError from pybinbot import MarketType, OrderStatus, QuoteAssets, Status, DealType, Position @@ -10,15 +14,26 @@ class DummyController: def __init__(self): self.saved: list[BotModel] = [] - self.created: list[BotModel] = [] + self.created: list[BotTable] = [] def save(self, bot: BotModel) -> BotModel: snapshot = BotModel.model_validate(bot.model_dump()) self.saved.append(snapshot) return snapshot - def create(self, new_bot) -> BotModel: - created = BotModel.model_validate(new_bot.model_dump()) + def create(self, new_bot) -> BotTable: + data = new_bot.model_dump(exclude={"recovery_params"}) + created = BotTable(**data, deal=DealTable(), orders=[]) + recovery_params = new_bot.recovery_params + if recovery_params is not None: + recovery_id = uuid4() + created.recovery_mode_id = recovery_id + created.recovery_params = RecoveryBotTable( + **recovery_params.model_dump(), + id=recovery_id, + created_at=1, + updated_at=1, + ) self.created.append(created) return created @@ -65,6 +80,19 @@ def buy(self, symbol, qty, reduce_only, leverage=None, reference_price=None): ) +class DummySymbolsCrud: + def __init__(self): + self.cooldowns: list[dict] = [] + + def start_cooldown(self, symbol: str, cooldown_seconds: int) -> None: + self.cooldowns.append( + { + "symbol": symbol, + "cooldown_seconds": cooldown_seconds, + } + ) + + class DummyResponse: def __init__(self, code: int, message: str): self.code = code @@ -78,9 +106,20 @@ def make_position_deal(bot, futures_api): position_deal.controller = controller position_deal.kucoin_futures_api = futures_api position_deal.kucoin_symbol = "BTCUSDTM" - position_deal.symbol_info = type("SymbolInfo", (), {"futures_leverage": 1})() + position_deal.symbol_info = type( + "SymbolInfo", + (), + {"futures_leverage": 1, "cooldown": 0}, + )() + position_deal.kucoin_symbol_data = type( + "KucoinSymbolInfo", + (), + {"multiplier": 0.001}, + )() + position_deal.symbols_crud = DummySymbolsCrud() position_deal.price_precision = 4 position_deal.qty_precision = 0 + position_deal.klines = None return position_deal, controller @@ -100,6 +139,49 @@ def make_long_bot(): return bot +def enable_source_recovery(bot: BotModel) -> None: + recovery_id = uuid4() + bot.recovery_mode_id = recovery_id + bot.recovery_params = RecoveryBotModel( + id=recovery_id, + reversal_path="source", + source_contracts=0, + source_loss_fiat=0, + stop_loss_pct=0, + created_at=1, + updated_at=1, + ) + + +def mark_as_recovery(bot: BotModel, stop_loss_pct: float = 3.0) -> None: + recovery_id = uuid4() + bot.recovery_mode_id = recovery_id + bot.recovery_params = RecoveryBotModel( + id=recovery_id, + reversal_path="recovery", + source_contracts=68, + source_loss_fiat=2.5, + stop_loss_pct=stop_loss_pct, + created_at=1, + updated_at=1, + ) + + +def recovery_klines( + *, + high: float, + low: float, + close: float, + closed_count: int, +) -> list[list[float]]: + candles = [ + [index, close, high, low, close, 100, index + 1] + for index in range(closed_count) + ] + candles.append([closed_count, close, high, low, close, 100, closed_count + 1]) + return candles + + def test_reverse_position_closes_source_with_reduce_only_and_creates_pending_bot(): bot = make_long_bot() futures_api = DummyFuturesApi(current_qty=68) @@ -175,3 +257,148 @@ def sell(self, symbol, qty, reduce_only, leverage=None, reference_price=None): # Source bot is NOT marked completed — close failed completed = [s for s in controller.saved if s.status == Status.completed] assert len(completed) == 0 + + +def test_compute_recovery_stop_uses_structure_and_atr_floor(): + bot = make_long_bot() + bot.stop_loss = 2.5 + position_deal, _ = make_position_deal(bot, DummyFuturesApi()) + position_deal.klines = recovery_klines( + high=102, + low=100, + close=100, + closed_count=15, + ) + + stop_loss_pct = position_deal.compute_recovery_stop_loss_pct( + reference_price=100, + target_position=Position.short, + ) + + # Structure distance 2% + 0.5 * 2% ATR = 3%; ATR floor is also 3%. + assert stop_loss_pct == 3.0 + + +def test_compute_recovery_stop_uses_fixed_buffer_without_atr(): + bot = make_long_bot() + bot.stop_loss = 2.5 + position_deal, _ = make_position_deal(bot, DummyFuturesApi()) + position_deal.klines = recovery_klines( + high=103, + low=99, + close=100, + closed_count=4, + ) + + stop_loss_pct = position_deal.compute_recovery_stop_loss_pct( + reference_price=100, + target_position=Position.short, + ) + + assert stop_loss_pct == 3.75 + assert any("ATR unavailable" in log for log in bot.logs) + + +def test_compute_recovery_stop_rejects_structure_beyond_cap(): + bot = make_long_bot() + position_deal, _ = make_position_deal(bot, DummyFuturesApi()) + position_deal.klines = recovery_klines( + high=107, + low=99, + close=100, + closed_count=4, + ) + + stop_loss_pct = position_deal.compute_recovery_stop_loss_pct( + reference_price=100, + target_position=Position.short, + ) + + assert stop_loss_pct is None + assert any("above 6.50% cap" in log for log in bot.logs) + + +def test_first_reversal_creates_recovery_bot_with_source_metadata(): + bot = make_long_bot() + bot.stop_loss = 2.5 + bot.trailing_profit = 2.0 + bot.trailing_deviation = 1.0 + enable_source_recovery(bot) + futures_api = DummyFuturesApi(current_qty=68) + position_deal, _ = make_position_deal(bot, futures_api) + position_deal.klines = recovery_klines( + high=1.30, + low=1.24, + close=1.267, + closed_count=4, + ) + + reversed_bot = position_deal.reverse_position() + + assert reversed_bot.position == Position.short + assert reversed_bot.status == Status.pending + assert reversed_bot.margin_short_reversal is False + assert reversed_bot.recovery_params is not None + assert reversed_bot.recovery_params.reversal_path == "recovery" + assert reversed_bot.recovery_params.source_contracts == 68 + assert reversed_bot.recovery_params.source_loss_fiat > 0 + assert reversed_bot.stop_loss == reversed_bot.recovery_params.stop_loss_pct + assert reversed_bot.stop_loss <= PositionDeal.RECOVERY_STOP_CAP_PCT + assert reversed_bot.fiat_order_size == 0.086156 + assert reversed_bot.trailing_profit >= 0.9 * reversed_bot.stop_loss + assert ( + reversed_bot.trailing_deviation + <= reversed_bot.trailing_profit - PositionDeal.RECOVERY_TRAILING_MIN_GAP_PCT + ) + + +def test_source_reversal_skips_recovery_and_starts_cooldown_when_structure_too_wide(): + bot = make_long_bot() + enable_source_recovery(bot) + futures_api = DummyFuturesApi(current_qty=68) + position_deal, controller = make_position_deal(bot, futures_api) + position_deal.klines = recovery_klines( + high=1.40, + low=1.20, + close=1.267, + closed_count=4, + ) + + result = position_deal.reverse_position() + + assert result.status == Status.completed + assert controller.created == [] + assert position_deal.symbols_crud.cooldowns == [ + { + "symbol": "BTCUSDT", + "cooldown_seconds": 360 * 60, + } + ] + + +def test_recovery_reversal_closes_only_and_starts_symbol_cooldown(): + bot = make_long_bot() + mark_as_recovery(bot) + futures_api = DummyFuturesApi(current_qty=68) + position_deal, controller = make_position_deal(bot, futures_api) + stop_loss_calls: list[float | None] = [] + + def execute_stop_loss(reference_price: float | None = None) -> BotModel: + stop_loss_calls.append(reference_price) + bot.status = Status.completed + return bot + + position_deal.execute_stop_loss = execute_stop_loss + + result = position_deal.reverse_position(reference_price=1.25) + + assert result.status == Status.completed + assert stop_loss_calls == [1.25] + assert controller.created == [] + assert futures_api.sell_calls == [] + assert position_deal.symbols_crud.cooldowns == [ + { + "symbol": "BTCUSDT", + "cooldown_seconds": 360 * 60, + } + ] diff --git a/api/tests/test_kucoin_futures_contract_sizing.py b/api/tests/test_kucoin_futures_contract_sizing.py index 66d87fe66..79bc516cb 100644 --- a/api/tests/test_kucoin_futures_contract_sizing.py +++ b/api/tests/test_kucoin_futures_contract_sizing.py @@ -1,7 +1,8 @@ from typing import Any, cast import types +from uuid import uuid4 -from bots.models import BotModel +from bots.models import BotModel, RecoveryBotModel from exchange_apis.kucoin.deals.base import KucoinBaseBalance from exchange_apis.kucoin.futures.futures_deal import KucoinPositionDeal from pybinbot import DealType, OrderBase, OrderStatus, Position @@ -204,6 +205,17 @@ def retrieve_order(self, order_id): # affordable: per-contract margin (100*1 + 2*100*0.0006) = 100.12 → floor(1000/100.12) = 9. # min(15, 9) = 9, downsized from 15 to 9. deal = make_sizing_deal(fiat_order_size=1500, stop_loss=1, multiplier=10) + recovery_id = uuid4() + deal.active_bot.recovery_mode_id = recovery_id + deal.active_bot.recovery_params = RecoveryBotModel( + id=recovery_id, + reversal_path="recovery", + source_contracts=20, + source_loss_fiat=4, + stop_loss_pct=3, + created_at=1, + updated_at=1, + ) deal.active_bot.fiat = "USDT" deal.fiat = "USDT" deal.kucoin_symbol = "TESTUSDTM" @@ -220,3 +232,4 @@ def retrieve_order(self, order_id): assert opened_bot.deal.base_order_size == 9 assert opened_bot.deal.opening_qty == 9 assert any("Futures order downsized from 15 to 9" in log for log in opened_bot.logs) + assert any("underpowered_recovery" in log for log in opened_bot.logs) diff --git a/api/tests/test_kucoin_futures_position_market.py b/api/tests/test_kucoin_futures_position_market.py index 69f77dd9e..8cdd42414 100644 --- a/api/tests/test_kucoin_futures_position_market.py +++ b/api/tests/test_kucoin_futures_position_market.py @@ -1,9 +1,10 @@ from typing import Any, cast import types +from uuid import uuid4 import pandas as pd -from bots.models import BotModel, DealModel +from bots.models import BotModel, DealModel, RecoveryBotModel from exchange_apis.kucoin.futures.futures_deal import KucoinPositionDeal from exchange_apis.kucoin.futures.position_market import PositionMarket from pybinbot import MarketType, Position @@ -103,6 +104,36 @@ def test_market_trailing_analytics_keeps_stop_loss_percent_when_pullback_missing assert market.active_bot.trailing_deviation == 2.0 +def test_market_trailing_analytics_preserves_recovery_parameters(monkeypatch): + market = make_position_market() + recovery_id = uuid4() + market.active_bot.stop_loss = 5.5 + market.active_bot.trailing_profit = 5 + market.active_bot.trailing_deviation = 2.5 + market.active_bot.recovery_mode_id = recovery_id + market.active_bot.recovery_params = RecoveryBotModel( + id=recovery_id, + reversal_path="recovery", + source_contracts=10, + source_loss_fiat=2, + stop_loss_pct=5.5, + created_at=1, + updated_at=1, + ) + monkeypatch.setattr( + "exchange_apis.kucoin.futures.position_market.ApexFlowClose", + lambda *_: (_ for _ in ()).throw( + AssertionError("recovery analytics should return before recalculation") + ), + ) + + market.market_trailing_analytics(current_price=104) + + assert market.active_bot.stop_loss == 5.5 + assert market.active_bot.trailing_profit == 5 + assert market.active_bot.trailing_deviation == 2.5 + + def test_derive_dynamic_trailing_params_widens_gap_on_shallow_pullback(): """Shallow pullback widens trailing only — emergency SL is never trailed.""" market = make_position_market(bot_profit=4.0) diff --git a/api/tests/test_kucoin_futures_stop_loss.py b/api/tests/test_kucoin_futures_stop_loss.py index 36de6dd6d..fe4746412 100644 --- a/api/tests/test_kucoin_futures_stop_loss.py +++ b/api/tests/test_kucoin_futures_stop_loss.py @@ -1,8 +1,9 @@ from time import time from typing import Any, cast import types +from uuid import uuid4 -from bots.models import BotModel, DealModel, OrderModel +from bots.models import BotModel, DealModel, OrderModel, RecoveryBotModel from exchange_apis.kucoin.futures.futures_deal import KucoinPositionDeal from exchange_apis.kucoin.futures.position_deal import PositionDeal from pybinbot import MarketType, OrderBase, OrderStatus, DealType, Position @@ -401,6 +402,43 @@ def test_exit_keeps_stale_loser_below_panic_close_band(monkeypatch): assert closed == [] +def test_exit_uses_recovery_stop_and_closes_without_second_flip(): + deal = _make_position_deal( + stop_loss=1.0, + stop_loss_price=0, + margin_short_reversal=False, + ) + deal.klines = None + recovery_id = uuid4() + deal.active_bot.recovery_mode_id = recovery_id + deal.active_bot.recovery_params = RecoveryBotModel( + id=recovery_id, + reversal_path="recovery", + source_contracts=10, + source_loss_fiat=2, + stop_loss_pct=5, + created_at=1, + updated_at=1, + ) + deal.controller = types.SimpleNamespace( + save=lambda bot: None, + update_logs=lambda *args, **kwargs: None, + ) + reverse_calls: list[float | None] = [] + + def reverse_position(reference_price: float | None = None) -> BotModel: + reverse_calls.append(reference_price) + return deal.active_bot + + deal.reverse_position = reverse_position + + PositionDeal.exit(deal, 94.9) + + assert deal.active_bot.stop_loss == 5 + assert deal.active_bot.deal.stop_loss_price == 95 + assert reverse_calls == [None] + + def test_reconcile_exchange_sl_skips_on_api_failure(): """API blip must not cancel/replace a possibly-still-valid SL.""" calls: list[str] = [] diff --git a/api/uv.lock b/api/uv.lock index 8cfe47950..5292c8503 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -265,15 +265,15 @@ wheels = [ [[package]] name = "beautifulsoup4" -version = "4.14.3" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] @@ -302,6 +302,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "aiohttp" }, { name = "alembic-postgresql-enum" }, { name = "coverage" }, { name = "httpx" }, @@ -316,6 +317,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", marker = "extra == 'dev'", specifier = ">=3.13.3,<3.14" }, { name = "alembic", specifier = ">=1.18.4" }, { name = "alembic-postgresql-enum" }, { name = "alembic-postgresql-enum", marker = "extra == 'dev'" }, @@ -328,7 +330,7 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9.12" }, { name = "py3cw", specifier = ">=0.0.39" }, { name = "py4j", specifier = ">=0.10.9" }, - { name = "pybinbot", specifier = ">=1.9.25" }, + { name = "pybinbot", specifier = ">=1.9.27" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" }, @@ -702,7 +704,7 @@ standard = [ [[package]] name = "fastapi-cloud-cli" -version = "0.18.0" +version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "detect-installer" }, @@ -715,9 +717,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/1d/57221a834b0f62dfa510c2b3db6e9b682cfbc280cef41919a8811ce1ff89/fastapi_cloud_cli-0.18.0.tar.gz", hash = "sha256:95f7a79200e3a90a005e068a4d8ede49d4b04accb095ccd4fd47da998fc28c74", size = 53320, upload-time = "2026-05-22T09:53:54.462Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/7c/f194925af8fabdb0b7a886a1b89087c0b7f327f99e79497a882aa94c1e34/fastapi_cloud_cli-0.19.0.tar.gz", hash = "sha256:f97b31c2ad6af3832eb4065870bdca3365b6e827a0ccf6eeb15e477bc1662b13", size = 57476, upload-time = "2026-06-01T08:24:03.407Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/1e/1d54aabf71c003e89e73df92c3dfded311228e68db7cea5db90b3e0ef2b5/fastapi_cloud_cli-0.18.0-py3-none-any.whl", hash = "sha256:1f136fc651b0b6e2f4a9679e23c56e1c3be3405e74469c14ba6e2d5b87fdc113", size = 37087, upload-time = "2026-05-22T09:53:53.001Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e6/1a2ec890fc273b9da2b173ca45f692a2e24a369bdd39ea7812c1d8a799e5/fastapi_cloud_cli-0.19.0-py3-none-any.whl", hash = "sha256:a2dfc4074c321e63ec88589cc1f90573d4b5bf980ddc44a7033e6f3cd8e96628", size = 38239, upload-time = "2026-06-01T08:24:02.437Z" }, ] [[package]] @@ -935,9 +937,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -945,9 +945,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -955,9 +953,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -965,9 +961,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -975,18 +969,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, - { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -994,9 +984,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -1085,11 +1073,11 @@ wheels = [ [[package]] name = "idna" -version = "3.17" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1967,7 +1955,7 @@ wheels = [ [[package]] name = "pybinbot" -version = "1.9.25" +version = "1.9.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1980,9 +1968,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/8c/e9e46691aff485e628e4d4f895ab1e2563cbb651af4c80475148a22e4c63/pybinbot-1.9.25.tar.gz", hash = "sha256:648e91a41455e49dcaa0c5e7ab074fc03942048f855ee03b44f4cfaccc132540", size = 71885, upload-time = "2026-05-31T20:26:53.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/cd/2f10743db3cf08c907c1ad349d6a9f424f6c935ec97325f4a74919fdd1b8/pybinbot-1.9.27.tar.gz", hash = "sha256:b961d804b3e616d1811250fdf7056ac6c7ebc8321b73ab80c21d5c29451e57c4", size = 72437, upload-time = "2026-06-09T00:17:10.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/48/be10eee991d7785ed208f96062e3e26f9f6e2b108363a4608126028df1ba/pybinbot-1.9.25-py3-none-any.whl", hash = "sha256:c3651d749ac72aadcaf4deefc111329b3ad745711923479d309c6275763f717b", size = 69067, upload-time = "2026-05-31T20:26:52.568Z" }, + { url = "https://files.pythonhosted.org/packages/e1/60/96a885d1115a75aba7db727d244461d85ef52312cca25771882e2a97ef48/pybinbot-1.9.27-py3-none-any.whl", hash = "sha256:a1d744561186593262df26b2441ced81acba4d1117477d22619396b50a55dfe5", size = 69012, upload-time = "2026-06-09T00:17:08.754Z" }, ] [[package]] @@ -2290,11 +2278,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.30" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -2426,16 +2414,16 @@ wheels = [ [[package]] name = "rich-toolkit" -version = "0.19.10" +version = "0.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/02/32217f3657ae91a0ea7cf1d74ade78f44352f830d00c468f753ddb3d4980/rich_toolkit-0.19.10.tar.gz", hash = "sha256:dc2e8c515ef9fbb4894e62bd41a2d2960dd7c2f505b5084894604d5ccfee3f09", size = 198167, upload-time = "2026-05-21T10:11:42.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/63/3e427c62f1992945c997d4ec31e2fcb37d26aadbe5aa44ae5b29f7f64d26/rich_toolkit-0.20.1.tar.gz", hash = "sha256:c7336ae281f435c785acecaedc4b71d4b663dc73d9c8079fea96372527e822a4", size = 203473, upload-time = "2026-06-05T08:56:57.679Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/84/a005adcb4d1e6846ba3d62768090c3b943e3f6d8dc5c47af64f33584c4a7/rich_toolkit-0.19.10-py3-none-any.whl", hash = "sha256:93a41f67a09aefe90379f1729495c2fee9ccbcc8cfda48e2ca2ae54a995e32b1", size = 33907, upload-time = "2026-05-21T10:11:43.578Z" }, + { url = "https://files.pythonhosted.org/packages/00/88/309f07d08155da2ba1d5ceb42d270fb42fbe34a807684543e3ffc10fe713/rich_toolkit-0.20.1-py3-none-any.whl", hash = "sha256:2a6d5f8e15759b9eba5a9ee63da10b275359ead20e5a0fc92bd5b4dbae8ce4bf", size = 35525, upload-time = "2026-06-05T08:56:58.586Z" }, ] [[package]] @@ -2547,40 +2535,40 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, - { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, - { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, - { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, - { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +version = "0.15.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, + { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] [[package]] name = "sentry-sdk" -version = "2.61.0" +version = "2.62.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/4d/3c66e6045bd2071256b6b6fdcb0cc02b86ce54b2acc2ceac79af8e0efbb5/sentry_sdk-2.61.0.tar.gz", hash = "sha256:1ca9b4bb777eb5be67004edab7eb894f21c6301f1d05ed64966719ad5d1764ce", size = 458510, upload-time = "2026-05-28T09:40:28.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/5d/a343201726150e05f2036eeb6e493e2e2f8bf8a66f5aa70f2f4ac96f9ca3/sentry_sdk-2.62.0.tar.gz", hash = "sha256:3c870b9f50d9fd15b58c817dbde1c7cfaa9fe3f05df0a4c6edd5571cb82f5491", size = 463986, upload-time = "2026-06-08T13:23:49.223Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/5a/9794736d5802689c1a48862e6afe6b7f3e86cc37c15d4a84bc0143877dc1/sentry_sdk-2.61.0-py3-none-any.whl", hash = "sha256:ec4d30273909cb1d198e03208b16ee70e2bc5d90a16fd9f1fb2fc6a72e1f03dc", size = 483111, upload-time = "2026-05-28T09:40:27.027Z" }, + { url = "https://files.pythonhosted.org/packages/3d/07/05440381627877aae223fd68f330df9b9fc6641d08bf65328b55235617a2/sentry_sdk-2.62.0-py3-none-any.whl", hash = "sha256:27f61d13a86c3c1648dec666dd5a64f79772dd6a84b446f11866601ecab24f6f", size = 490586, upload-time = "2026-06-08T13:23:47.486Z" }, ] [[package]] @@ -2687,14 +2675,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.67.3" +version = "4.68.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/b3/36c8ecf72e8925200671613332db156d84b99b3aee742a41c1938ebb0808/tqdm-4.68.1.tar.gz", hash = "sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5", size = 171236, upload-time = "2026-06-05T17:23:15.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/47/aa/218a0eb34de1f753c83e4d0d1c8e7c4cef27f20dcb8342e024f63a80dc86/tqdm-4.68.1-py3-none-any.whl", hash = "sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8", size = 78354, upload-time = "2026-06-05T17:23:13.654Z" }, ] [[package]] @@ -2711,7 +2699,7 @@ wheels = [ [[package]] name = "typer" -version = "0.26.4" +version = "0.26.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -2719,9 +2707,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/d3/90c1ee19209cb59f6ad185883fd4ccfcf72f8f0bfd549d5a8b70474611d0/typer-0.26.4.tar.gz", hash = "sha256:25b128964de66c5ea36d5ac82adc579e5e113509b17469edf9f5a4a1864ff2a9", size = 201191, upload-time = "2026-05-30T17:05:04.213Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/6d/5a525c69df4a90892135e5d490b00e9e46402491f3416d4395fcb0d0201e/typer-0.26.4-py3-none-any.whl", hash = "sha256:11bfd7b43557137e373c2b10f6967a555f9678a61ed72c808968b011d95534d6", size = 122436, upload-time = "2026-05-30T17:05:05.812Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, ] [[package]] @@ -2823,15 +2811,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.48.0" +version = "0.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] [package.optional-dependencies] diff --git a/terminal/src/app/components/StopLossTab.tsx b/terminal/src/app/components/StopLossTab.tsx index 32e6e0f40..09d999867 100644 --- a/terminal/src/app/components/StopLossTab.tsx +++ b/terminal/src/app/components/StopLossTab.tsx @@ -11,7 +11,12 @@ import { } from "react-bootstrap"; import InputGroupText from "react-bootstrap/esm/InputGroupText"; import { useForm } from "react-hook-form"; -import { selectBot, setField, setToggle } from "../../features/bots/botSlice"; +import { + selectBot, + setField, + setRecoveryParams, + setToggle, +} from "../../features/bots/botSlice"; import { useAppDispatch, useAppSelector } from "../hooks"; import { type AppDispatch } from "../store"; import { BotType, TabsKeys } from "../../utils/enums"; @@ -20,15 +25,16 @@ import { setTestBotField, setTestBotToggle, } from "../../features/bots/paperTradingSlice"; +import type { RecoveryParams } from "../../features/bots/botInitialState"; +import { defaultRecoveryParams } from "../../features/bots/botInitialState"; const StopLossTab: FC<{ botType?: BotType }> = ({ botType = "bots" }) => { const dispatch: AppDispatch = useAppDispatch(); - let { bot } = useAppSelector(selectBot); - - if (botType === BotType.PAPER_TRADING) { - const testBot = useAppSelector(selectTestBot); - bot = testBot.paperTrading; - } + const { bot: liveBot } = useAppSelector(selectBot); + const { paperTrading } = useAppSelector(selectTestBot); + const bot = botType === BotType.PAPER_TRADING ? paperTrading : liveBot; + const recoveryEnabled = + botType !== BotType.PAPER_TRADING && bot.recovery_params != null; const { watch, @@ -45,21 +51,22 @@ const StopLossTab: FC<{ botType?: BotType }> = ({ botType = "bots" }) => { }); useEffect(() => { - const { unsubscribe } = watch((v, { name, type }) => { - if (v && v?.[name]) { - if (typeof v === "boolean") { + const { unsubscribe } = watch((values, { name }) => { + const value = name ? values[name] : undefined; + if (name && value !== undefined) { + if (typeof value === "boolean") { if (botType === BotType.PAPER_TRADING) { - dispatch(setTestBotToggle({ name, value: v[name] })); + dispatch(setTestBotToggle({ name, value })); } else { - dispatch(setToggle({ name, value: v[name] })); + dispatch(setToggle({ name, value })); } } else { if (botType === BotType.PAPER_TRADING) { dispatch( - setTestBotField({ name, value: v[name] as number | string }), + setTestBotField({ name, value: value as number | string }), ); } else { - dispatch(setField({ name, value: v[name] as number | string })); + dispatch(setField({ name, value: value as number | string })); } } } @@ -73,7 +80,7 @@ const StopLossTab: FC<{ botType?: BotType }> = ({ botType = "bots" }) => { } return () => unsubscribe(); - }, [watch, dispatch, bot, reset]); + }, [watch, dispatch, bot, reset, botType]); const handleBlur = (e) => { if (e.target.value) { @@ -89,6 +96,25 @@ const StopLossTab: FC<{ botType?: BotType }> = ({ botType = "bots" }) => { } }; + const handleRecoveryToggle = () => { + dispatch( + setRecoveryParams(recoveryEnabled ? null : { ...defaultRecoveryParams }), + ); + }; + + const handleRecoveryChange = ( + name: keyof RecoveryParams, + value: RecoveryParams[keyof RecoveryParams], + ) => { + dispatch( + setRecoveryParams({ + ...defaultRecoveryParams, + ...bot.recovery_params, + [name]: value, + }), + ); + }; + return ( = ({ botType = "bots" }) => { > - + Stop loss * @@ -128,7 +154,7 @@ const StopLossTab: FC<{ botType?: BotType }> = ({ botType = "bots" }) => { )} - + Autoswitch (reversal) @@ -168,7 +194,106 @@ const StopLossTab: FC<{ botType?: BotType }> = ({ botType = "bots" }) => { + {botType !== BotType.PAPER_TRADING && ( + + + Recovery mode +
+ + + {recoveryEnabled ? "On" : "Off"} + + +
+ + )}
+ {recoveryEnabled && ( + + + Reversal path + + handleRecoveryChange( + "reversal_path", + event.target.value as RecoveryParams["reversal_path"], + ) + } + > + + + + + + + Source contracts + + + handleRecoveryChange( + "source_contracts", + Number(event.target.value) || 0, + ) + } + /> + + + + Source loss ({bot.fiat ?? "fiat"}) + + + handleRecoveryChange( + "source_loss_fiat", + Number(event.target.value) || 0, + ) + } + /> + + + + Recovery stop loss + + + + handleRecoveryChange( + "stop_loss_pct", + Number(event.target.value) || 0, + ) + } + /> + % + + + + )}
); diff --git a/terminal/src/app/components/tests/StopLossTab.test.tsx b/terminal/src/app/components/tests/StopLossTab.test.tsx new file mode 100644 index 000000000..d4963df56 --- /dev/null +++ b/terminal/src/app/components/tests/StopLossTab.test.tsx @@ -0,0 +1,78 @@ +import { + fireEvent, + render, + screen as testingScreen, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Provider } from "react-redux"; +import { Tab } from "react-bootstrap"; + +import { makeStore } from "../../store"; +import StopLossTab from "../StopLossTab"; +import { BotType, TabsKeys } from "../../../utils/enums"; + +const renderStopLossTab = (botType: BotType = BotType.BOTS) => { + const store = makeStore(); + const user = userEvent.setup(); + + render( + + + + + + + , + ); + + return { store, user }; +}; + +describe("StopLossTab recovery params", () => { + it("creates, edits, and clears recovery params with the recovery toggle", async () => { + const { store, user } = renderStopLossTab(); + const recoveryToggle = testingScreen.getByRole("checkbox"); + + await user.click(recoveryToggle); + + expect(store.getState().bot.bot.recovery_params).toEqual({ + reversal_path: "source", + source_contracts: 0, + source_loss_fiat: 0, + stop_loss_pct: 0, + }); + expect(testingScreen.getByLabelText("Reversal path")).not.toBeNull(); + + fireEvent.change(testingScreen.getByLabelText("Reversal path"), { + target: { value: "recovery" }, + }); + fireEvent.change(testingScreen.getByLabelText("Source contracts"), { + target: { value: "7" }, + }); + fireEvent.change(testingScreen.getByLabelText("Source loss (USDC)"), { + target: { value: "3.5" }, + }); + fireEvent.change(testingScreen.getByLabelText("Recovery stop loss"), { + target: { value: "8" }, + }); + + expect(store.getState().bot.bot.recovery_params).toMatchObject({ + reversal_path: "recovery", + source_contracts: 7, + source_loss_fiat: 3.5, + stop_loss_pct: 8, + }); + + await user.click(recoveryToggle); + + expect(store.getState().bot.bot.recovery_mode_id).toBeNull(); + expect(store.getState().bot.bot.recovery_params).toBeNull(); + expect(testingScreen.queryByLabelText("Reversal path")).toBeNull(); + }); + + it("does not show recovery controls for paper trading", () => { + renderStopLossTab(BotType.PAPER_TRADING); + + expect(testingScreen.queryByText("Recovery mode")).toBeNull(); + }); +}); diff --git a/terminal/src/features/bots/botInitialState.ts b/terminal/src/features/bots/botInitialState.ts index c53c113de..963fefa11 100644 --- a/terminal/src/features/bots/botInitialState.ts +++ b/terminal/src/features/bots/botInitialState.ts @@ -23,6 +23,22 @@ export interface Deal { total_commissions: number; } +export type ReversalPath = "source" | "recovery"; + +export interface RecoveryParams { + reversal_path: ReversalPath; + source_contracts: number; + source_loss_fiat: number; + stop_loss_pct: number; +} + +export const defaultRecoveryParams: RecoveryParams = { + reversal_path: "source", + source_contracts: 0, + source_loss_fiat: 0, + stop_loss_pct: 0, +}; + export interface Bot { id: string; pair: string; @@ -39,6 +55,8 @@ export interface Bot { status: BotStatus; stop_loss: number; margin_short_reversal: boolean; + recovery_mode_id?: string | null; + recovery_params?: RecoveryParams | null; take_profit: number; trailing: boolean; trailing_deviation: number; @@ -98,5 +116,7 @@ export const singleBot: Bot = { orders: [], stop_loss: 3, margin_short_reversal: true, + recovery_mode_id: null, + recovery_params: null, position: BotPosition.LONG, }; diff --git a/terminal/src/features/bots/botSlice.ts b/terminal/src/features/bots/botSlice.ts index 2fe4f0ad0..ef5736f58 100644 --- a/terminal/src/features/bots/botSlice.ts +++ b/terminal/src/features/bots/botSlice.ts @@ -1,6 +1,6 @@ import type { PayloadAction } from "@reduxjs/toolkit"; import { createAppSlice } from "../../app/createAppSlice"; -import { singleBot } from "./botInitialState"; +import { singleBot, type RecoveryParams } from "./botInitialState"; import type { BotDetailsFormField, BotDetailsFormFieldBoolean, @@ -23,6 +23,14 @@ export const botSlice = createAppSlice({ state.bot[payload.name] = payload.value; }, ), + setRecoveryParams: create.reducer( + (state, { payload }: PayloadAction) => { + state.bot.recovery_params = payload; + if (payload === null) { + state.bot.recovery_mode_id = null; + } + }, + ), setBot: create.reducer( (state, { payload }: PayloadAction) => { state.bot = { ...payload.bot }; @@ -51,6 +59,12 @@ export const botSlice = createAppSlice({ }, }); -export const { setField, setBot, setToggle, setCurrentPrice, resetBot } = - botSlice.actions; +export const { + setField, + setBot, + setToggle, + setRecoveryParams, + setCurrentPrice, + resetBot, +} = botSlice.actions; export const { selectBot } = botSlice.selectors; diff --git a/terminal/src/features/bots/botsApiSlice.ts b/terminal/src/features/bots/botsApiSlice.ts index 3d4e7e137..b3024ca1e 100644 --- a/terminal/src/features/bots/botsApiSlice.ts +++ b/terminal/src/features/bots/botsApiSlice.ts @@ -109,7 +109,7 @@ export const botsApiSlice = userApiSlice.injectEndpoints({ query: (body) => ({ url: import.meta.env.VITE_GET_BOTS, method: "POST", - body: body, + body, invalidatesTags: (result) => [{ type: "bot", id: result.id }], }), transformResponse: ({ data, message, error }, meta, arg) => { @@ -127,7 +127,7 @@ export const botsApiSlice = userApiSlice.injectEndpoints({ query: ({ body, id }) => ({ url: `${import.meta.env.VITE_GET_BOTS}/${id}`, method: "PUT", - body: body, + body, invalidatesTags: (result) => [{ type: "bot", id: result.id }], }), transformResponse: ({ botId, message, error }, meta, arg) => { diff --git a/terminal/src/utils/api.test.ts b/terminal/src/utils/api.test.ts new file mode 100644 index 000000000..e99e6e0ab --- /dev/null +++ b/terminal/src/utils/api.test.ts @@ -0,0 +1,30 @@ +import { buildBackUrl } from "./api"; + +describe("buildBackUrl", () => { + it("uses port 8008 for a staging machine hostname", () => { + expect( + buildBackUrl({ + hostname: "desktop-mkotse4", + protocol: "http:", + }), + ).toBe("http://desktop-mkotse4:8008"); + }); + + it("uses port 8008 for localhost", () => { + expect( + buildBackUrl({ + hostname: "localhost", + protocol: "http:", + }), + ).toBe("http://localhost:8008"); + }); + + it("uses the API subdomain for a deployed domain", () => { + expect( + buildBackUrl({ + hostname: "binbot.in", + protocol: "https:", + }), + ).toBe("https://api.binbot.in"); + }); +}); diff --git a/terminal/src/utils/api.ts b/terminal/src/utils/api.ts index 0e619dd19..2fcf9fe4a 100644 --- a/terminal/src/utils/api.ts +++ b/terminal/src/utils/api.ts @@ -2,15 +2,13 @@ import { fetchBaseQuery } from "@reduxjs/toolkit/query"; import { Bounce, toast } from "react-toastify"; import { getToken, removeToken } from "./login"; -export function buildBackUrl() { - let base = window.location.hostname.split("."); - if (base.includes("localhost")) { - base = ["localhost:8008"]; - } else { - base.unshift("api"); - } - const backUrl = `${window.location.protocol}//${base.join(".")}`; - return backUrl; +export function buildBackUrl( + location: Pick = window.location, +) { + const host = location.hostname.includes(".") + ? `api.${location.hostname}` + : `${location.hostname}:8008`; + return `${location.protocol}//${host}`; } export const binbotBaseQuery = async (