diff --git a/AGENTS.md b/AGENTS.md index 057dc0119..bea24b57c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/api/bots/routes.py b/api/bots/routes.py index eafd91aae..0582c6b9c 100644 --- a/api/bots/routes.py +++ b/api/bots/routes.py @@ -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 @@ -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 diff --git a/api/deals/gateway.py b/api/deals/gateway.py index 8f51a15c2..a3b5b022f 100644 --- a/api/deals/gateway.py +++ b/api/deals/gateway.py @@ -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 @@ -35,11 +35,11 @@ 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: @@ -47,7 +47,7 @@ def __init__( 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: diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index eacab5cbf..88987cd73 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -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 @@ -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 @@ -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( @@ -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, + ) + 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, + ) + + 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. """ @@ -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 @@ -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. @@ -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 @@ -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 @@ -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( @@ -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 @@ -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 diff --git a/api/exchange_apis/kucoin/futures/position_deal.py b/api/exchange_apis/kucoin/futures/lifecycle.py similarity index 92% rename from api/exchange_apis/kucoin/futures/position_deal.py rename to api/exchange_apis/kucoin/futures/lifecycle.py index 625b2bd32..52c91a7b2 100644 --- a/api/exchange_apis/kucoin/futures/position_deal.py +++ b/api/exchange_apis/kucoin/futures/lifecycle.py @@ -31,9 +31,9 @@ from streaming.spot_position import SpotPosition -class PositionDeal(KucoinPositionDeal): +class Lifecycle(KucoinPositionDeal): """ - Position-based implementation for Kucoin futures trading. + Position lifecycle for Kucoin futures trading. Previously called FuturesLongDeal, but long or short position logic is all handled within this class since Kucoin Futures logic allows easy isolated margin and switching positions. @@ -44,6 +44,7 @@ class PositionDeal(KucoinPositionDeal): """ TRAILING_STOP_REFRESH_MIN_IMPROVEMENT_RATIO = 0.002 + TRAILING_STOP_REPLACE_COOLDOWN_MS = 5 * 60 * 1000 RECOVERY_ATR_WINDOW = 14 RECOVERY_STRUCTURE_WINDOW = 4 RECOVERY_STOP_CAP_PCT = 6.5 @@ -78,10 +79,14 @@ def should_refresh_trailing_stop_loss( current_stop_price: float, new_stop_price: float, direction: int, + last_replace_ts_ms: int | None = None, ) -> bool: if new_stop_price <= 0: return False + if self.trailing_stop_replace_on_cooldown(last_replace_ts_ms): + return False + if current_stop_price <= 0: return True @@ -94,6 +99,17 @@ def should_refresh_trailing_stop_loss( ) return improvement >= min_improvement + def trailing_stop_replace_on_cooldown(self, last_replace_ts_ms: int | None) -> bool: + if not last_replace_ts_ms or last_replace_ts_ms <= 0: + return False + + now_ms = int(time() * 1000) + return now_ms - last_replace_ts_ms < self.TRAILING_STOP_REPLACE_COOLDOWN_MS + + def last_trailing_stop_replace_ts_ms(self) -> int | None: + _, ts, _ = self._bot_known_trailing_stop_loss() + return ts + def place_reversal_reentry_order( self, contracts: float, @@ -159,15 +175,13 @@ def take_profit_order(self) -> BotModel: """ deal_buy_price = self.active_bot.deal.opening_price buy_total_qty = self.active_bot.deal.opening_qty - take_profit_pct = float(self.active_bot.take_profit or 0) / 100 + take_profit_pct = self.active_bot.take_profit / 100 take_profit_multiplier = ( 1 - take_profit_pct if self.active_bot.position == Position.short else 1 + take_profit_pct ) - self.active_bot.deal.take_profit_price = take_profit_multiplier * float( - deal_buy_price - ) + self.active_bot.deal.take_profit_price = take_profit_multiplier * deal_buy_price close_side = ( OrderSide.buy if self.active_bot.position == Position.short @@ -176,7 +190,11 @@ def take_profit_order(self) -> BotModel: # Paper trading: do not hit the exchange, just simulate an order if isinstance(self.controller, PaperTradingTableCrud): - price = float(self.active_bot.deal.current_price or deal_buy_price) + price = ( + self.active_bot.deal.current_price + if self.active_bot.deal.current_price > 0 + else deal_buy_price + ) qty = round_numbers(buy_total_qty, 8) order_data = OrderModel( timestamp=int(time() * 1000), @@ -259,10 +277,10 @@ def execute_stop_loss(self, reference_price: float | None = None) -> BotModel: # Use reference_price as the simulated fill price when available so # paper-trade results reflect the anti-wick capped behaviour. - price = float( + price = ( reference_price if reference_price is not None - else (self.active_bot.deal.current_price or 0) + else self.active_bot.deal.current_price ) close_side = ( OrderSide.buy @@ -351,7 +369,7 @@ def place_trailing_stop_loss( if isinstance(self.controller, PaperTradingTableCrud): # all qty simulated qty = self.active_bot.deal.opening_qty or 1.0 - price = float(self.active_bot.deal.current_price or 0) + price = self.active_bot.deal.current_price close_side = ( OrderSide.buy if self.active_bot.position == Position.short @@ -380,17 +398,38 @@ def place_trailing_stop_loss( qty = round_numbers( abs(float(position.current_qty)) * repurchase_multiplier, 8 ) + intended_price = self.active_bot.deal.trailing_stop_loss_price + _, last_replace_ts_ms, trailing_order_id = ( + self._bot_known_trailing_stop_loss() + ) + exchange_ok, exchange_price = self._exchange_stop_loss_price( + order_id=trailing_order_id + ) + if exchange_ok: + if exchange_price is None and self.trailing_stop_replace_on_cooldown( + last_replace_ts_ms + ): + return self.active_bot + if ( + exchange_price is not None + and not self.should_replace_stop_loss_order( + current_stop_price=exchange_price, + new_stop_price=intended_price, + last_replace_ts_ms=last_replace_ts_ms, + cooldown_ms=self.TRAILING_STOP_REPLACE_COOLDOWN_MS, + ) + ): + return self.active_bot + elif self.trailing_stop_replace_on_cooldown(last_replace_ts_ms): + return self.active_bot + action = "buy" if self.active_bot.position == Position.short else "sell" self.controller.update_logs( f"Dispatching futures {action} order for trailing profit...", self.active_bot, ) - # since trailing_profit only runs when trail is broken - # we can assume stop loss needs to be replaced - # if it constantly runs, then we need to add conditional logic - # to avoid cancelling constantly - self.cancel_current_sl() + self.cancel_current_trailing_sl() if self.active_bot.position == Position.short: order_base: OrderBase = self.kucoin_futures_api.place_futures_order( @@ -423,13 +462,17 @@ def place_trailing_stop_loss( self.remove_stale_orders() self.active_bot.orders.append(order_data) - if order_data.status != OrderStatus.FILLED: + if order_data.status == OrderStatus.FILLED: + self.active_bot.add_log( + "Completed futures take profit after failing to break trailing" + ) + elif order_data.status == OrderStatus.NEW: self.active_bot.add_log( - f"Trailing profit order not filled immediately, got status {order_data.status}" + f"Trailing stop armed on exchange with status {order_data.status}" ) else: self.active_bot.add_log( - "Completed futures take profit after failing to break trailing" + f"Trailing stop placement returned status {order_data.status}; verify exchange order state" ) self.controller.save(self.active_bot) @@ -441,18 +484,27 @@ def reconcile_trailing_stop_loss(self) -> None: a stop order. The bot-side trailing price is the intended exit once trailing has armed. """ - intended_price = float(self.active_bot.deal.trailing_stop_loss_price) + intended_price = self.active_bot.deal.trailing_stop_loss_price if intended_price <= 0: return - exchange_ok, exchange_price = self._exchange_stop_loss_price() + _, last_replace_ts_ms, trailing_order_id = self._bot_known_trailing_stop_loss() + exchange_ok, exchange_price = self._exchange_stop_loss_price( + order_id=trailing_order_id + ) if not exchange_ok: return + if exchange_price is None and self.trailing_stop_replace_on_cooldown( + last_replace_ts_ms + ): + return + if exchange_price is not None and not self.should_replace_stop_loss_order( current_stop_price=exchange_price, new_stop_price=intended_price, - last_replace_ts_ms=None, + last_replace_ts_ms=last_replace_ts_ms, + cooldown_ms=self.TRAILING_STOP_REPLACE_COOLDOWN_MS, ): return @@ -521,7 +573,7 @@ def compute_recovery_stop_loss_pct( structure_distance_pct + self.RECOVERY_FALLBACK_BUFFER_PCT ) recovery_stop_pct = max( - float(self.active_bot.stop_loss), + self.active_bot.stop_loss, buffered_structure_pct, ) self.active_bot.add_log( @@ -533,7 +585,7 @@ def compute_recovery_stop_loss_pct( structure_distance_pct + self.RECOVERY_STRUCTURE_ATR_BUFFER * atr_pct ) recovery_stop_pct = max( - float(self.active_bot.stop_loss), + self.active_bot.stop_loss, buffered_structure_pct, self.RECOVERY_ATR_FLOOR_MULTIPLIER * atr_pct, ) @@ -623,7 +675,7 @@ def _close_source_without_recovery( 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 + bot_cooldown_seconds = self.active_bot.cooldown * 60 cooldown_seconds = max( configured_symbol_cooldown, bot_cooldown_seconds, @@ -705,7 +757,7 @@ def _prior_leg_was_loss(self) -> bool: if self.active_bot.name not in self._NO_REVERSAL_AFTER_LOSS_NAMES: return False try: - cooldown_minutes = max(int(self.active_bot.cooldown or 0), 240) + cooldown_minutes = max(self.active_bot.cooldown, 240) window_ms = cooldown_minutes * 60 * 1000 now_ms = int(time() * 1000) candidates = self.controller.get( @@ -931,7 +983,7 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: exit_reference_price = closed_close # panic close low activity assets - opening_price = float(self.active_bot.deal.opening_price) + opening_price = self.active_bot.deal.opening_price bot_profit = ( ((current_price - opening_price) / opening_price) * 100 * direction if opening_price > 0 @@ -952,7 +1004,7 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: return self.active_bot recovery_params = self.active_bot.recovery_params - sl_pct = float(self.active_bot.stop_loss) + sl_pct = self.active_bot.stop_loss is_recovery_bot = self._is_recovery_bot() if ( is_recovery_bot @@ -963,7 +1015,7 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: 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) + entry_price = self.active_bot.deal.opening_price # 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. @@ -999,7 +1051,7 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: ) if source_reversal_requires_confirmation: - stop_loss_price = float(self.active_bot.deal.stop_loss_price) + stop_loss_price = self.active_bot.deal.stop_loss_price emergency_price, emergency_pct = self.recovery_emergency_stop_price( stop_loss_price=stop_loss_price, completed_candles=completed_candles, @@ -1102,15 +1154,15 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: # First activation: derive the next trailing trigger from entry or the last trailing stop. if self.active_bot.deal.trailing_stop_loss_price == 0: - trailing_price = float(self.active_bot.deal.opening_price) * ( - 1 + direction * (float(self.active_bot.trailing_profit) / 100) + trailing_price = self.active_bot.deal.opening_price * ( + 1 + direction * (self.active_bot.trailing_profit / 100) ) trailing_price = round_numbers(trailing_price, self.price_precision) else: # Advance the trailing trigger in the profitable direction. - trailing_price = float( - self.active_bot.deal.trailing_stop_loss_price - ) * (1 + direction * (self.active_bot.trailing_profit / 100)) + trailing_price = self.active_bot.deal.trailing_stop_loss_price * ( + 1 + direction * (self.active_bot.trailing_profit / 100) + ) trailing_price = round_numbers(trailing_price, self.price_precision) self.active_bot.deal.trailing_profit_price = round_numbers( @@ -1144,6 +1196,7 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: current_stop_price=self.active_bot.deal.trailing_stop_loss_price, new_stop_price=new_trailing_stop_loss, direction=direction, + last_replace_ts_ms=self.last_trailing_stop_replace_ts_ms(), ): self.active_bot.deal.trailing_stop_loss_price = ( new_trailing_stop_loss @@ -1182,7 +1235,7 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: def update_short_trailing(self, close_price: float) -> None: deal = self.active_bot.deal - opening_price = float(deal.opening_price) + opening_price = deal.opening_price if opening_price <= 0: return @@ -1190,8 +1243,8 @@ def update_short_trailing(self, close_price: float) -> None: self.close_price = close_price self.active_bot.deal.current_price = close_price - take_profit_pct = float(self.active_bot.take_profit) / 100 - deviation_pct = float(self.active_bot.trailing_deviation) / 100 + take_profit_pct = self.active_bot.take_profit / 100 + deviation_pct = self.active_bot.trailing_deviation / 100 if deal.trailing_stop_loss_price == 0: price_reference = ( diff --git a/api/exchange_apis/kucoin/futures/position_market.py b/api/exchange_apis/kucoin/futures/position_market.py index 1a5d6d074..184a8ac7e 100644 --- a/api/exchange_apis/kucoin/futures/position_market.py +++ b/api/exchange_apis/kucoin/futures/position_market.py @@ -99,8 +99,8 @@ def build_bb_metrics(self) -> tuple[float, float] | None: ) def build_pullback_metrics(self, current_price: float) -> dict[str, float] | None: - entry_price = float(self.active_bot.deal.opening_price or 0) - entry_timestamp = int(self.active_bot.deal.opening_timestamp or 0) + entry_price = self.active_bot.deal.opening_price + entry_timestamp = self.active_bot.deal.opening_timestamp if entry_price <= 0 or entry_timestamp <= 0: return None @@ -182,13 +182,13 @@ def derive_dynamic_trailing_params( # Emergency SL: pin to existing value if already set, otherwise derive # an initial one. Never re-trail it from market state. - existing_stop_loss = float(self.active_bot.stop_loss or 0) + existing_stop_loss = self.active_bot.stop_loss if existing_stop_loss > 0: stop_loss = clamp( existing_stop_loss, self.MIN_STOP_LOSS, self.MAX_STOP_LOSS ) else: - opening_price = float(self.active_bot.deal.opening_price or 0) + opening_price = self.active_bot.deal.opening_price if is_aggressive_momo and opening_price > 0: stop_loss = ((expansion_range * 0.5) / opening_price) * 100 else: @@ -363,14 +363,14 @@ def bb_extreme_reversion_trailing_analytics(self, current_price: float) -> None: str(market_type).lower() != MarketType.FUTURES.value.lower() or position_value not in {Position.long.value.lower(), Position.short.value.lower()} - or float(self.active_bot.deal.opening_price or 0) <= 0 + or self.active_bot.deal.opening_price <= 0 ): return # ───────────────────────────── # ATR-based stop loss (emergency only; pinned once set) # ───────────────────────────── - existing_stop_loss = float(self.active_bot.stop_loss or 0) + existing_stop_loss = self.active_bot.stop_loss if existing_stop_loss > 0: stop_loss = clamp( existing_stop_loss, self.MIN_STOP_LOSS, self.MAX_STOP_LOSS @@ -407,8 +407,16 @@ def bb_extreme_reversion_trailing_analytics(self, current_price: float) -> None: trailing_deviation, self.MIN_TRAILING_DEVIATION, max_deviation ) else: - trailing_profit = float(self.active_bot.trailing_profit or 2.3) - trailing_deviation = float(self.active_bot.trailing_deviation or 1.63) + trailing_profit = ( + self.active_bot.trailing_profit + if self.active_bot.trailing_profit > 0 + else 2.3 + ) + trailing_deviation = ( + self.active_bot.trailing_deviation + if self.active_bot.trailing_deviation > 0 + else 1.63 + ) self.active_bot.stop_loss = round_numbers(stop_loss, 2) self.active_bot.trailing_profit = round_numbers(trailing_profit, 2) @@ -453,7 +461,7 @@ def market_trailing_analytics( if ( str(market_type).lower() != MarketType.FUTURES.value.lower() or str(position).lower() != Position.long.value.lower() - or float(self.active_bot.deal.opening_price or 0) <= 0 + or self.active_bot.deal.opening_price <= 0 ): return diff --git a/api/grid_ladders/lifecycle.py b/api/grid_ladders/lifecycle.py index 3e9f365e7..74f0ae808 100644 --- a/api/grid_ladders/lifecycle.py +++ b/api/grid_ladders/lifecycle.py @@ -115,7 +115,7 @@ def process_symbol(self, symbol: str) -> None: return # Panic close stale ladders with flat PnL after 1.5 days (mirrors - # PositionDeal.exit stale-position logic). + # Lifecycle.exit stale-position logic). if self._is_stale(ladder): total_pnl = float(ladder.realized_pnl or 0) + float( ladder.unrealized_pnl or 0 @@ -605,7 +605,7 @@ def _is_orphaned(self, ladder: GridLadderTable) -> bool: def _is_stale(self, ladder: GridLadderTable) -> bool: """True when the ladder has been running for 1.5 days with flat PnL - (between -1% and +1% of total_margin), mirroring PositionDeal's + (between -1% and +1% of total_margin), mirroring Lifecycle's panic-close logic for low-activity positions.""" if not ladder.created_at: return False diff --git a/api/tests/test_anti_wick_exit.py b/api/tests/test_anti_wick_exit.py index 63d077b11..920e526af 100644 --- a/api/tests/test_anti_wick_exit.py +++ b/api/tests/test_anti_wick_exit.py @@ -23,7 +23,7 @@ import pytest from bots.models import BotModel, DealModel -from exchange_apis.kucoin.futures.position_deal import PositionDeal +from exchange_apis.kucoin.futures.lifecycle import Lifecycle from pybinbot import MarketType, OrderBase, OrderStatus, DealType, Position @@ -78,8 +78,8 @@ def _make_fheusdtm_deal( margin_short_reversal: bool = False, position: Position = Position.long, ) -> Any: - """Minimal PositionDeal stub shaped after the FHEUSDTM production case.""" - deal = cast(Any, PositionDeal.__new__(PositionDeal)) + """Minimal Lifecycle stub shaped after the FHEUSDTM production case.""" + deal = cast(Any, Lifecycle.__new__(Lifecycle)) deal.price_precision = 5 deal.kucoin_symbol = "FHEUSDTM" deal.symbol_info = types.SimpleNamespace(futures_leverage=2) @@ -139,7 +139,7 @@ def fake_sell(symbol, qty, reduce_only, leverage, reference_price=None): deal = _make_fheusdtm_deal() deal.kucoin_futures_api = types.SimpleNamespace(sell=fake_sell) - PositionDeal.execute_stop_loss(deal, reference_price=0.02252) + Lifecycle.execute_stop_loss(deal, reference_price=0.02252) assert captured.get("reference_price") == pytest.approx(0.02252, abs=1e-6) @@ -169,7 +169,7 @@ def fake_buy(symbol, qty, reduce_only, reference_price=None): deal = _make_fheusdtm_deal(position=Position.short, stop_loss_price=0.02334) deal.kucoin_futures_api = types.SimpleNamespace(buy=fake_buy) - PositionDeal.execute_stop_loss(deal, reference_price=0.02245) + Lifecycle.execute_stop_loss(deal, reference_price=0.02245) assert captured.get("reference_price") == pytest.approx(0.02245, abs=1e-6) @@ -201,7 +201,7 @@ def update_logs(self, *args: Any, **kwargs: Any) -> PaperTradingTable: deal.controller = PaperCtrlStub() deal.active_bot.deal.current_price = 0.0224 # the wick low - PositionDeal.execute_stop_loss(deal, reference_price=0.02252) + Lifecycle.execute_stop_loss(deal, reference_price=0.02252) assert len(saved) > 0 closing_price = saved[-1].deal.closing_price @@ -244,6 +244,6 @@ def fake_sell(symbol, qty, reduce_only, leverage, reference_price=None): create=lambda bot: BotModel(**bot.model_dump()), ) - PositionDeal.reverse_position(deal, reference_price=0.02252) + Lifecycle.reverse_position(deal, reference_price=0.02252) assert captured.get("reference_price") == pytest.approx(0.02252, abs=1e-6) diff --git a/api/tests/test_bot_model_defaults.py b/api/tests/test_bot_model_defaults.py index 7e9a53f65..043f66469 100644 --- a/api/tests/test_bot_model_defaults.py +++ b/api/tests/test_bot_model_defaults.py @@ -7,7 +7,7 @@ from databases.crud.paper_trading_crud import PaperTradingTableCrud from databases.tables.bot_table import BotTable, PaperTradingTable from databases.tables.deal_table import DealTable -from exchange_apis.kucoin.futures.position_deal import PositionDeal +from exchange_apis.kucoin.futures.lifecycle import Lifecycle from pybinbot import DealType, MarketType, OrderStatus, Status from sqlalchemy.pool import StaticPool from sqlmodel import SQLModel, Session, create_engine @@ -195,13 +195,13 @@ def stub_open_deal(self): self.active_bot.status = Status.active return self.active_bot - position_deal = cast(Any, PositionDeal.__new__(PositionDeal)) + position_deal = cast(Any, Lifecycle.__new__(Lifecycle)) position_deal.active_bot = bot position_deal.controller = StubController() position_deal.price_precision = 2 position_deal.open_deal = lambda: stub_open_deal(position_deal) - result = PositionDeal.exit(position_deal, close_price=100.0) + result = Lifecycle.exit(position_deal, close_price=100.0) assert open_deal_calls == [True] assert result.status == Status.active diff --git a/api/tests/test_futures_reversal_integration.py b/api/tests/test_futures_reversal_integration.py index 3d3017a4d..e8855bdae 100644 --- a/api/tests/test_futures_reversal_integration.py +++ b/api/tests/test_futures_reversal_integration.py @@ -6,7 +6,7 @@ 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 exchange_apis.kucoin.futures.lifecycle import Lifecycle from kucoin_universal_sdk.model.common import RestError from pybinbot import MarketType, OrderStatus, QuoteAssets, Status, DealType, Position from tests.fixtures.mock_bot_table import make_mock_bot_active_model @@ -102,7 +102,7 @@ def __init__(self, code: int, message: str): def make_position_deal(bot, futures_api): controller = DummyController() - position_deal = cast(Any, PositionDeal.__new__(PositionDeal)) + position_deal = cast(Any, Lifecycle.__new__(Lifecycle)) position_deal.active_bot = bot position_deal.controller = controller position_deal.kucoin_futures_api = futures_api @@ -292,7 +292,7 @@ def prepare_kat_source_bot() -> BotModel: def set_lifecycle_time(monkeypatch, when: datetime) -> None: timestamp_seconds = when.timestamp() monkeypatch.setattr( - "exchange_apis.kucoin.futures.position_deal.time", + "exchange_apis.kucoin.futures.lifecycle.time", lambda: timestamp_seconds, ) monkeypatch.setattr( @@ -306,7 +306,7 @@ def test_reverse_position_closes_source_with_reduce_only_and_creates_pending_bot futures_api = DummyFuturesApi(current_qty=68) position_deal, controller = make_position_deal(bot, futures_api) - reversed_bot = PositionDeal.reverse_position(position_deal) + reversed_bot = Lifecycle.reverse_position(position_deal) # New bot is pending with flipped direction and no orders/deal assert reversed_bot.position == Position.short @@ -332,7 +332,7 @@ def test_reverse_position_short_closes_with_buy(): futures_api = DummyFuturesApi(current_qty=-68) position_deal, controller = make_position_deal(bot, futures_api) - reversed_bot = PositionDeal.reverse_position(position_deal) + reversed_bot = Lifecycle.reverse_position(position_deal) assert reversed_bot.position == Position.long assert reversed_bot.status == Status.pending @@ -351,7 +351,7 @@ def get_futures_position(self, symbol): futures_api = NoPositionApi() position_deal, controller = make_position_deal(bot, futures_api) - result = PositionDeal.reverse_position(position_deal) + result = Lifecycle.reverse_position(position_deal) assert result.status == Status.error assert len(futures_api.sell_calls) == 0 @@ -370,7 +370,7 @@ def sell(self, symbol, qty, reduce_only, leverage=None, reference_price=None): futures_api = FailingApi() position_deal, controller = make_position_deal(bot, futures_api) - result = PositionDeal.reverse_position(position_deal) + result = Lifecycle.reverse_position(position_deal) assert result.status == Status.error # Source bot is NOT marked completed — close failed @@ -462,12 +462,12 @@ def test_first_reversal_creates_recovery_bot_with_source_metadata(): 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.stop_loss <= Lifecycle.RECOVERY_STOP_CAP_PCT assert reversed_bot.fiat_order_size == 15.0 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 + <= reversed_bot.trailing_profit - Lifecycle.RECOVERY_TRAILING_MIN_GAP_PCT ) diff --git a/api/tests/test_kucoin_futures_stop_loss.py b/api/tests/test_kucoin_futures_stop_loss.py index fe4746412..493ffa964 100644 --- a/api/tests/test_kucoin_futures_stop_loss.py +++ b/api/tests/test_kucoin_futures_stop_loss.py @@ -5,7 +5,7 @@ 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 exchange_apis.kucoin.futures.lifecycle import Lifecycle from pybinbot import MarketType, OrderBase, OrderStatus, DealType, Position from kucoin_universal_sdk.generate.futures.order.model_add_order_req import ( AddOrderReq, @@ -41,7 +41,7 @@ def _make_deal( if orders is not None: deal.active_bot.orders = orders - deal.controller = types.SimpleNamespace(update_logs=lambda **kwargs: None) + deal.controller = types.SimpleNamespace(update_logs=lambda *args, **kwargs: None) deal.kucoin_futures_api = types.SimpleNamespace( get_all_stop_loss_orders=lambda symbol: [], batch_cancel_stop_loss_orders=lambda ids: None, @@ -52,7 +52,7 @@ def _make_deal( def _make_position_deal(**kwargs) -> Any: base_deal = _make_deal(**kwargs) - deal = cast(Any, PositionDeal.__new__(PositionDeal)) + deal = cast(Any, Lifecycle.__new__(Lifecycle)) deal.__dict__.update(base_deal.__dict__) return deal @@ -204,7 +204,7 @@ def test_reconcile_trailing_stop_loss_replaces_worse_exchange_stop(): ) deal.place_trailing_stop_loss = lambda: calls.append("trailing") - PositionDeal.reconcile_trailing_stop_loss(deal) + Lifecycle.reconcile_trailing_stop_loss(deal) assert calls == ["trailing"] @@ -220,16 +220,275 @@ def test_reconcile_trailing_stop_loss_keeps_better_exchange_stop(): ) deal.place_trailing_stop_loss = lambda: calls.append("trailing") - PositionDeal.reconcile_trailing_stop_loss(deal) + Lifecycle.reconcile_trailing_stop_loss(deal) assert calls == [] +def test_reconcile_trailing_stop_loss_uses_tracked_trailing_order(): + calls: list[str] = [] + now_ms = int(time() * 1000) + emergency_order = OrderModel( + order_id="emergency-sl", + order_type="market", + pair="BEATUSDT", + order_side="sell", + qty=1, + price=97.0, + status=OrderStatus.NEW, + timestamp=now_ms - 20_000, + time_in_force="GTC", + deal_type=DealType.stop_loss, + ) + trailing_order = OrderModel( + order_id="trail-1", + order_type="market", + pair="BEATUSDT", + order_side="sell", + qty=1, + price=99.0, + status=OrderStatus.NEW, + timestamp=now_ms - (Lifecycle.TRAILING_STOP_REPLACE_COOLDOWN_MS + 1_000), + time_in_force="GTC", + deal_type=DealType.trailing_profit, + ) + deal = _make_position_deal( + trailing_stop_loss_price=99.0, + orders=[emergency_order, trailing_order], + ) + deal.kucoin_futures_api = types.SimpleNamespace( + get_all_stop_loss_orders=lambda symbol: [ + types.SimpleNamespace(stop_price="97.0", id="emergency-sl"), + types.SimpleNamespace(stop_price="99.0", id="trail-1"), + ], + batch_cancel_stop_loss_orders=lambda ids: None, + ) + deal.place_trailing_stop_loss = lambda: calls.append("trailing") + + Lifecycle.reconcile_trailing_stop_loss(deal) + + assert calls == [] + + +def test_place_trailing_stop_loss_keeps_existing_exchange_stop_without_cancel(): + calls: list[str] = [] + deal = _make_position_deal(trailing_stop_loss_price=99.0) + deal.controller = types.SimpleNamespace( + update_logs=lambda *args, **kwargs: None, + save=lambda bot: None, + ) + deal.kucoin_futures_api = types.SimpleNamespace( + get_futures_position=lambda symbol: types.SimpleNamespace(current_qty=1), + get_all_stop_loss_orders=lambda symbol: [ + types.SimpleNamespace(stop_price="99.0", id="trail-1") + ], + batch_cancel_stop_loss_orders=lambda ids: calls.append("cancel"), + place_futures_order=lambda **kwargs: calls.append("place"), + ) + + Lifecycle.place_trailing_stop_loss(deal) + + assert calls == [] + assert deal.active_bot.orders == [] + + +def test_place_trailing_stop_loss_cancels_only_tracked_trailing_order(): + cancelled_ids: list[str] = [] + now_ms = int(time() * 1000) + emergency_order = OrderModel( + order_id="emergency-sl", + order_type="market", + pair="BEATUSDT", + order_side="sell", + qty=1, + price=97.0, + status=OrderStatus.NEW, + timestamp=now_ms - 20_000, + time_in_force="GTC", + deal_type=DealType.stop_loss, + ) + trailing_order = OrderModel( + order_id="trail-1", + order_type="market", + pair="BEATUSDT", + order_side="sell", + qty=1, + price=98.0, + status=OrderStatus.NEW, + timestamp=now_ms - (Lifecycle.TRAILING_STOP_REPLACE_COOLDOWN_MS + 1_000), + time_in_force="GTC", + deal_type=DealType.trailing_profit, + ) + + def fake_place_futures_order(**kwargs): + return OrderBase( + order_id="trail-2", + order_type="market", + pair=kwargs["symbol"], + timestamp=now_ms, + order_side="sell", + qty=1, + price=kwargs["stop_price"], + status=OrderStatus.NEW, + time_in_force="GTC", + deal_type=DealType.trailing_profit, + ) + + deal = _make_position_deal( + trailing_stop_loss_price=99.0, + orders=[emergency_order, trailing_order], + ) + deal.controller = types.SimpleNamespace( + update_logs=lambda *args, **kwargs: None, + save=lambda bot: None, + ) + deal.kucoin_futures_api = types.SimpleNamespace( + get_futures_position=lambda symbol: types.SimpleNamespace(current_qty=1), + get_all_stop_loss_orders=lambda symbol: [ + types.SimpleNamespace(stop_price="97.0", id="emergency-sl"), + types.SimpleNamespace(stop_price="98.0", id="trail-1"), + ], + batch_cancel_stop_loss_orders=lambda ids: cancelled_ids.extend(ids), + place_futures_order=fake_place_futures_order, + ) + + Lifecycle.place_trailing_stop_loss(deal) + + assert cancelled_ids == ["trail-1"] + assert [order.order_id for order in deal.active_bot.orders] == [ + "emergency-sl", + "trail-2", + ] + + +def test_place_trailing_stop_loss_blocks_recent_trailing_replace(): + calls: list[str] = [] + now_ms = int(time() * 1000) + recent_trailing_order = OrderModel( + order_id="trail-1", + order_type="market", + pair="BEATUSDT", + order_side="sell", + qty=1, + price=98.0, + status=OrderStatus.NEW, + timestamp=now_ms - 1000, + time_in_force="GTC", + deal_type=DealType.trailing_profit, + ) + deal = _make_position_deal( + trailing_stop_loss_price=99.0, + orders=[recent_trailing_order], + ) + deal.controller = types.SimpleNamespace( + update_logs=lambda *args, **kwargs: None, + save=lambda bot: None, + ) + deal.kucoin_futures_api = types.SimpleNamespace( + get_futures_position=lambda symbol: types.SimpleNamespace(current_qty=1), + get_all_stop_loss_orders=lambda symbol: [ + types.SimpleNamespace(stop_price="98.0", id="trail-1") + ], + batch_cancel_stop_loss_orders=lambda ids: calls.append("cancel"), + place_futures_order=lambda **kwargs: calls.append("place"), + ) + + Lifecycle.place_trailing_stop_loss(deal) + + assert calls == [] + assert deal.active_bot.orders == [recent_trailing_order] + + +def test_last_trailing_stop_replace_ignores_emergency_and_terminal_trailing_orders(): + now_ms = int(time() * 1000) + emergency_order = OrderModel( + order_id="emergency-sl", + order_type="market", + pair="BEATUSDT", + order_side="sell", + qty=1, + price=97.0, + status=OrderStatus.NEW, + timestamp=now_ms, + time_in_force="GTC", + deal_type=DealType.stop_loss, + ) + closed_trailing_order = OrderModel( + order_id="trail-closed", + order_type="market", + pair="BEATUSDT", + order_side="sell", + qty=1, + price=98.0, + status=OrderStatus.CANCELED, + timestamp=now_ms - 1_000, + time_in_force="GTC", + deal_type=DealType.trailing_profit, + ) + active_trailing_order = OrderModel( + order_id="trail-active", + order_type="market", + pair="BEATUSDT", + order_side="sell", + qty=1, + price=98.5, + status=OrderStatus.NEW, + timestamp=now_ms - 2_000, + time_in_force="GTC", + deal_type=DealType.trailing_profit, + ) + deal = _make_position_deal( + orders=[emergency_order, closed_trailing_order, active_trailing_order], + ) + + assert Lifecycle.last_trailing_stop_replace_ts_ms(deal) == now_ms - 2_000 + + +def test_place_trailing_stop_loss_logs_new_status_as_armed_stop(): + calls: list[str] = [] + + def fake_place_futures_order(**kwargs): + calls.append("place") + return OrderBase( + order_id="trail-1", + order_type="market", + pair=kwargs["symbol"], + timestamp=1775008219262, + order_side="sell", + qty=1, + price=kwargs["stop_price"], + status=OrderStatus.NEW, + time_in_force="GTC", + deal_type=DealType.trailing_profit, + ) + + deal = _make_position_deal(trailing_stop_loss_price=99.0) + deal.controller = types.SimpleNamespace( + update_logs=lambda *args, **kwargs: None, + save=lambda bot: None, + ) + deal.kucoin_futures_api = types.SimpleNamespace( + get_futures_position=lambda symbol: types.SimpleNamespace(current_qty=1), + get_all_stop_loss_orders=lambda symbol: [], + batch_cancel_stop_loss_orders=lambda ids: calls.append("cancel"), + place_futures_order=fake_place_futures_order, + ) + + Lifecycle.place_trailing_stop_loss(deal) + + assert calls == ["place"] + assert any( + "Trailing stop armed on exchange with status" in log + for log in deal.active_bot.logs + ) + assert not any("not filled immediately" in log for log in deal.active_bot.logs) + + def test_should_refresh_trailing_stop_loss_allows_first_stop(): deal = _make_position_deal() assert ( - PositionDeal.should_refresh_trailing_stop_loss( + Lifecycle.should_refresh_trailing_stop_loss( deal, current_stop_price=0.0, new_stop_price=99.0, @@ -243,7 +502,7 @@ def test_should_refresh_trailing_stop_loss_blocks_small_long_improvement(): deal = _make_position_deal() assert ( - PositionDeal.should_refresh_trailing_stop_loss( + Lifecycle.should_refresh_trailing_stop_loss( deal, current_stop_price=100.0, new_stop_price=100.1, @@ -257,7 +516,7 @@ def test_should_refresh_trailing_stop_loss_allows_material_long_improvement(): deal = _make_position_deal() assert ( - PositionDeal.should_refresh_trailing_stop_loss( + Lifecycle.should_refresh_trailing_stop_loss( deal, current_stop_price=100.0, new_stop_price=100.2, @@ -267,11 +526,43 @@ def test_should_refresh_trailing_stop_loss_allows_material_long_improvement(): ) +def test_should_refresh_trailing_stop_loss_blocks_recent_replace(): + deal = _make_position_deal() + now_ms = int(time() * 1000) + + assert ( + Lifecycle.should_refresh_trailing_stop_loss( + deal, + current_stop_price=100.0, + new_stop_price=101.0, + direction=1, + last_replace_ts_ms=now_ms - 1000, + ) + is False + ) + + +def test_should_refresh_trailing_stop_loss_blocks_recent_replace_without_local_stop(): + deal = _make_position_deal() + now_ms = int(time() * 1000) + + assert ( + Lifecycle.should_refresh_trailing_stop_loss( + deal, + current_stop_price=0.0, + new_stop_price=101.0, + direction=1, + last_replace_ts_ms=now_ms - 1000, + ) + is False + ) + + def test_should_refresh_trailing_stop_loss_blocks_small_short_improvement(): deal = _make_position_deal() assert ( - PositionDeal.should_refresh_trailing_stop_loss( + Lifecycle.should_refresh_trailing_stop_loss( deal, current_stop_price=100.0, new_stop_price=99.9, @@ -285,7 +576,7 @@ def test_should_refresh_trailing_stop_loss_allows_material_short_improvement(): deal = _make_position_deal() assert ( - PositionDeal.should_refresh_trailing_stop_loss( + Lifecycle.should_refresh_trailing_stop_loss( deal, current_stop_price=100.0, new_stop_price=99.8, @@ -335,7 +626,7 @@ def test_reconcile_exchange_sl_places_when_exchange_missing(): def test_exit_panic_closes_stale_mild_loser_after_three_days(monkeypatch): - deal = cast(Any, PositionDeal.__new__(PositionDeal)) + deal = cast(Any, Lifecycle.__new__(Lifecycle)) deal.price_precision = 2 deal.klines = None deal.active_bot = BotModel( @@ -359,17 +650,17 @@ def test_exit_panic_closes_stale_mild_loser_after_three_days(monkeypatch): deal.close_all = lambda: closed.append(True) monkeypatch.setattr( - "exchange_apis.kucoin.futures.position_deal.time", + "exchange_apis.kucoin.futures.lifecycle.time", lambda: (1_000 + (4 * 24 * 60 * 60 * 1000)) / 1000, ) - PositionDeal.exit(deal, 99.5) + Lifecycle.exit(deal, 99.5) assert closed == [True] def test_exit_keeps_stale_loser_below_panic_close_band(monkeypatch): - deal = cast(Any, PositionDeal.__new__(PositionDeal)) + deal = cast(Any, Lifecycle.__new__(Lifecycle)) deal.price_precision = 2 deal.klines = None deal.active_bot = BotModel( @@ -393,11 +684,11 @@ def test_exit_keeps_stale_loser_below_panic_close_band(monkeypatch): deal.close_all = lambda: closed.append(True) monkeypatch.setattr( - "exchange_apis.kucoin.futures.position_deal.time", + "exchange_apis.kucoin.futures.lifecycle.time", lambda: (1_000 + (4 * 24 * 60 * 60 * 1000)) / 1000, ) - PositionDeal.exit(deal, 98.9) + Lifecycle.exit(deal, 98.9) assert closed == [] @@ -432,7 +723,7 @@ def reverse_position(reference_price: float | None = None) -> BotModel: deal.reverse_position = reverse_position - PositionDeal.exit(deal, 94.9) + Lifecycle.exit(deal, 94.9) assert deal.active_bot.stop_loss == 5 assert deal.active_bot.deal.stop_loss_price == 95