""" Take-profit / stop-loss monitor. Subscribes to the Binance price stream; for every open trade that has TP/SL set, closes the HL position as soon as the live mark price crosses the threshold. Lifecycle: - bot_engine.open_position calls register_trade(...) after each new trade - price callback in binance.py calls on_price_tick() once per second - when TP or SL hits, we enqueue close_and_finalize(...) and de-register """ import asyncio import logging from dataclasses import dataclass from typing import Dict, Optional logger = logging.getLogger(__name__) @dataclass class WatchedTrade: trade_id: int wallet: str api_key: str leverage: int asset: str side: str # "long" | "short" entry_price: float take_profit_pct: Optional[float] stop_loss_pct: Optional[float] # trade_id -> WatchedTrade _watched: Dict[int, WatchedTrade] = {} # Strong references to fire-close tasks to prevent GC before completion _background_tasks: set = set() def register_trade( trade_id: int, wallet: str, api_key: str, leverage: int, asset: str, side: str, entry_price: float, take_profit_pct: Optional[float], stop_loss_pct: Optional[float], ) -> None: if take_profit_pct is None and stop_loss_pct is None: return # nothing to watch _watched[trade_id] = WatchedTrade( trade_id=trade_id, wallet=wallet, api_key=api_key, leverage=leverage, asset=asset, side=side, entry_price=entry_price, take_profit_pct=take_profit_pct, stop_loss_pct=stop_loss_pct, ) logger.info("TP/SL watching trade %d (%s %s @ %.2f, tp=%s, sl=%s)", trade_id, side, asset, entry_price, take_profit_pct, stop_loss_pct) def unregister(trade_id: int) -> None: _watched.pop(trade_id, None) def on_price_tick(asset: str, price: float) -> None: """Called from binance.py on every price update. Fires close for any trade that hit TP/SL.""" if not _watched: return triggered = [] for tid, wt in list(_watched.items()): if wt.asset != asset: continue pct = (price - wt.entry_price) / wt.entry_price signed_pct = pct if wt.side == 'long' else -pct # gain in position's direction pct_x100 = signed_pct * 100 if wt.take_profit_pct is not None and pct_x100 >= wt.take_profit_pct: triggered.append((wt, "take_profit")) elif wt.stop_loss_pct is not None and pct_x100 <= -wt.stop_loss_pct: triggered.append((wt, "stop_loss")) for wt, reason in triggered: unregister(wt.trade_id) task = asyncio.create_task(_fire_close(wt, reason)) _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) async def _fire_close(wt: WatchedTrade, reason: str) -> None: from app.services.bot_engine import close_and_finalize try: await close_and_finalize( trade_id=wt.trade_id, api_key=wt.api_key, leverage=wt.leverage, asset=wt.asset, wallet=wt.wallet, reason=reason, ) except Exception as exc: logger.error("TP/SL close failed for trade %d: %s", wt.trade_id, exc)