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 (