← Назад
""" Position Manager β€” monitors open positions and executes TP/SL strategy. Strategy: Variant C Hybrid Entry β†’ signal from bot SL: -1.5% TP1: +2% β†’ close 50%, move SL to BE (0%) TP2: +3% β†’ close 25% (50% of remaining), move SL to +1.5% TP3: +5% β†’ close remaining 25% """ import asyncio import logging import os from dataclasses import dataclass, field from datetime import datetime, timezone, timedelta from trader import BinanceFuturesTrader from trade_log import log_event, get_open_trades, get_tp_state from order_placer import ExchangeOrderManager, MAKER_FEE_PCT logger = logging.getLogger(__name__) VANCOUVER_TZ = timezone(timedelta(hours=-7)) # === Strategy Config (from env or defaults) === TRADE_SIZE_USDT = float(os.environ.get("TRADE_SIZE_USDT", "10")) MAX_LEVERAGE = int(os.environ.get("MAX_LEVERAGE", "5")) MAX_OPEN_POSITIONS = int(os.environ.get("MAX_OPEN_POSITIONS", "3")) SL_PERCENT = float(os.environ.get("SL_PERCENT", "1.5")) TP1_PERCENT = float(os.environ.get("TP1_PERCENT", "2.0")) TP2_PERCENT = float(os.environ.get("TP2_PERCENT", "3.0")) TP3_PERCENT = float(os.environ.get("TP3_PERCENT", "5.0")) TP1_CLOSE_RATIO = 0.50 # Close 50% of total at TP1 TP2_CLOSE_RATIO = 0.50 # Close 50% of remaining at TP2 (= 25% of total) # TP3 = close everything remaining SL_AFTER_TP1_PCT = 0.0 # Move SL to breakeven after TP1 SL_AFTER_TP2_PCT = 1.5 # Move SL to +1.5% after TP2 PRICE_CHECK_INTERVAL = int(os.environ.get("PRICE_CHECK_INTERVAL", "3")) # Exchange-side TP/SL WT_USE_EXCHANGE_ORDERS = os.environ.get("WT_USE_EXCHANGE_ORDERS", "true").lower() == "true" # Binance Futures taker fee: 0.04% per side (open + close = 0.08% total) TAKER_FEE_PCT = 0.04 def safe_pnl_pct(entry_price: float, current_price: float, side: str) -> float: """Calculate PnL % safely (no division by zero).""" if entry_price <= 0: return 0.0 if side == "BUY": return ((current_price - entry_price) / entry_price) * 100 else: return ((entry_price - current_price) / entry_price) * 100 @dataclass class Position: """Active trading position.""" symbol: str side: str # "BUY" (long) or "SELL" (short) entry_price: float total_quantity: float remaining_quantity: float sl_price: float tp1_price: float tp2_price: float tp3_price: float tp1_hit: bool = False tp2_hit: bool = False opened_at: str = "" signal_data: dict = field(default_factory=dict) trade_id: str = "" # Track realized PnL from partial closes realized_pnl: float = 0.0 # Exchange-side order IDs sl_order_id: int | None = None tp1_order_id: int | None = None tp2_order_id: int | None = None tp3_order_id: int | None = None use_exchange_orders: bool = False def calculate_levels(entry_price: float, side: str) -> dict: """ Calculate SL and TP price levels. For LONG (BUY): SL = entry * (1 - SL%) TP = entry * (1 + TP%) For SHORT (SELL): SL = entry * (1 + SL%) TP = entry * (1 - TP%) """ if side == "BUY": sl = entry_price * (1 - SL_PERCENT / 100) tp1 = entry_price * (1 + TP1_PERCENT / 100) tp2 = entry_price * (1 + TP2_PERCENT / 100) tp3 = entry_price * (1 + TP3_PERCENT / 100) else: # SELL (short) sl = entry_price * (1 + SL_PERCENT / 100) tp1 = entry_price * (1 - TP1_PERCENT / 100) tp2 = entry_price * (1 - TP2_PERCENT / 100) tp3 = entry_price * (1 - TP3_PERCENT / 100) return {"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3} class PositionManager: """Manages all open positions and runs the monitor loop.""" def __init__(self, trader: BinanceFuturesTrader, notify_fn, tmm=None): """ Args: trader: authenticated Binance client notify_fn: async function to send Telegram alerts tmm: optional TMMClient for journal integration """ self.trader = trader self.notify = notify_fn self.tmm = tmm self.positions: dict[str, Position] = {} # symbol -> Position self.order_mgr = ExchangeOrderManager(trader) def can_open_position(self, symbol: str) -> tuple[bool, str]: """Check if we can open a new position.""" if symbol in self.positions: return False, f"Already have position in {symbol}" # Double-check Binance β€” prevents duplicates after restarts existing = self.trader.get_position(symbol) if existing: logger.warning(f"WT {symbol}: position exists on Binance but not in manager β€” skip") return False, f"Position exists on Binance (untracked)" if len(self.positions) >= MAX_OPEN_POSITIONS: return False, f"Max positions reached ({MAX_OPEN_POSITIONS})" balance = self.trader.get_account_balance() if balance < TRADE_SIZE_USDT: return False, f"Insufficient balance: ${balance:.2f} < ${TRADE_SIZE_USDT}" return True, "OK" async def open_trade(self, symbol: str, side: str, signal_data: dict) -> bool: """ Open a new trade. Returns True if position was opened successfully. """ can_open, reason = self.can_open_position(symbol) if not can_open: logger.info(f"Cannot open {symbol}: {reason}") await self.notify(f"⏭ Skip {symbol}: {reason}") return False # Execute on Binance result = self.trader.open_position(symbol, side, TRADE_SIZE_USDT, MAX_LEVERAGE) if not result: await self.notify(f"❌ Failed to open {side} {symbol}") log_event("ERROR", {"symbol": symbol, "side": side, "error": "open_position failed"}) return False entry_price = result["fill_price"] quantity = result["quantity"] # Calculate levels levels = calculate_levels(entry_price, side) now = datetime.now(VANCOUVER_TZ) trade_id = f"{symbol}_{now.strftime('%Y%m%d_%H%M%S')}" # Create position object pos = Position( symbol=symbol, side=side, entry_price=entry_price, total_quantity=quantity, remaining_quantity=quantity, sl_price=levels["sl"], tp1_price=levels["tp1"], tp2_price=levels["tp2"], tp3_price=levels["tp3"], opened_at=now.isoformat(), signal_data=signal_data, trade_id=trade_id, ) # Deduct open commission from realized PnL (so total PnL matches exchange) open_fee = quantity * entry_price * TAKER_FEE_PCT / 100 pos.realized_pnl = -open_fee self.positions[symbol] = pos # Place exchange-side TP/SL orders if WT_USE_EXCHANGE_ORDERS: self._place_initial_orders(pos) # Log entry (include WT signal data for post-analysis) log_event("ENTRY", { "trade_id": trade_id, "symbol": symbol, "side": side, "entry_price": entry_price, "quantity": quantity, "leverage": MAX_LEVERAGE, "margin_usdt": TRADE_SIZE_USDT, "sl_price": levels["sl"], "tp1_price": levels["tp1"], "tp2_price": levels["tp2"], "tp3_price": levels["tp3"], "exchange_orders": pos.use_exchange_orders, "wt_15m_signal": signal_data.get("wt_15m_signal", ""), "wt1_15m": signal_data.get("wt1_15m", 0), "wt1_1h": signal_data.get("wt1_1h", 0), "wt_1h_signal": signal_data.get("wt_1h_signal", ""), }) # Notify Rick direction = "LONG" if side == "BUY" else "SHORT" order_tag = " [EX]" if pos.use_exchange_orders else "" notional = quantity * entry_price msg = ( f"{'🟒' if side == 'BUY' else 'πŸ”΄'} TRADE OPENED: {direction} {symbol}{order_tag}\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"πŸ’° Entry: ${entry_price:.6f}\n" f"πŸ“Š Size: {quantity} ({MAX_LEVERAGE}x, ${notional:.2f})\n" f"πŸ›‘ SL: ${levels['sl']:.6f} (-{SL_PERCENT}%)\n" f"🎯 TP1: ${levels['tp1']:.6f} (+{TP1_PERCENT}%) β†’ 50%\n" f"🎯 TP2: ${levels['tp2']:.6f} (+{TP2_PERCENT}%) β†’ 25%\n" f"🎯 TP3: ${levels['tp3']:.6f} (+{TP3_PERCENT}%) β†’ 25%\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) logger.info(f"Trade opened: {direction} {symbol} @ {entry_price}, qty={quantity}") # TMM journal: tag trade if self.tmm: wt_sig = signal_data.get("wt_15m_signal", "") self.tmm.on_trade_opened(symbol, side, "WT", signal_info=f"[WT] {direction} via {wt_sig}") return True async def open_trade_with_levels( self, symbol: str, side: str, sl_price: float, tp1_price: float, tp2_price: float, tp3_price: float, signal_data: dict, ) -> bool: """ Open a trade with custom SL/TP levels (for Digash formations). Instead of calculating levels from fixed percentages, uses specific price levels based on formation analysis. """ can_open, reason = self.can_open_position(symbol) if not can_open: logger.info(f"Cannot open {symbol}: {reason}") await self.notify(f"⏭ Skip {symbol}: {reason}") return False # Execute on Binance result = self.trader.open_position(symbol, side, TRADE_SIZE_USDT, MAX_LEVERAGE) if not result: await self.notify(f"❌ Failed to open {side} {symbol}") log_event("ERROR", {"symbol": symbol, "side": side, "error": "open_position failed"}) return False entry_price = result["fill_price"] quantity = result["quantity"] now = datetime.now(VANCOUVER_TZ) trade_id = f"DG_{symbol}_{now.strftime('%Y%m%d_%H%M%S')}" pos = Position( symbol=symbol, side=side, entry_price=entry_price, total_quantity=quantity, remaining_quantity=quantity, sl_price=sl_price, tp1_price=tp1_price, tp2_price=tp2_price, tp3_price=tp3_price, opened_at=now.isoformat(), signal_data=signal_data, trade_id=trade_id, ) # Deduct open commission open_fee = quantity * entry_price * TAKER_FEE_PCT / 100 pos.realized_pnl = -open_fee # Place exchange-side TP/SL orders if WT_USE_EXCHANGE_ORDERS: self._place_initial_orders(pos) self.positions[symbol] = pos # Calculate SL/TP distances for display sl_dist = safe_pnl_pct(entry_price, sl_price, side) tp1_dist = safe_pnl_pct(entry_price, tp1_price, "SELL" if side == "BUY" else "BUY") tp3_dist = safe_pnl_pct(entry_price, tp3_price, "SELL" if side == "BUY" else "BUY") log_event("ENTRY", { "trade_id": trade_id, "symbol": symbol, "side": side, "entry_price": entry_price, "quantity": quantity, "leverage": MAX_LEVERAGE, "margin_usdt": TRADE_SIZE_USDT, "sl_price": sl_price, "tp1_price": tp1_price, "tp2_price": tp2_price, "tp3_price": tp3_price, "source": "digash", "formation": signal_data.get("formation", ""), }) direction = "LONG" if side == "BUY" else "SHORT" notional = quantity * entry_price formation = signal_data.get("formation", "?") msg = ( f"{'🟒' if side == 'BUY' else 'πŸ”΄'} DIGASH {direction} {symbol}\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"πŸ“ Formation: {formation}\n" f"πŸ’° Entry: ${entry_price:.6f}\n" f"πŸ“Š Size: {quantity} ({MAX_LEVERAGE}x, ${notional:.2f})\n" f"πŸ›‘ SL: ${sl_price:.6f} ({sl_dist:+.2f}%)\n" f"🎯 TP1: ${tp1_price:.6f} β†’ 50%\n" f"🎯 TP2: ${tp2_price:.6f} β†’ 25%\n" f"🎯 TP3: ${tp3_price:.6f} β†’ 25%\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) logger.info(f"Digash trade opened: {direction} {symbol} @ {entry_price}, formation={formation}") return True def _place_initial_orders(self, pos: Position): """Place TP1/TP2/TP3 + SL orders on Binance after position opens.""" tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO) tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * TP2_CLOSE_RATIO) tp3_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity - tp1_qty - tp2_qty) orders = self.order_mgr.place_tp_sl_orders( pos.symbol, pos.side, sl_price=pos.sl_price, sl_quantity=pos.total_quantity, tp_levels=[ (pos.tp1_price, tp1_qty), (pos.tp2_price, tp2_qty), (pos.tp3_price, tp3_qty), ], ) pos.sl_order_id = orders["sl_order_id"] ids = orders["tp_order_ids"] pos.tp1_order_id = ids[0] if len(ids) > 0 else None pos.tp2_order_id = ids[1] if len(ids) > 1 else None pos.tp3_order_id = ids[2] if len(ids) > 2 else None pos.use_exchange_orders = bool(pos.sl_order_id and pos.tp1_order_id) if pos.use_exchange_orders: logger.info(f"WT {pos.symbol}: exchange orders placed (SL #{pos.sl_order_id}, TP1 #{pos.tp1_order_id}, TP2 #{pos.tp2_order_id}, TP3 #{pos.tp3_order_id})") else: logger.warning(f"WT {pos.symbol}: exchange orders failed, using polling fallback") def _place_recovery_orders(self, pos: Position): """Place exchange orders for a recovered position (respects TP state).""" remaining = pos.remaining_quantity # Build TP levels based on what hasn't been hit yet tp_levels = [] if pos.tp2_hit: # Only TP3 remaining tp_levels.append((pos.tp3_price, remaining)) elif pos.tp1_hit: # TP2 + TP3 remaining tp2_qty = self.trader.round_quantity(pos.symbol, remaining * TP2_CLOSE_RATIO) tp3_qty = self.trader.round_quantity(pos.symbol, remaining - tp2_qty) if tp2_qty > 0: tp_levels.append((pos.tp2_price, tp2_qty)) if tp3_qty > 0: tp_levels.append((pos.tp3_price, tp3_qty)) else: # All TPs remaining β€” use remaining_quantity (not total) tp1_qty = self.trader.round_quantity(pos.symbol, remaining * TP1_CLOSE_RATIO) tp2_qty = self.trader.round_quantity(pos.symbol, (remaining - tp1_qty) * TP2_CLOSE_RATIO) tp3_qty = self.trader.round_quantity(pos.symbol, remaining - tp1_qty - tp2_qty) if tp1_qty > 0: tp_levels.append((pos.tp1_price, tp1_qty)) if tp2_qty > 0: tp_levels.append((pos.tp2_price, tp2_qty)) if tp3_qty > 0: tp_levels.append((pos.tp3_price, tp3_qty)) orders = self.order_mgr.place_tp_sl_orders( pos.symbol, pos.side, sl_price=pos.sl_price, sl_quantity=remaining, tp_levels=tp_levels, ) pos.sl_order_id = orders["sl_order_id"] ids = orders["tp_order_ids"] # Assign TP order IDs based on state if pos.tp2_hit: pos.tp3_order_id = ids[0] if len(ids) > 0 else None elif pos.tp1_hit: pos.tp2_order_id = ids[0] if len(ids) > 0 else None pos.tp3_order_id = ids[1] if len(ids) > 1 else None else: pos.tp1_order_id = ids[0] if len(ids) > 0 else None pos.tp2_order_id = ids[1] if len(ids) > 1 else None pos.tp3_order_id = ids[2] if len(ids) > 2 else None pos.use_exchange_orders = bool(pos.sl_order_id and len(ids) > 0) if pos.use_exchange_orders: logger.info(f"WT {pos.symbol}: recovery orders placed (SL #{pos.sl_order_id}, TPs: {ids})") else: logger.warning(f"WT {pos.symbol}: recovery orders failed, using polling fallback") async def _handle_sl(self, pos: Position, current_price: float): """Handle stop loss hit.""" # Close everything result = self.trader.close_full(pos.symbol, pos.side) # Use actual fill price from Binance fill_price = current_price # fallback if result and result.get("fill_price"): fill_price = result["fill_price"] logger.info(f"SL close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f} slip={((fill_price - current_price) / current_price * 100):+.3f}%") # Calculate PnL (with commission) using actual fill price pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side) pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price) if pnl_pct < 0: pnl_usdt = -pnl_usdt # Subtract close commission (open commission already paid) close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100 pnl_usdt -= close_fee total_pnl = pos.realized_pnl + pnl_usdt log_event("SL_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "side": pos.side, "entry_price": pos.entry_price, "exit_price": fill_price, "mark_price": current_price, "slippage_pct": round((fill_price - current_price) / current_price * 100, 3) if current_price else 0, "closed_quantity": pos.remaining_quantity, "pnl_pct": round(pnl_pct, 2), "realized_pnl_usdt": round(pnl_usdt, 2), "total_trade_pnl_usdt": round(total_pnl, 2), "tp1_was_hit": pos.tp1_hit, "tp2_was_hit": pos.tp2_hit, }) sl_type = "BE" if pos.tp1_hit else "SL" emoji = "🟑" if pos.tp1_hit else "πŸ”΄" slip_line = "" if abs(fill_price - current_price) / current_price > 0.001: slip_line = f"⚠️ Slip: {((fill_price - current_price) / current_price * 100):+.2f}%\n" msg = ( f"{emoji} {sl_type} HIT: {pos.symbol}\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Entry: ${pos.entry_price:.6f}\n" f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n" f"{slip_line}" f"πŸ’΅ PnL этой части: ${pnl_usdt:+.2f}\n" f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ ΠΏΠΎ сдСлкС: ${total_pnl:+.2f}\n" f"{'βœ… TP1 Π±Ρ‹Π» взят' if pos.tp1_hit else ''}" f"{'βœ… TP2 Π±Ρ‹Π» взят' if pos.tp2_hit else ''}\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) del self.positions[pos.symbol] logger.info(f"SL hit for {pos.symbol}: {pnl_pct:+.2f}%, PnL ${total_pnl:+.2f}") async def _handle_tp1(self, pos: Position, current_price: float): """Handle TP1: close 50%, move SL to BE.""" close_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO) if close_qty <= 0: close_qty = pos.remaining_quantity # fallback: close all result = self.trader.close_partial(pos.symbol, pos.side, close_qty) if not result: logger.error(f"Failed to close partial at TP1 for {pos.symbol}") return # Use actual fill price from Binance fill_price = result.get("fill_price") or current_price if fill_price != current_price: logger.info(f"TP1 close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}") # Update position pos.tp1_hit = True pos.remaining_quantity -= close_qty # Calculate partial PnL (with commission) using actual fill price pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side) pnl_usdt = close_qty * abs(fill_price - pos.entry_price) close_fee = close_qty * fill_price * TAKER_FEE_PCT / 100 pnl_usdt -= close_fee pos.realized_pnl += pnl_usdt # Move SL to breakeven pos.sl_price = pos.entry_price # BE = entry price log_event("TP1_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "price": fill_price, "closed_quantity": close_qty, "remaining_quantity": pos.remaining_quantity, "pnl_pct": round(pnl_pct, 2), "realized_pnl_usdt": round(pnl_usdt, 2), "new_sl_price": pos.sl_price, "new_sl_pct": SL_AFTER_TP1_PCT, }) msg = ( f"🎯 TP1 HIT: {pos.symbol} (+{TP1_PERCENT}%)\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: 50% ({close_qty})\n" f"πŸ’΅ +${pnl_usdt:.2f}\n" f"πŸ›‘ SL β†’ BE (${pos.entry_price:.6f})\n" f"ΠžΡΡ‚Π°Π»ΠΎΡΡŒ: {pos.remaining_quantity} β†’ TP2/TP3\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) logger.info(f"TP1 hit for {pos.symbol}: closed {close_qty}, SL β†’ BE") async def _handle_tp2(self, pos: Position, current_price: float): """Handle TP2: close 50% of remaining (25% of total), move SL to +1.5%.""" close_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO) if close_qty <= 0: close_qty = pos.remaining_quantity result = self.trader.close_partial(pos.symbol, pos.side, close_qty) if not result: logger.error(f"Failed to close partial at TP2 for {pos.symbol}") return # Use actual fill price from Binance fill_price = result.get("fill_price") or current_price if fill_price != current_price: logger.info(f"TP2 close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}") pos.tp2_hit = True pos.remaining_quantity -= close_qty # Calculate partial PnL (with commission) using actual fill price pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side) pnl_usdt = close_qty * abs(fill_price - pos.entry_price) close_fee = close_qty * fill_price * TAKER_FEE_PCT / 100 pnl_usdt -= close_fee pos.realized_pnl += pnl_usdt # Move SL to +1.5% if pos.side == "BUY": pos.sl_price = pos.entry_price * (1 + SL_AFTER_TP2_PCT / 100) else: pos.sl_price = pos.entry_price * (1 - SL_AFTER_TP2_PCT / 100) log_event("TP2_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "price": current_price, "closed_quantity": close_qty, "remaining_quantity": pos.remaining_quantity, "pnl_pct": round(pnl_pct, 2), "realized_pnl_usdt": round(pnl_usdt, 2), "new_sl_price": pos.sl_price, "new_sl_pct": SL_AFTER_TP2_PCT, }) msg = ( f"🎯🎯 TP2 HIT: {pos.symbol} (+{TP2_PERCENT}%)\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: Π΅Ρ‰Ρ‘ 25% ({close_qty})\n" f"πŸ’΅ +${pnl_usdt:.2f}\n" f"πŸ›‘ SL β†’ +{SL_AFTER_TP2_PCT}% (${pos.sl_price:.6f})\n" f"ΠžΡΡ‚Π°Π»ΠΎΡΡŒ: {pos.remaining_quantity} β†’ TP3\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) logger.info(f"TP2 hit for {pos.symbol}: closed {close_qty}, SL β†’ +{SL_AFTER_TP2_PCT}%") async def _handle_tp3(self, pos: Position, current_price: float): """Handle TP3: close everything remaining.""" result = self.trader.close_full(pos.symbol, pos.side) # Use actual fill price from Binance fill_price = current_price # fallback if result and result.get("fill_price"): fill_price = result["fill_price"] logger.info(f"TP3 close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}") # Calculate PnL (with commission) using actual fill price pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side) pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price) close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100 pnl_usdt -= close_fee total_pnl = pos.realized_pnl + pnl_usdt log_event("TP3_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "side": pos.side, "entry_price": pos.entry_price, "exit_price": fill_price, "mark_price": current_price, "closed_quantity": pos.remaining_quantity, "pnl_pct": round(pnl_pct, 2), "realized_pnl_usdt": round(pnl_usdt, 2), "total_trade_pnl_usdt": round(total_pnl, 2), }) msg = ( f"πŸ† TP3 FULL HIT: {pos.symbol} (+{TP3_PERCENT}%)\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Entry: ${pos.entry_price:.6f}\n" f"Exit: ${fill_price:.6f}\n" f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ PnL: ${total_pnl:+.2f}\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) del self.positions[pos.symbol] logger.info(f"TP3 full hit for {pos.symbol}: total PnL ${total_pnl:+.2f}") def _build_current_tps(self, pos: Position) -> list[tuple]: """Build list of (price, qty) for TPs that haven't been hit yet.""" tps = [] if not pos.tp1_hit: tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO) tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * TP2_CLOSE_RATIO) tp3_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity - tp1_qty - tp2_qty) if tp1_qty > 0: tps.append((pos.tp1_price, tp1_qty)) if tp2_qty > 0: tps.append((pos.tp2_price, tp2_qty)) if tp3_qty > 0: tps.append((pos.tp3_price, tp3_qty)) elif not pos.tp2_hit: tp2_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO) tp3_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity - tp2_qty) if tp2_qty > 0: tps.append((pos.tp2_price, tp2_qty)) if tp3_qty > 0: tps.append((pos.tp3_price, tp3_qty)) else: if pos.remaining_quantity > 0: tps.append((pos.tp3_price, pos.remaining_quantity)) return tps async def _check_exchange_orders(self, pos: Position, price: float): """Check exchange-side order fills and react. TP = regular LIMIT (queryable), SL = STOP_MARKET algo (NOT queryable). Detection pattern: - TP filled? β†’ check LIMIT order status (queryable) - SL fired? β†’ position gone on Binance + TP not filled After TP1/TP2: cancel_all + re-place SL + remaining TPs via replace_sl_and_tps(). """ # 0. Detect externally cancelled orders β†’ re-place all first_tp_id = pos.tp1_order_id or pos.tp2_order_id or pos.tp3_order_id if first_tp_id: st = self.trader.get_order_status(pos.symbol, first_tp_id) if st and st["status"] in ("CANCELED", "CANCELLED", "EXPIRED", "REJECTED"): logger.warning(f"WT {pos.symbol}: orders cancelled externally, re-placing") remaining_tps = self._build_current_tps(pos) orders = self.order_mgr.replace_sl_and_tps( pos.symbol, pos.side, pos.sl_price, pos.remaining_quantity, remaining_tps ) pos.sl_order_id = orders["sl_order_id"] ids = orders["tp_order_ids"] if not pos.tp1_hit: pos.tp1_order_id = ids[0] if len(ids) > 0 else None pos.tp2_order_id = ids[1] if len(ids) > 1 else None pos.tp3_order_id = ids[2] if len(ids) > 2 else None elif not pos.tp2_hit: pos.tp2_order_id = ids[0] if len(ids) > 0 else None pos.tp3_order_id = ids[1] if len(ids) > 1 else None else: pos.tp3_order_id = ids[0] if len(ids) > 0 else None await self.notify(f"πŸ”„ WT {pos.symbol}: ΠΎΡ€Π΄Π΅Ρ€Π° пСрСставлСны (Π±Ρ‹Π»ΠΈ ΠΎΡ‚ΠΌΠ΅Π½Π΅Π½Ρ‹)") logger.info(f"WT {pos.symbol}: re-placed SL + {len(ids)} TPs") return # 1. Check TP1 fill (regular LIMIT β€” queryable) if not pos.tp1_hit and pos.tp1_order_id: tp1_st = self.trader.get_order_status(pos.symbol, pos.tp1_order_id) if tp1_st and tp1_st["status"] == "FILLED": fill_price = tp1_st["fill_price"] if tp1_st["fill_price"] > 0 else pos.tp1_price close_qty = tp1_st["executedQty"] or self.trader.round_quantity( pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO) pos.tp1_hit = True pos.remaining_quantity -= close_qty pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side) pnl_usdt = close_qty * abs(fill_price - pos.entry_price) close_fee = close_qty * fill_price * MAKER_FEE_PCT / 100 # maker! pnl_usdt -= close_fee pos.realized_pnl += pnl_usdt # Move SL to BE + re-place remaining TPs new_sl = pos.entry_price pos.sl_price = new_sl # Build remaining TP levels remaining_tps = [] tp2_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO) tp3_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity - tp2_qty) if tp2_qty > 0: remaining_tps.append((pos.tp2_price, tp2_qty)) if tp3_qty > 0: remaining_tps.append((pos.tp3_price, tp3_qty)) orders = self.order_mgr.replace_sl_and_tps( pos.symbol, pos.side, new_sl, pos.remaining_quantity, remaining_tps ) pos.sl_order_id = orders["sl_order_id"] ids = orders["tp_order_ids"] pos.tp1_order_id = None # already filled pos.tp2_order_id = ids[0] if len(ids) > 0 else None pos.tp3_order_id = ids[1] if len(ids) > 1 else None log_event("TP1_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "price": fill_price, "closed_quantity": close_qty, "remaining_quantity": pos.remaining_quantity, "pnl_pct": round(pnl_pct, 2), "realized_pnl_usdt": round(pnl_usdt, 2), "new_sl_price": new_sl, "order_type": "EXCHANGE_LIMIT", }) msg = ( f"🎯 TP1 HIT: {pos.symbol} [LIMIT]\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: 50% ({close_qty})\n" f"πŸ’΅ +${pnl_usdt:.2f}\n" f"πŸ›‘ SL β†’ BE (${new_sl:.6f})\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) logger.info(f"WT TP1 (exchange): {pos.symbol} closed {close_qty}, SLβ†’BE") return # 2. Check TP2 fill if pos.tp1_hit and not pos.tp2_hit and pos.tp2_order_id: tp2_st = self.trader.get_order_status(pos.symbol, pos.tp2_order_id) if tp2_st and tp2_st["status"] == "FILLED": fill_price = tp2_st["fill_price"] if tp2_st["fill_price"] > 0 else pos.tp2_price close_qty = tp2_st["executedQty"] or self.trader.round_quantity( pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO) pos.tp2_hit = True pos.remaining_quantity -= close_qty pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side) pnl_usdt = close_qty * abs(fill_price - pos.entry_price) close_fee = close_qty * fill_price * MAKER_FEE_PCT / 100 pnl_usdt -= close_fee pos.realized_pnl += pnl_usdt # Move SL to +1.5% + re-place remaining TP3 if pos.side == "BUY": new_sl = pos.entry_price * (1 + SL_AFTER_TP2_PCT / 100) else: new_sl = pos.entry_price * (1 - SL_AFTER_TP2_PCT / 100) pos.sl_price = new_sl remaining_tps = [] if pos.remaining_quantity > 0: remaining_tps.append((pos.tp3_price, pos.remaining_quantity)) orders = self.order_mgr.replace_sl_and_tps( pos.symbol, pos.side, new_sl, pos.remaining_quantity, remaining_tps ) pos.sl_order_id = orders["sl_order_id"] ids = orders["tp_order_ids"] pos.tp2_order_id = None # already filled pos.tp3_order_id = ids[0] if len(ids) > 0 else None log_event("TP2_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "price": fill_price, "closed_quantity": close_qty, "remaining_quantity": pos.remaining_quantity, "pnl_pct": round(pnl_pct, 2), "realized_pnl_usdt": round(pnl_usdt, 2), "new_sl_price": new_sl, "order_type": "EXCHANGE_LIMIT", }) msg = ( f"🎯🎯 TP2 HIT: {pos.symbol} [LIMIT]\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: Π΅Ρ‰Ρ‘ 25% ({close_qty})\n" f"πŸ’΅ +${pnl_usdt:.2f}\n" f"πŸ›‘ SL β†’ +{SL_AFTER_TP2_PCT}% (${new_sl:.6f})\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) logger.info(f"WT TP2 (exchange): {pos.symbol} closed {close_qty}") return # 3. Check TP3 fill if pos.tp2_hit and pos.tp3_order_id: tp3_st = self.trader.get_order_status(pos.symbol, pos.tp3_order_id) if tp3_st and tp3_st["status"] == "FILLED": fill_price = tp3_st["fill_price"] if tp3_st["fill_price"] > 0 else pos.tp3_price pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side) pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price) close_fee = pos.remaining_quantity * fill_price * MAKER_FEE_PCT / 100 pnl_usdt -= close_fee total_pnl = pos.realized_pnl + pnl_usdt # Cancel SL algo order via cancel_all self.order_mgr.cancel_all_for_symbol(pos.symbol) log_event("TP3_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "entry_price": pos.entry_price, "exit_price": fill_price, "total_trade_pnl_usdt": round(total_pnl, 2), "order_type": "EXCHANGE_LIMIT", }) msg = ( f"πŸ† TP3 FULL HIT: {pos.symbol} [LIMIT]\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Entry: ${pos.entry_price:.6f}\n" f"Exit: ${fill_price:.6f}\n" f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ PnL: ${total_pnl:+.2f}\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) del self.positions[pos.symbol] logger.info(f"WT TP3 (exchange): {pos.symbol} total PnL ${total_pnl:+.2f}") return # 4. Position gone? Either SL fired (STOP_MARKET algo) or manual close binance_pos = self.trader.get_position(pos.symbol) if not binance_pos: # Clean up all orders (both regular LIMIT and algo STOP_MARKET) self.order_mgr.cancel_all_for_symbol(pos.symbol) # Check if any TP was filled between checks (race condition) any_tp_filled = False for tp_id, tp_price, label in [ (pos.tp1_order_id, pos.tp1_price, "TP1"), (pos.tp2_order_id, pos.tp2_price, "TP2"), (pos.tp3_order_id, pos.tp3_price, "TP3"), ]: if tp_id: st = self.trader.get_order_status(pos.symbol, tp_id) if st and st["status"] == "FILLED": any_tp_filled = True logger.info(f"WT {pos.symbol}: {label} was filled (detected via position-gone)") if not any_tp_filled: # SL fired via STOP_MARKET algo order fill_price = pos.sl_price # best estimate (algo orders don't give fill info) pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side) pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price) if pnl_pct < 0: pnl_usdt = -pnl_usdt close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100 pnl_usdt -= close_fee total_pnl = pos.realized_pnl + pnl_usdt sl_type = "BE" if pos.tp1_hit else "SL" emoji = "🟑" if pos.tp1_hit else "πŸ”΄" log_event("SL_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "side": pos.side, "entry_price": pos.entry_price, "exit_price": fill_price, "mark_price": price, "pnl_pct": round(pnl_pct, 2), "total_trade_pnl_usdt": round(total_pnl, 2), "tp1_was_hit": pos.tp1_hit, "tp2_was_hit": pos.tp2_hit, "order_type": "EXCHANGE_STOP", }) msg = ( f"{emoji} {sl_type} HIT: {pos.symbol} [STOP]\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Entry: ${pos.entry_price:.6f}\n" f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n" f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ: ${total_pnl:+.2f}\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) else: # TP was filled but we missed it β€” log as manual/race pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side) log_event("MANUAL_CLOSE", { "trade_id": pos.trade_id, "symbol": pos.symbol, "exit_price": price, "pnl_pct": round(pnl_pct, 2), "note": "tp_filled_race_condition", }) await self.notify( f"πŸ‘‹ {pos.symbol} Π·Π°ΠΊΡ€Ρ‹Ρ‚Π° (TP fill + position gone)\n" f"ПослСдняя Ρ†Π΅Π½Π°: ${price:.6f} ({pnl_pct:+.2f}%)" ) del self.positions[pos.symbol] async def check_position(self, pos: Position): """ Check a single position against current mark price. Order of checks: SL β†’ TP3 β†’ TP2 β†’ TP1 (safety first). """ price = self.trader.get_mark_price(pos.symbol) if price is None: return # === Exchange-side order mode === if pos.use_exchange_orders: await self._check_exchange_orders(pos, price) return # === Polling fallback mode === # Safety: detect if position was closed externally (manually on exchange) binance_pos = self.trader.get_position(pos.symbol) if not binance_pos: pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side) logger.warning(f"Position {pos.symbol} no longer exists on Binance β€” closed externally") log_event("MANUAL_CLOSE", { "trade_id": pos.trade_id, "symbol": pos.symbol, "side": pos.side, "exit_price": price, "pnl_pct": round(pnl_pct, 2), "note": "closed_externally_on_exchange", "tp1_was_hit": pos.tp1_hit, "tp2_was_hit": pos.tp2_hit, }) await self.notify( f"πŸ‘‹ {pos.symbol} Π·Π°ΠΊΡ€Ρ‹Ρ‚Π° Π½Π° Π±ΠΈΡ€ΠΆΠ΅ Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ\n" f"Π£Π±ΠΈΡ€Π°ΡŽ ΠΈΠ· ΠΌΠΎΠ½ΠΈΡ‚ΠΎΡ€ΠΈΠ½Π³Π°.\n" f"ПослСдняя Ρ†Π΅Π½Π°: ${price:.6f} ({pnl_pct:+.2f}%)" ) del self.positions[pos.symbol] return # Safety: if entry_price is 0, try to recover from Binance if pos.entry_price <= 0: binance_pos = self.trader.get_position(pos.symbol) if binance_pos and binance_pos["entry_price"] > 0: pos.entry_price = binance_pos["entry_price"] levels = calculate_levels(pos.entry_price, pos.side) pos.sl_price = levels["sl"] pos.tp1_price = levels["tp1"] pos.tp2_price = levels["tp2"] pos.tp3_price = levels["tp3"] logger.info(f"Recovered entry price for {pos.symbol}: ${pos.entry_price:.6f}") await self.notify( f"πŸ”§ Recovered {pos.symbol} entry: ${pos.entry_price:.6f}\n" f"SL: ${pos.sl_price:.6f} | TP1: ${pos.tp1_price:.6f}" ) else: logger.warning(f"Cannot recover entry price for {pos.symbol}, skipping check") return is_long = pos.side == "BUY" # 1. Check SL sl_hit = (price <= pos.sl_price) if is_long else (price >= pos.sl_price) if sl_hit: await self._handle_sl(pos, price) return # 2. Check TP3 (close all remaining) tp3_hit = (price >= pos.tp3_price) if is_long else (price <= pos.tp3_price) if tp3_hit: await self._handle_tp3(pos, price) return # 3. Check TP2 (if TP1 already hit) if pos.tp1_hit and not pos.tp2_hit: tp2_hit = (price >= pos.tp2_price) if is_long else (price <= pos.tp2_price) if tp2_hit: await self._handle_tp2(pos, price) return # 4. Check TP1 if not pos.tp1_hit: tp1_hit = (price >= pos.tp1_price) if is_long else (price <= pos.tp1_price) if tp1_hit: await self._handle_tp1(pos, price) return async def close_trade_manual(self, symbol: str) -> bool: """Manually close a position (via /close command).""" if symbol not in self.positions: return False pos = self.positions[symbol] price = self.trader.get_mark_price(symbol) if not price: return False # Cancel exchange orders before market close if pos.use_exchange_orders: self.order_mgr.cancel_all_for_symbol(symbol) result = self.trader.close_full(symbol, pos.side) if pos.side == "BUY": pnl_pct = ((price - pos.entry_price) / pos.entry_price) * 100 else: pnl_pct = ((pos.entry_price - price) / pos.entry_price) * 100 pnl_usdt = pos.remaining_quantity * abs(price - pos.entry_price) if pnl_pct < 0: pnl_usdt = -pnl_usdt close_fee = pos.remaining_quantity * price * TAKER_FEE_PCT / 100 pnl_usdt -= close_fee total_pnl = pos.realized_pnl + pnl_usdt log_event("MANUAL_CLOSE", { "trade_id": pos.trade_id, "symbol": symbol, "exit_price": price, "pnl_pct": round(pnl_pct, 2), "realized_pnl_usdt": round(pnl_usdt, 2), "total_trade_pnl_usdt": round(total_pnl, 2), }) await self.notify( f"πŸ– Manual close: {symbol}\n" f"Exit: ${price:.6f} ({pnl_pct:+.2f}%)\n" f"πŸ’° PnL: ${total_pnl:+.2f}" ) del self.positions[symbol] return True def recover_positions(self): """ On startup, check for open positions on Binance and reconstruct Position objects from trade log. """ open_trades = get_open_trades() if not open_trades: logger.info("No open trades to recover") return for trade in open_trades: symbol = trade.get("symbol", "") if not symbol: continue # Check if position actually exists on Binance binance_pos = self.trader.get_position(symbol) if not binance_pos: logger.info(f"Trade log has {symbol} but no Binance position β€” skipping") continue entry_price = trade.get("entry_price", binance_pos["entry_price"]) side = trade.get("side", binance_pos["side"]) levels = calculate_levels(entry_price, side) # Check TP state from log tp_state = get_tp_state(symbol) pos = Position( symbol=symbol, side=side, entry_price=entry_price, total_quantity=float(trade.get("quantity", binance_pos["quantity"])), remaining_quantity=binance_pos["quantity"], sl_price=levels["sl"], tp1_price=levels["tp1"], tp2_price=levels["tp2"], tp3_price=levels["tp3"], tp1_hit=tp_state["tp1_hit"], tp2_hit=tp_state["tp2_hit"], opened_at=trade.get("timestamp", ""), trade_id=trade.get("trade_id", symbol), ) # Adjust SL based on TP state if pos.tp2_hit: if side == "BUY": pos.sl_price = entry_price * (1 + SL_AFTER_TP2_PCT / 100) else: pos.sl_price = entry_price * (1 - SL_AFTER_TP2_PCT / 100) elif pos.tp1_hit: pos.sl_price = entry_price # BE # Recover or re-place exchange orders if WT_USE_EXCHANGE_ORDERS: # Cancel any stale orders from before restart self.order_mgr.cancel_all_for_symbol(symbol) import time; time.sleep(2.0) # Let Binance fully process algo order cancellation # Re-place orders based on current TP state self._place_recovery_orders(pos) logger.info(f"Re-placed exchange orders for recovered WT {symbol}") self.positions[symbol] = pos logger.info( f"Recovered position: {side} {symbol} @ {entry_price}, " f"remaining={pos.remaining_quantity}, tp1={pos.tp1_hit}, tp2={pos.tp2_hit}, " f"exchange_orders={'YES' if pos.use_exchange_orders else 'NO'}" ) async def monitor_loop(self): """Price monitor loop β€” check all positions every N seconds.""" logger.info(f"Position monitor started (interval: {PRICE_CHECK_INTERVAL}s)") while True: try: # Copy keys to avoid dict-changed-during-iteration symbols = list(self.positions.keys()) for symbol in symbols: pos = self.positions.get(symbol) if pos: await self.check_position(pos) except Exception as e: logger.error(f"Error in monitor loop: {e}", exc_info=True) await asyncio.sleep(PRICE_CHECK_INTERVAL) def format_positions_message(self) -> str: """Format open positions for Telegram /positions command.""" if not self.positions: return "πŸ“­ НСт ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚Ρ‹Ρ… ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΉ" lines = ["πŸ“Š ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚Ρ‹Π΅ ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ:\n━━━━━━━━━━━━━━━━━━━━"] for symbol, pos in self.positions.items(): price = self.trader.get_mark_price(symbol) if not price: price = pos.entry_price if pos.entry_price > 0: if pos.side == "BUY": pnl_pct = ((price - pos.entry_price) / pos.entry_price) * 100 else: pnl_pct = ((pos.entry_price - price) / pos.entry_price) * 100 else: pnl_pct = 0 direction = "LONG" if pos.side == "BUY" else "SHORT" emoji = "🟒" if pnl_pct >= 0 else "πŸ”΄" tp_status = "" if pos.tp2_hit: tp_status = "βœ…βœ… TP1+TP2" elif pos.tp1_hit: tp_status = "βœ… TP1" else: tp_status = "⏳ waiting" lines.append( f"\n{emoji} {direction} {symbol}\n" f" Entry: ${pos.entry_price:.6f}\n" f" Now: ${price:.6f} ({pnl_pct:+.2f}%)\n" f" SL: ${pos.sl_price:.6f}\n" f" Status: {tp_status}\n" f" Remaining: {pos.remaining_quantity}" ) lines.append("\n━━━━━━━━━━━━━━━━━━━━") return "\n".join(lines)