← Назад
""" Gerchik Position Manager β€” Position lifecycle with limit orders. Manages Gerchik Level strategy positions: - Limit order entry - Stop-Limit SL with market fallback - Limit TP1/TP2/TP3 with partial closes - SL to BE after 2x SL in profit - Trail SL behind new levels """ import asyncio import logging import os import time from dataclasses import dataclass, field from datetime import datetime, timezone, timedelta from typing import Optional, Callable from trader import BinanceFuturesTrader from trade_log import log_event from order_placer import ExchangeOrderManager from gerchik_config import ( GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE, GERCHIK_MAX_POSITIONS, GERCHIK_CHECK_INTERVAL, GERCHIK_SCAN_INTERVAL, GERCHIK_TP1_CLOSE_PCT, GERCHIK_TP2_CLOSE_PCT, GERCHIK_BE_TRIGGER_STOPLOSS_MULT, GERCHIK_USE_LIMIT_ORDERS, GERCHIK_LIMIT_FALLBACK_SEC, GERCHIK_USE_EXCHANGE_ORDERS, TAKER_FEE_PCT, MAKER_FEE_PCT, MODEL_LABELS, ) from gerchik_models import GerchikSignal logger = logging.getLogger(__name__) VANCOUVER_TZ = timezone(timedelta(hours=-7)) def now_van() -> datetime: return datetime.now(VANCOUVER_TZ) def safe_pnl_pct(entry: float, exit_p: float, side: str) -> float: if entry == 0: return 0 if side == "BUY": return ((exit_p - entry) / entry) * 100 else: return ((entry - exit_p) / entry) * 100 @dataclass class GerchikPosition: """A Gerchik level trade.""" symbol: str side: str model: str # A, B, C, D entry_price: float quantity: float total_quantity: float remaining_quantity: float sl_price: float tp1_price: float tp2_price: float tp3_price: float level_price: float level_strength: float trade_id: str opened_at: datetime tp1_hit: bool = False tp2_hit: bool = False realized_pnl: float = 0.0 # Accumulated from partial closes be_moved: bool = False # Whether SL was moved to BE signal_data: dict = field(default_factory=dict) # 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 @property def age_minutes(self) -> float: return (now_van() - self.opened_at).total_seconds() / 60 class GerchikManager: """Manages Gerchik Level strategy positions.""" def __init__(self, trader: BinanceFuturesTrader, notify_fn: Callable, tmm=None): self.trader = trader self.notify = notify_fn self.tmm = tmm self.order_mgr = ExchangeOrderManager(trader) self.positions: dict[str, GerchikPosition] = {} self.stats = {"wins": 0, "losses": 0, "total_pnl": 0.0} self._cooldowns: dict[str, datetime] = {} # symbol -> last close time async def _open_with_limit(self, symbol: str, side: str, qty: float, limit_price: float) -> dict | None: """ Place limit order, wait for fill, fallback to market if timeout. Returns dict with fill_price, quantity, and is_maker flag. """ order = self.trader.open_limit_order(symbol, side, qty, limit_price) if not order: logger.warning(f"Limit order failed for {symbol}, trying market") r = self.trader.open_position(symbol, side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE) if r: r["is_maker"] = False return r order_id = order["orderId"] status = order["status"] # If filled immediately (unlikely but possible) if status == "FILLED": fp = order.get("fill_price") or limit_price logger.info(f"Limit order filled immediately: {symbol} @ ${fp:.6f}") return {"fill_price": fp, "quantity": order["quantity"], "is_maker": True} # Poll for fill within timeout elapsed = 0 poll_interval = 1 # 1 second while elapsed < GERCHIK_LIMIT_FALLBACK_SEC: await asyncio.sleep(poll_interval) elapsed += poll_interval check = self.trader.get_order_status(symbol, order_id) if not check: continue if check["status"] == "FILLED": fp = check["fill_price"] if check["fill_price"] > 0 else limit_price logger.info(f"Limit filled after {elapsed}s: {symbol} @ ${fp:.6f}") return {"fill_price": fp, "quantity": check["executedQty"], "is_maker": True} if check["status"] in ("CANCELED", "EXPIRED", "REJECTED"): logger.warning(f"Limit order {check['status']} for {symbol}, trying market") r = self.trader.open_position(symbol, side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE) if r: r["is_maker"] = False return r # Timeout β€” cancel limit, go market logger.info(f"Limit timeout ({GERCHIK_LIMIT_FALLBACK_SEC}s) for {symbol}, cancel β†’ market") # Check one more time (might have filled during cancel) check = self.trader.get_order_status(symbol, order_id) if check and check["status"] == "FILLED": fp = check["fill_price"] if check["fill_price"] > 0 else limit_price return {"fill_price": fp, "quantity": check["executedQty"], "is_maker": True} # Partially filled? if check and check["executedQty"] > 0: # Cancel remaining, keep what we got self.trader.cancel_order(symbol, order_id) fp = check["fill_price"] if check["fill_price"] > 0 else limit_price logger.info(f"Limit partial fill {check['executedQty']}/{check['origQty']} for {symbol}") return {"fill_price": fp, "quantity": check["executedQty"], "is_maker": True} # Nothing filled β€” cancel and market self.trader.cancel_order(symbol, order_id) r = self.trader.open_position(symbol, side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE) if r: r["is_maker"] = False return r async def open_trade(self, signal: GerchikSignal) -> bool: """Open a new Gerchik level trade (limit order with market fallback).""" symbol = signal.symbol if symbol in self.positions: logger.debug(f"Already have position in {symbol}") return False # Double-check: verify no position exists on Binance (prevents duplicates on restart) existing = self.trader.get_position(symbol) if existing: logger.warning(f"GR {symbol}: position exists on Binance ({existing['quantity']} qty) but not in manager β€” skip open") return False if len(self.positions) >= GERCHIK_MAX_POSITIONS: logger.debug(f"Max Gerchik positions reached ({GERCHIK_MAX_POSITIONS})") return False # Cooldown: don't re-enter same symbol within 15 min if symbol in self._cooldowns: elapsed = (now_van() - self._cooldowns[symbol]).total_seconds() if elapsed < 900: # 15 min return False # Set leverage and margin type first if not self.trader.set_leverage(symbol, GERCHIK_LEVERAGE): return False self.trader.set_margin_type(symbol, "ISOLATED") # May fail if position exists, non-critical # Get current price for qty calculation price = self.trader.get_mark_price(symbol) if not price: return False qty = self.trader.calculate_quantity(symbol, GERCHIK_SIZE_USDT, price, GERCHIK_LEVERAGE) if qty <= 0: logger.error(f"Invalid quantity for {symbol}") return False if GERCHIK_USE_LIMIT_ORDERS: # Limit price: slightly better than current for sniper entry # BUY β†’ bid slightly below current | SELL β†’ ask slightly above current tick_improve = price * 0.0002 # 0.02% improvement if signal.side == "BUY": limit_price = price - tick_improve else: limit_price = price + tick_improve result = await self._open_with_limit(symbol, signal.side, qty, limit_price) else: result = self.trader.open_position( symbol, signal.side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE ) if not result: logger.error(f"Failed to open Gerchik {signal.side} {symbol}") return False entry = result["fill_price"] qty = result["quantity"] # Recalculate SL/TP from actual entry (not from signal's current_price) if signal.side == "BUY": sl_dist = entry - signal.sl_price tp1 = entry + sl_dist * (signal.tp1_price - signal.entry_price) / (signal.entry_price - signal.sl_price) tp2 = entry + sl_dist * (signal.tp2_price - signal.entry_price) / (signal.entry_price - signal.sl_price) tp3 = entry + sl_dist * (signal.tp3_price - signal.entry_price) / (signal.entry_price - signal.sl_price) else: sl_dist = signal.sl_price - entry tp1 = entry - sl_dist * (signal.entry_price - signal.tp1_price) / (signal.sl_price - signal.entry_price) tp2 = entry - sl_dist * (signal.entry_price - signal.tp2_price) / (signal.sl_price - signal.entry_price) tp3 = entry - sl_dist * (signal.entry_price - signal.tp3_price) / (signal.sl_price - signal.entry_price) # Open fee (maker if limit filled, taker if market fallback) is_maker = result.get("is_maker", False) entry_fee_pct = MAKER_FEE_PCT if is_maker else TAKER_FEE_PCT open_fee = qty * entry * entry_fee_pct / 100 order_type = "LIMIT" if is_maker else "MARKET" now = now_van() trade_id = f"GR_{symbol}_{now.strftime('%Y%m%d_%H%M%S')}" pos = GerchikPosition( symbol=symbol, side=signal.side, model=signal.model, entry_price=entry, quantity=qty, total_quantity=qty, remaining_quantity=qty, sl_price=signal.sl_price, tp1_price=tp1, tp2_price=tp2, tp3_price=tp3, level_price=signal.level.price, level_strength=signal.level.strength, trade_id=trade_id, opened_at=now, realized_pnl=-open_fee, signal_data={ "model": signal.model, "pattern": signal.candle_pattern, "trend": signal.trend, "volume_ratio": signal.volume_ratio, "level_type": signal.level.level_type, "level_touches": signal.level.touches, }, ) # Place exchange-side TP/SL orders if GERCHIK_USE_EXCHANGE_ORDERS: self._place_initial_orders(pos) self.positions[symbol] = pos # Log log_event("GR_ENTRY", { "trade_id": trade_id, "symbol": symbol, "side": signal.side, "model": signal.model, "model_label": MODEL_LABELS.get(signal.model, "?"), "entry_price": entry, "quantity": qty, "leverage": GERCHIK_LEVERAGE, "margin_usdt": GERCHIK_SIZE_USDT, "sl_price": signal.sl_price, "tp1_price": tp1, "tp2_price": tp2, "tp3_price": tp3, "level_price": signal.level.price, "level_strength": signal.level.strength, "level_type": signal.level.level_type, "level_touches": signal.level.touches, "trend": signal.trend, "pattern": signal.candle_pattern, "order_type": order_type, "entry_fee_pct": entry_fee_pct, }) direction = "LONG" if signal.side == "BUY" else "SHORT" model_label = MODEL_LABELS.get(signal.model, "?") level_type = signal.level.level_type sl_pct = signal.sl_distance_pct notional = qty * entry msg = ( f"πŸ“ GERCHIK МодСль {signal.model} ({model_label}): {direction} {symbol}\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"πŸ“Š Π£Ρ€ΠΎΠ²Π΅Π½ΡŒ: ${signal.level.price:.6f} ({signal.level.touches} кас., {level_type})\n" f"πŸ’ͺ Π‘ΠΈΠ»Π°: {signal.level.strength:.0f}/100\n" f"πŸ’° Entry: ${entry:.6f} ({order_type})\n" f"πŸ“Š Size: {qty} ({GERCHIK_LEVERAGE}x, ${notional:.2f})\n" f"πŸ›‘ SL: ${signal.sl_price:.6f} (-{sl_pct:.2f}%)\n" f"🎯 TP1: ${tp1:.6f} (3:1) β†’ 50%\n" f"🎯 TP2: ${tp2:.6f} (4:1) β†’ 25%\n" f"🎯 TP3: ${tp3:.6f} (5:1) β†’ 25%\n" f"πŸ“ˆ Π’Ρ€Π΅Π½Π΄: {signal.trend} | πŸ“ {signal.candle_pattern}\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) logger.info(f"Gerchik trade opened: {direction} {symbol} model={signal.model} level=${signal.level.price:.6f}") # TMM journal: tag trade if self.tmm: model_name = MODEL_LABELS.get(signal.model, signal.model) self.tmm.on_trade_opened(symbol, signal.side, "GERCHIK", model=signal.model, signal_info=f"[GR-{signal.model}] {direction} {model_name} @ level ${signal.level.price:.6f}") return True def _place_initial_orders(self, pos: GerchikPosition): """Place TP1/TP2/TP3 + SL orders on Binance after position opens.""" tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT) tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * GERCHIK_TP2_CLOSE_PCT) 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"GR {pos.symbol}: exchange orders placed (SL #{pos.sl_order_id}, TP1 #{pos.tp1_order_id})") else: logger.warning(f"GR {pos.symbol}: exchange orders failed, using polling fallback") def _place_recovery_orders(self, pos: GerchikPosition): """Place exchange orders for a recovered position (respects TP state).""" remaining = pos.remaining_quantity tp_levels = [] if pos.tp2_hit: tp_levels.append((pos.tp3_price, remaining)) elif pos.tp1_hit: tp2_qty = self.trader.round_quantity(pos.symbol, remaining * GERCHIK_TP2_CLOSE_PCT) 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: tp1_qty = self.trader.round_quantity(pos.symbol, remaining * GERCHIK_TP1_CLOSE_PCT) tp2_qty = self.trader.round_quantity(pos.symbol, (remaining - tp1_qty) * GERCHIK_TP2_CLOSE_PCT) 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"] 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"GR {pos.symbol}: recovery orders placed (SL #{pos.sl_order_id}, TPs: {ids})") else: logger.warning(f"GR {pos.symbol}: recovery orders failed, using polling fallback") async def _handle_sl(self, pos: GerchikPosition, current_price: float): """Handle stop loss hit.""" result = self.trader.close_full(pos.symbol, pos.side) fill_price = current_price if result and result.get("fill_price"): fill_price = result["fill_price"] logger.info(f"GR SL {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}") 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 self.stats["losses"] += 1 self.stats["total_pnl"] += total_pnl log_event("GR_SL_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "side": pos.side, "model": pos.model, "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, "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, }) sl_type = "BE" if pos.tp1_hit else "SL" emoji = "🟑" if pos.tp1_hit else "πŸ”΄" slip_line = "" if current_price and 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} GR {sl_type}: {pos.symbol} (МодСль {pos.model})\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Entry: ${pos.entry_price:.6f}\n" f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n" f"{slip_line}" f"πŸ’΅ PnL: ${total_pnl:+.2f}\n" f"πŸ“Š Π£Ρ€ΠΎΠ²Π΅Π½ΡŒ: ${pos.level_price:.6f}\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) self._cooldowns[pos.symbol] = now_van() del self.positions[pos.symbol] logger.info(f"GR SL: {pos.symbol} {pnl_pct:+.2f}% ${total_pnl:+.2f}") async def _handle_tp1(self, pos: GerchikPosition, current_price: float): """Handle TP1: close 50%, SL β†’ BE.""" close_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT) 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 GR TP1 for {pos.symbol}") return fill_price = result.get("fill_price") or current_price 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 * TAKER_FEE_PCT / 100 pnl_usdt -= close_fee pos.realized_pnl += pnl_usdt # Move SL to breakeven pos.sl_price = pos.entry_price log_event("GR_TP1_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "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, }) msg = ( f"🎯 GR TP1: {pos.symbol} (МодСль {pos.model})\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"GR TP1: {pos.symbol} closed {close_qty}, SLβ†’BE") async def _handle_tp2(self, pos: GerchikPosition, current_price: float): """Handle TP2: close 50% of remaining, SL β†’ +1.5 SL.""" close_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * GERCHIK_TP2_CLOSE_PCT) 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 GR TP2 for {pos.symbol}") return fill_price = result.get("fill_price") or current_price 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 * TAKER_FEE_PCT / 100 pnl_usdt -= close_fee pos.realized_pnl += pnl_usdt # Move SL to +1.5 SL distance (lock in profit) sl_dist = abs(pos.entry_price - pos.level_price) if pos.side == "BUY": pos.sl_price = pos.entry_price + sl_dist * 1.5 else: pos.sl_price = pos.entry_price - sl_dist * 1.5 log_event("GR_TP2_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "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, }) msg = ( f"🎯🎯 GR TP2: {pos.symbol} (МодСль {pos.model})\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: Π΅Ρ‰Ρ‘ 25% ({close_qty})\n" f"πŸ’΅ +${pnl_usdt:.2f}\n" f"πŸ›‘ SL β†’ ${pos.sl_price:.6f} (Π»ΠΎΠΊ ΠΏΡ€ΠΎΡ„ΠΈΡ‚Π°)\n" f"ΠžΡΡ‚Π°Π»ΠΎΡΡŒ: {pos.remaining_quantity} β†’ TP3\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) logger.info(f"GR TP2: {pos.symbol} closed {close_qty}") async def _handle_tp3(self, pos: GerchikPosition, current_price: float): """Handle TP3: close everything remaining.""" result = self.trader.close_full(pos.symbol, pos.side) fill_price = current_price if result and result.get("fill_price"): fill_price = result["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 self.stats["wins"] += 1 self.stats["total_pnl"] += total_pnl log_event("GR_TP3_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "entry_price": pos.entry_price, "exit_price": fill_price, "pnl_pct": round(pnl_pct, 2), "total_trade_pnl_usdt": round(total_pnl, 2), }) msg = ( f"πŸ† GR TP3 FULL: {pos.symbol} (МодСль {pos.model})\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) self._cooldowns[pos.symbol] = now_van() del self.positions[pos.symbol] logger.info(f"GR TP3: {pos.symbol} total PnL ${total_pnl:+.2f}") def _build_current_tps(self, pos: GerchikPosition) -> 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 * GERCHIK_TP1_CLOSE_PCT) tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * GERCHIK_TP2_CLOSE_PCT) 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 * GERCHIK_TP2_CLOSE_PCT) 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: GerchikPosition, price: float): """Check exchange-side order fills for Gerchik position. 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(). """ is_long = pos.side == "BUY" # 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"GR {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"πŸ”„ GR {pos.symbol}: ΠΎΡ€Π΄Π΅Ρ€Π° пСрСставлСны (Π±Ρ‹Π»ΠΈ ΠΎΡ‚ΠΌΠ΅Π½Π΅Π½Ρ‹)") logger.info(f"GR {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 * GERCHIK_TP1_CLOSE_PCT) pos.tp1_hit = True pos.remaining_quantity -= close_qty 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 # SL β†’ BE + re-place remaining TPs new_sl = pos.entry_price pos.sl_price = new_sl remaining_tps = [] tp2_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * GERCHIK_TP2_CLOSE_PCT) 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("GR_TP1_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "price": fill_price, "closed_quantity": close_qty, "new_sl_price": new_sl, "order_type": "EXCHANGE_LIMIT", }) msg = ( f"🎯 GR TP1: {pos.symbol} (М{pos.model}) [LIMIT]\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: 50% ({close_qty})\nπŸ’΅ +${pnl_usdt:.2f}\n" f"πŸ›‘ SL β†’ BE (${new_sl:.6f})\n━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) 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 * GERCHIK_TP2_CLOSE_PCT) pos.tp2_hit = True pos.remaining_quantity -= close_qty 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 # SL β†’ entry + 1.5x SL distance + re-place remaining TP3 sl_dist = abs(pos.entry_price - pos.level_price) if pos.side == "BUY": new_sl = pos.entry_price + sl_dist * 1.5 else: new_sl = pos.entry_price - sl_dist * 1.5 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("GR_TP2_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "price": fill_price, "closed_quantity": close_qty, "new_sl_price": new_sl, "order_type": "EXCHANGE_LIMIT", }) msg = ( f"🎯🎯 GR TP2: {pos.symbol} (М{pos.model}) [LIMIT]\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: 25% ({close_qty})\nπŸ’΅ +${pnl_usdt:.2f}\n" f"πŸ›‘ SL β†’ ${new_sl:.6f}\n━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) 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_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) self.stats["wins"] += 1 self.stats["total_pnl"] += total_pnl log_event("GR_TP3_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "entry_price": pos.entry_price, "exit_price": fill_price, "total_trade_pnl_usdt": round(total_pnl, 2), "order_type": "EXCHANGE_LIMIT", }) msg = ( f"πŸ† GR TP3: {pos.symbol} (М{pos.model}) [LIMIT]\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ PnL: ${total_pnl:+.2f}\n━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) self._cooldowns[pos.symbol] = now_van() del self.positions[pos.symbol] return # 4. BE move (polling-based trailing β€” acceptable, doesn't need order query) if not pos.tp1_hit and not pos.be_moved: sl_dist = abs(pos.entry_price - pos.level_price) be_trigger = pos.entry_price + sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT if is_long \ else pos.entry_price - sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT if (price >= be_trigger) if is_long else (price <= be_trigger): new_sl = pos.entry_price pos.sl_price = new_sl pos.be_moved = True # Build remaining TP levels for re-placement remaining_tps = [] tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT) tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * GERCHIK_TP2_CLOSE_PCT) tp3_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity - tp1_qty - tp2_qty) if pos.tp1_order_id and tp1_qty > 0: remaining_tps.append((pos.tp1_price, tp1_qty)) if pos.tp2_order_id and tp2_qty > 0: remaining_tps.append((pos.tp2_price, tp2_qty)) if pos.tp3_order_id and 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"] idx = 0 if pos.tp1_order_id: pos.tp1_order_id = ids[idx] if idx < len(ids) else None idx += 1 if pos.tp2_order_id: pos.tp2_order_id = ids[idx] if idx < len(ids) else None idx += 1 if pos.tp3_order_id: pos.tp3_order_id = ids[idx] if idx < len(ids) else None logger.info(f"GR BE (exchange): {pos.symbol} SL moved to BE") # 5. 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, label in [ (pos.tp1_order_id, "TP1"), (pos.tp2_order_id, "TP2"), (pos.tp3_order_id, "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"GR {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 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 if pnl_pct >= 0: self.stats["wins"] += 1 else: self.stats["losses"] += 1 self.stats["total_pnl"] += total_pnl sl_type = "BE" if pos.tp1_hit else "SL" emoji = "🟑" if pos.tp1_hit else "πŸ”΄" log_event("GR_SL_HIT", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "entry_price": pos.entry_price, "exit_price": fill_price, "pnl_pct": round(pnl_pct, 2), "total_trade_pnl_usdt": round(total_pnl, 2), "order_type": "EXCHANGE_STOP", }) msg = ( f"{emoji} GR {sl_type}: {pos.symbol} (М{pos.model}) [STOP]\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Entry: ${pos.entry_price:.6f}\nExit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n" f"πŸ’΅ PnL: ${total_pnl:+.2f}\n━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) else: pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side) log_event("GR_MANUAL_CLOSE", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "exit_price": price, "pnl_pct": round(pnl_pct, 2), "note": "tp_filled_race_condition", }) await self.notify( f"πŸ‘‹ GR {pos.symbol} Π·Π°ΠΊΡ€Ρ‹Ρ‚ (TP fill + position gone)") self._cooldowns[pos.symbol] = now_van() del self.positions[pos.symbol] async def check_position(self, pos: GerchikPosition): """Check a single position: exchange orders or polling fallback.""" price = self.trader.get_mark_price(pos.symbol) if not price: 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 binance_pos = self.trader.get_position(pos.symbol) if not binance_pos: pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side) pnl_usdt = pos.remaining_quantity * abs(price - pos.entry_price) if pnl_pct < 0: pnl_usdt = -pnl_usdt total_pnl = pos.realized_pnl + pnl_usdt log_event("GR_MANUAL_CLOSE", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "exit_price": price, "pnl_pct": round(pnl_pct, 2), "total_pnl_usdt": round(total_pnl, 2), }) await self.notify( f"πŸ‘‹ GR Manual: {pos.symbol} Π·Π°ΠΊΡ€Ρ‹Ρ‚ Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ\n" f"PnL: ${total_pnl:+.2f} ({pnl_pct:+.2f}%)" ) self._cooldowns[pos.symbol] = now_van() del self.positions[pos.symbol] return is_long = pos.side == "BUY" # 1. SL check 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. TP3 (close all remaining) if not pos.tp2_hit: pass # Can't hit TP3 without TP2 else: 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. TP2 if not pos.tp1_hit: pass # Can't hit TP2 without TP1 elif 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. 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 # 5. Move SL to BE after 2x SL distance in profit (before TP1) if not pos.tp1_hit and not pos.be_moved: sl_dist = abs(pos.entry_price - pos.level_price) be_trigger = pos.entry_price + sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT if is_long \ else pos.entry_price - sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT be_triggered = (price >= be_trigger) if is_long else (price <= be_trigger) if be_triggered: pos.sl_price = pos.entry_price pos.be_moved = True logger.info(f"GR BE: {pos.symbol} SL moved to BE @ ${pos.entry_price:.6f}") async def monitor_loop(self): """Check all Gerchik positions every N seconds.""" logger.info(f"Gerchik position monitor started (interval: {GERCHIK_CHECK_INTERVAL}s)") while True: try: 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"Gerchik monitor error: {e}", exc_info=True) await asyncio.sleep(GERCHIK_CHECK_INTERVAL) async def scan_loop(self, wt_positions: dict, scalp_positions: dict): """ Periodic scan for Gerchik level signals. Args: wt_positions: WT strategy positions scalp_positions: Scalp strategy positions """ from gerchik_scanner import scan_for_gerchik_signals logger.info(f"Gerchik scanner started (interval={GERCHIK_SCAN_INTERVAL}s)") # Track previously alerted nearby levels (avoid spam) self._alerted_nearby: set[str] = set() scan_count = 0 # Wait for init await asyncio.sleep(15) while True: try: skip = ( set(self.positions.keys()) | set(wt_positions.keys()) | set(scalp_positions.keys()) ) async def on_signal(signal: GerchikSignal): if len(self.positions) < GERCHIK_MAX_POSITIONS: await self.open_trade(signal) result = await scan_for_gerchik_signals( self.trader.client, on_signal, skip, ) scan_count += 1 nearby = result.get("nearby", []) # Alert on new nearby levels (price approaching strong level) new_nearby = [] for n in nearby: key = f"{n.symbol}_{n.level_price:.6f}" if key not in self._alerted_nearby: self._alerted_nearby.add(key) new_nearby.append(n) if new_nearby: lines = ["πŸ‘€ Π¦Π΅Π½Π° Ρƒ ΡΠΈΠ»ΡŒΠ½Ρ‹Ρ… ΡƒΡ€ΠΎΠ²Π½Π΅ΠΉ:\n━━━━━━━━━━━━━━━━━━━━"] for n in new_nearby[:5]: direction = "⬆️" if n.side == "BUY" else "⬇️" lines.append( f"{direction} {n.symbol} ${n.current_price:.4f}\n" f" πŸ“Š Π£Ρ€ΠΎΠ²Π΅Π½ΡŒ ${n.level_price:.4f} ({n.level_type}, " f"{n.level_touches}кас, πŸ’ͺ{n.level_strength:.0f})\n" f" πŸ“ РасстояниС: {n.distance_pct:.2f}%" ) lines.append("━━━━━━━━━━━━━━━━━━━━") await self.notify("\n".join(lines)) # Clean up stale alerts every 12 scans (~1 hour) if scan_count % 12 == 0: self._alerted_nearby.clear() except Exception as e: logger.error(f"Gerchik scan error: {e}", exc_info=True) await asyncio.sleep(GERCHIK_SCAN_INTERVAL) def recover_positions(self): """Recover Gerchik positions from trade log + Binance API on restart.""" from trade_log import load_trade_log log = load_trade_log() open_trades = {} close_events = {"GR_SL_HIT", "GR_TP3_HIT", "GR_MANUAL_CLOSE"} for event in log: evt = event.get("event", "") tid = event.get("trade_id", "") if not tid or not tid.startswith("GR_"): continue if evt == "GR_ENTRY": open_trades[tid] = event elif evt in close_events and tid in open_trades: del open_trades[tid] for tid, entry in open_trades.items(): symbol = entry.get("symbol", "") if not symbol: continue # Verify on Binance binance_pos = self.trader.get_position(symbol) if not binance_pos: logger.info(f"GR recovery: {symbol} not found on Binance, skipping") continue # Get TP state from log tp1_hit = False tp2_hit = False sl_price = entry.get("sl_price", 0) realized_pnl = 0.0 for e in log: if e.get("trade_id") != tid: continue evt = e.get("event", "") if evt == "GR_TP1_HIT": tp1_hit = True sl_price = e.get("new_sl_price", sl_price) realized_pnl += e.get("realized_pnl_usdt", 0) elif evt == "GR_TP2_HIT": tp2_hit = True sl_price = e.get("new_sl_price", sl_price) realized_pnl += e.get("realized_pnl_usdt", 0) opened_at = datetime.fromisoformat(entry["timestamp"]) entry_price = entry.get("entry_price", binance_pos["entry_price"]) level_price = entry.get("level_price", 0) # Detect if BE was already moved: SL at entry price or price past BE trigger be_already = False if sl_price and entry_price and abs(sl_price - entry_price) < entry_price * 0.001: be_already = True elif level_price and not tp1_hit: # Check if current price is past BE trigger sl_dist = abs(entry_price - level_price) be_trigger_mult = GERCHIK_BE_TRIGGER_STOPLOSS_MULT current_price = self.trader.get_mark_price(symbol) if current_price: side = entry.get("side", binance_pos["side"]) is_long = side == "BUY" be_trigger = entry_price + sl_dist * be_trigger_mult if is_long else entry_price - sl_dist * be_trigger_mult if (is_long and current_price >= be_trigger) or (not is_long and current_price <= be_trigger): be_already = True sl_price = entry_price # Set SL to BE pos = GerchikPosition( symbol=symbol, side=entry.get("side", binance_pos["side"]), model=entry.get("model", "?"), entry_price=entry_price, quantity=entry.get("quantity", binance_pos["quantity"]), total_quantity=entry.get("quantity", binance_pos["quantity"]), remaining_quantity=binance_pos["quantity"], sl_price=sl_price, tp1_price=entry.get("tp1_price", 0), tp2_price=entry.get("tp2_price", 0), tp3_price=entry.get("tp3_price", 0), level_price=level_price, level_strength=entry.get("level_strength", 0), trade_id=tid, opened_at=opened_at, tp1_hit=tp1_hit, tp2_hit=tp2_hit, realized_pnl=realized_pnl, ) pos.be_moved = be_already if be_already: logger.info(f"GR recovery: {symbol} BE already triggered, SL at entry") self.positions[symbol] = pos # Place exchange-side TP/SL orders for recovered position if GERCHIK_USE_EXCHANGE_ORDERS: try: self.order_mgr.cancel_all_for_symbol(symbol) time.sleep(2.0) # Let Binance fully process algo order cancellation self._place_recovery_orders(pos) logger.info( f"Recovered GR position: {pos.side} {symbol} @ {pos.entry_price}, " f"model={pos.model}, tp1={tp1_hit}, tp2={tp2_hit}, exchange_orders=YES" ) except Exception as e: logger.error(f"GR recovery orders failed for {symbol}: {e}", exc_info=True) else: logger.info( f"Recovered GR position: {pos.side} {symbol} @ {pos.entry_price}, " f"model={pos.model}, tp1={tp1_hit}, tp2={tp2_hit}" ) def format_positions_message(self) -> str: """Format Gerchik positions for Telegram.""" if not self.positions: return "πŸ“ НСт Gerchik-ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΉ" lines = ["πŸ“ Gerchik ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ:\n━━━━━━━━━━━━━━━━━━━━"] for symbol, pos in self.positions.items(): price = self.trader.get_mark_price(symbol) or pos.entry_price pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side) direction = "L" if pos.side == "BUY" else "S" emoji = "🟒" if pnl_pct >= 0 else "πŸ”΄" model_lbl = MODEL_LABELS.get(pos.model, "?") tp_status = "" if pos.tp2_hit: tp_status = " TP2βœ…" elif pos.tp1_hit: tp_status = " TP1βœ…" lines.append( f"{emoji} {direction} {symbol} | М{pos.model} | {pnl_pct:+.2f}%{tp_status}\n" f" Π£Ρ€ΠΎΠ²Π΅Π½ΡŒ: ${pos.level_price:.4f} | {pos.age_minutes:.0f}ΠΌΠΈΠ½" ) total = self.stats["wins"] + self.stats["losses"] wr = (self.stats["wins"] / total * 100) if total > 0 else 0 lines.append(f"\nπŸ“Š WR: {wr:.0f}% ({self.stats['wins']}W/{self.stats['losses']}L)") lines.append(f"πŸ’° PnL: ${self.stats['total_pnl']:+.2f}") lines.append("━━━━━━━━━━━━━━━━━━━━") return "\n".join(lines) async def close_manual(self, symbol: str) -> bool: """Manual close from 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) fill_price = price if result and result.get("fill_price"): fill_price = result["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 close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100 pnl_usdt -= close_fee total_pnl = pos.realized_pnl + pnl_usdt if pnl_pct >= 0: self.stats["wins"] += 1 else: self.stats["losses"] += 1 self.stats["total_pnl"] += total_pnl log_event("GR_MANUAL_CLOSE", { "trade_id": pos.trade_id, "symbol": pos.symbol, "model": pos.model, "exit_price": fill_price, "pnl_pct": round(pnl_pct, 2), "total_pnl_usdt": round(total_pnl, 2), }) await self.notify( f"πŸ‘‹ GR Manual: {pos.symbol} (М{pos.model})\n" f"PnL: ${total_pnl:+.2f} ({pnl_pct:+.2f}%)" ) self._cooldowns[pos.symbol] = now_van() del self.positions[pos.symbol] return True