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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ These projects are interconnected, and Codex conversations often refer across th
- Trust Pydantic model defaults and types for fields such as `active_bot.*` and `active_bot.deal.*`.
- Do not add fallback expressions like `self.active_bot.deal.stop_loss_price or 0` when the model already defines `0` as the default.
- Do not wrap model fields in casts like `float(...)` when the Pydantic model already validates the field as a float.
- Use validated bot fields directly in trading arithmetic and comparisons, for example `self.active_bot.deal.trailing_stop_loss_price`, `self.active_bot.deal.opening_price`, `self.active_bot.stop_loss`, and `self.active_bot.trailing_profit`.
- Do not write patterns such as `float(self.active_bot.deal.trailing_stop_loss_price or 0)`, `float(self.active_bot.stop_loss)`, or `int(self.active_bot.deal.opening_timestamp)` for validated Pydantic model fields.
- Add explicit fallbacks or casts only at real trust boundaries, such as raw exchange payloads, database rows before validation, or optional third-party values.

## Tests
Expand Down
4 changes: 2 additions & 2 deletions api/bots/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from databases.utils import get_session
from deals.gateway import DealGateway
from databases.tables.bot_table import BotTable, PaperTradingTable
from exchange_apis.kucoin.futures.position_deal import PositionDeal
from exchange_apis.kucoin.futures.lifecycle import Lifecycle
from kucoin_universal_sdk.model.common import RestError
from user.services.auth import get_current_user
from uuid import UUID
Expand Down Expand Up @@ -234,7 +234,7 @@ def activate_bot(
bot_row = crud.get_one(bot_id=bot_id)
bot_model = BotModel.dump_from_table(bot_row)
deal_gateway = DealGateway(bot_model, db_table=BotTable)
if isinstance(deal_gateway.deal, PositionDeal) and bot_model.margin_short_reversal:
if isinstance(deal_gateway.deal, Lifecycle) and bot_model.margin_short_reversal:
can_reverse = deal_gateway.deal.estimate_reversal_possible_for_new_bot()
if not can_reverse:
bot_model.margin_short_reversal = False
Expand Down
8 changes: 4 additions & 4 deletions api/deals/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from databases.crud.autotrade_crud import AutotradeCrud
from exchange_apis.binance.deals.short import BinanceShortDeal
from exchange_apis.binance.deals.long import BinanceLongDeal
from exchange_apis.kucoin.futures.position_deal import PositionDeal
from exchange_apis.kucoin.futures.lifecycle import Lifecycle

if TYPE_CHECKING:
from streaming.base import BaseStreaming
Expand Down Expand Up @@ -35,19 +35,19 @@ def __init__(
BinanceShortDeal,
KucoinLongDeal,
KucoinShortDeal,
PositionDeal,
Lifecycle,
]
if self.autotrade_settings.exchange_id == ExchangeId.KUCOIN:
if bot.market_type == MarketType.FUTURES:
self.deal = PositionDeal(
self.deal = Lifecycle(
bot, db_table=db_table, base_streaming=base_streaming
)
else:
if bot.position == Position.short:
self.deal = KucoinShortDeal(bot, db_table=db_table)
else:
if bot.market_type == MarketType.FUTURES:
self.deal = PositionDeal(
self.deal = Lifecycle(
bot, db_table=db_table, base_streaming=base_streaming
)
else:
Expand Down
131 changes: 102 additions & 29 deletions api/exchange_apis/kucoin/futures/futures_deal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from time import time
from typing import Type
from typing import Any, Type

from bots.models import BotModel, OrderModel
from databases.crud.bot_crud import BotTableCrud
Expand Down Expand Up @@ -42,6 +42,12 @@ class KucoinPositionDeal(KucoinBaseBalance):
STOP_LOSS_REPLACE_MIN_MOVE_RATIO = 0.0015 # 0.15% of price
STOP_LOSS_REPLACE_MIN_TICKS = 2
STOP_LOSS_REPLACE_COOLDOWN_MS = 30_000
TERMINAL_STOP_ORDER_STATUSES = (
OrderStatus.FILLED,
OrderStatus.CANCELED,
OrderStatus.EXPIRED,
OrderStatus.REJECTED,
)
ENTRY_ATR_WINDOW = 14
ENTRY_ATR_MULTIPLIER = 0.5
ENTRY_MIN_ALLOWANCE_PCT = 0.5
Expand Down Expand Up @@ -415,7 +421,7 @@ def backfill_position_from_fills(self) -> BotModel:
else GetTradeHistoryReq.SideEnum.SELL
)

start_at = int(self.active_bot.deal.opening_timestamp) # already ms
start_at = self.active_bot.deal.opening_timestamp
now_ms = int(time() * 1000)

fills = self.base_streaming.kucoin_futures_api.get_fills(
Expand Down Expand Up @@ -528,38 +534,88 @@ def cancel_current_sl(self) -> None:
else:
self.remove_stale_orders()

def _bot_known_stop_loss(self) -> tuple[float | None, int | None]:
def cancel_current_trailing_sl(self) -> None:
"""
Cancel only the active trailing stop when the bot knows its order id.

First trailing activation still falls back to the broad stop cleanup so
the emergency SL can be replaced by the trailing SL.
"""
_, _, trailing_order_id = self._bot_known_trailing_stop_loss()
if trailing_order_id is None:
self.cancel_current_sl()
return

stop_orders = self.kucoin_futures_api.get_all_stop_loss_orders(
self.kucoin_symbol
)
stop_order_ids = [
order.id
for order in stop_orders
if str(getattr(order, "id", "")) == trailing_order_id
]
if stop_order_ids:
self.kucoin_futures_api.batch_cancel_stop_loss_orders(stop_order_ids)

self.active_bot.orders = [
order
for order in self.active_bot.orders
if str(order.order_id) != trailing_order_id
]
if not stop_order_ids:
self.remove_stale_orders()

def _bot_known_stop_order(
self,
deal_type: DealType,
fallback_price: float,
) -> tuple[float | None, int | None, str | None]:
"""
Source of truth from the bot's local order list:
return (price, timestamp_ms) of the most recent open SL order, or
(None, None) if there is no open SL recorded.
return (price, timestamp_ms, order_id) of the most recent open stop
order matching the requested deal type, or (None, None, None) if
there is no matching local order.
"""
for order in reversed(self.active_bot.orders):
if order.deal_type != DealType.stop_loss:
if order.deal_type != deal_type:
continue
if order.status in {
OrderStatus.FILLED,
OrderStatus.CANCELED,
OrderStatus.EXPIRED,
OrderStatus.REJECTED,
}:
if order.status in self.TERMINAL_STOP_ORDER_STATUSES:
continue
order_price = float(order.price or 0)
ts = int(order.timestamp or 0)
order_id = str(order.order_id) if order.order_id else None
if order_price > 0:
return order_price, ts
sl_price = float(self.active_bot.deal.stop_loss_price or 0)
if sl_price > 0:
return sl_price, ts
return None, None
return order_price, ts, order_id
if fallback_price > 0:
return fallback_price, ts, order_id
return None, ts, order_id
return None, None, None

def _exchange_stop_loss_price(self) -> tuple[bool, float | None]:
def _bot_known_stop_loss(self) -> tuple[float | None, int | None]:
stop_price, ts, _ = self._bot_known_stop_order(
DealType.stop_loss,
self.active_bot.deal.stop_loss_price,
)
Comment thread
Copilot marked this conversation as resolved.
return stop_price, ts

def _bot_known_trailing_stop_loss(
self,
) -> tuple[float | None, int | None, str | None]:
return self._bot_known_stop_order(
DealType.trailing_profit,
self.active_bot.deal.trailing_stop_loss_price,
)
Comment thread
Copilot marked this conversation as resolved.

def _exchange_stop_loss_price(
self, order_id: str | None = None
) -> tuple[bool, float | None]:
"""
Source of truth from the exchange.

Returns ``(ok, price)``:
- ``ok=True, price=float`` → exchange has an SL at this price
- ``ok=True, price=None`` → exchange confirmed no SL exists
for the requested order id, or no stop exists when no id is passed
- ``ok=False, price=None`` → query failed; caller must NOT treat
this as "no SL", or it will cancel/replace a still-valid one.
"""
Expand All @@ -574,7 +630,18 @@ def _exchange_stop_loss_price(self) -> tuple[bool, float | None]:
if not stop_orders:
return True, None

for order in stop_orders:
matching_orders: list[Any] = stop_orders
if order_id is not None:
matching_orders = [
order
for order in stop_orders
if str(getattr(order, "id", "")) == order_id
]

if not matching_orders:
return True, None

for order in matching_orders:
stop_price = float(getattr(order, "stop_price", 0) or 0)
if stop_price > 0:
return True, stop_price
Expand All @@ -585,6 +652,7 @@ def should_replace_stop_loss_order(
current_stop_price: float | None,
new_stop_price: float,
last_replace_ts_ms: int | None = None,
cooldown_ms: int | None = None,
) -> bool:
"""
Decide whether the on-exchange SL needs replacing.
Expand Down Expand Up @@ -615,8 +683,13 @@ def should_replace_stop_loss_order(
return False

if last_replace_ts_ms and last_replace_ts_ms > 0:
cooldown = (
self.STOP_LOSS_REPLACE_COOLDOWN_MS
if cooldown_ms is None
else cooldown_ms
)
now_ms = int(time() * 1000)
if now_ms - last_replace_ts_ms < self.STOP_LOSS_REPLACE_COOLDOWN_MS:
if now_ms - last_replace_ts_ms < cooldown:
return False

return True
Expand Down Expand Up @@ -854,7 +927,7 @@ def place_stop_loss(self) -> None:
return

direction = self._direction_multiplier()
stop_price = float(self.active_bot.deal.stop_loss_price)
stop_price = self.active_bot.deal.stop_loss_price
if stop_price <= 0:
stop_price = round_numbers(
self.active_bot.deal.opening_price
Expand Down Expand Up @@ -919,7 +992,7 @@ def recompute_derived_prices(self) -> BotModel:
break

if self.active_bot.stop_loss > 0:
entry_price = float(self.active_bot.deal.opening_price)
entry_price = self.active_bot.deal.opening_price
delta = entry_price * (self.active_bot.stop_loss / 100)
stop_loss_price = entry_price - (delta * direction)
self.active_bot.deal.stop_loss_price = round_numbers(
Expand All @@ -931,9 +1004,9 @@ def recompute_derived_prices(self) -> BotModel:
and self.active_bot.trailing_deviation > 0
and self.active_bot.trailing_profit > 0
):
entry_price = float(self.active_bot.deal.opening_price)
entry_price = self.active_bot.deal.opening_price
trailing_profit_price = entry_price * (
1 + direction * (float(self.active_bot.trailing_profit) / 100)
1 + direction * (self.active_bot.trailing_profit / 100)
)
self.active_bot.deal.trailing_profit_price = round_numbers(
trailing_profit_price, self.price_precision
Expand Down Expand Up @@ -969,20 +1042,20 @@ def update_parameters_with_activation(self) -> BotModel:
direction = self._direction_multiplier()

if self.active_bot.stop_loss > 0:
price = float(self.active_bot.deal.opening_price)
price = self.active_bot.deal.opening_price
delta = price * (self.active_bot.stop_loss / 100)
self.active_bot.deal.stop_loss_price = price - (delta * direction)

if self.active_bot.trailing:
trailing_profit = float(self.active_bot.deal.opening_price) * (
1 + direction * (float(self.active_bot.trailing_profit) / 100)
trailing_profit = self.active_bot.deal.opening_price * (
1 + direction * (self.active_bot.trailing_profit / 100)
)
self.active_bot.deal.trailing_profit_price = trailing_profit
self.active_bot.deal.trailing_stop_loss_price = 0
self.active_bot.deal.take_profit_price = 0
else:
take_profit_price = float(self.active_bot.deal.opening_price) * (
1 + direction * (float(self.active_bot.take_profit) / 100)
take_profit_price = self.active_bot.deal.opening_price * (
1 + direction * (self.active_bot.take_profit / 100)
)
self.active_bot.deal.take_profit_price = take_profit_price

Expand Down
Loading
Loading