← Назад
""" Scalp Position Manager β€” Quick Take strategy. Simple: +1% TP, -0.75% SL, 30min time stop. No partial closes β€” full in, full out. Separate PnL tracking from WT strategy. """ 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 from order_placer import ExchangeOrderManager, MAKER_FEE_PCT logger = logging.getLogger(__name__) VANCOUVER_TZ = timezone(timedelta(hours=-7)) # === Scalp Config === SCALP_ENABLED = os.environ.get("SCALP_ENABLED", "false").lower() == "true" SCALP_SIZE_USDT = float(os.environ.get("SCALP_SIZE_USDT", "10")) SCALP_LEVERAGE = int(os.environ.get("SCALP_LEVERAGE", "5")) SCALP_MAX_POSITIONS = int(os.environ.get("SCALP_MAX_POSITIONS", "3")) SCALP_TP_PCT = float(os.environ.get("SCALP_TP_PCT", "1.0")) SCALP_SL_PCT = float(os.environ.get("SCALP_SL_PCT", "0.75")) SCALP_TIME_STOP_MIN = int(os.environ.get("SCALP_TIME_STOP_MIN", "15")) SCALP_SCAN_INTERVAL = int(os.environ.get("SCALP_SCAN_INTERVAL", "60")) SCALP_CHECK_INTERVAL = int(os.environ.get("SCALP_CHECK_INTERVAL", "3")) SCALP_COOLDOWN_MIN = int(os.environ.get("SCALP_COOLDOWN_MIN", "30")) SCALP_BE_TRIGGER_PCT = float(os.environ.get("SCALP_BE_TRIGGER_PCT", "0.5")) # Exchange-side TP/SL (limit TP + stop-market SL on Binance) SCALP_USE_EXCHANGE_ORDERS = os.environ.get("SCALP_USE_EXCHANGE_ORDERS", "true").lower() == "true" # Taker fee TAKER_FEE_PCT = 0.04 @dataclass class ScalpPosition: """A scalp trade β€” simple TP/SL/Time stop.""" symbol: str side: str entry_price: float quantity: float tp_price: float sl_price: float opened_at: datetime trade_id: str signal_data: dict = field(default_factory=dict) # Exchange-side order IDs (None = using polling fallback) tp_order_id: int | None = None sl_order_id: int | None = None use_exchange_orders: bool = False # True if TP/SL are on Binance moved_to_be: bool = False # True once SL moved to breakeven @property def age_minutes(self) -> float: now = datetime.now(VANCOUVER_TZ) return (now - self.opened_at).total_seconds() / 60 class ScalpManager: """Manages Quick Take scalp positions.""" def __init__(self, trader: BinanceFuturesTrader, notify_fn, tmm=None): self.trader = trader self.notify = notify_fn self.tmm = tmm self.positions: dict[str, ScalpPosition] = {} self.order_mgr = ExchangeOrderManager(trader) self.cooldowns: dict[str, datetime] = {} # symbol β†’ cooldown expiry # Stats tracking (in-memory, persisted in trade_log) self.stats = {"wins": 0, "losses": 0, "time_stops": 0, "total_pnl": 0.0} def recover_positions(self): """Recover scalp positions from trade_log + Binance on restart.""" log = load_event = None # just to avoid confusion from trade_log import load_trade_log trade_log = load_trade_log() # Find SCALP_ENTRY without matching close open_scalps = {} close_events = {"SCALP_TP", "SCALP_SL", "SCALP_TIME_STOP", "SCALP_MANUAL"} for event in trade_log: symbol = event.get("symbol", "") evt = event.get("event", "") if evt == "SCALP_ENTRY": open_scalps[symbol] = event elif evt in close_events and symbol in open_scalps: del open_scalps[symbol] if not open_scalps: logger.info("No scalp positions to recover") return for symbol, entry in open_scalps.items(): # Verify position exists on Binance binance_pos = self.trader.get_position(symbol) if not binance_pos: logger.info(f"Scalp log has {symbol} but no Binance position β€” skip") continue entry_price = entry.get("entry_price", binance_pos["entry_price"]) side = entry.get("side", binance_pos["side"]) qty = binance_pos["quantity"] # Reconstruct TP/SL if side == "BUY": tp = entry_price * (1 + SCALP_TP_PCT / 100) sl = entry_price * (1 - SCALP_SL_PCT / 100) else: tp = entry_price * (1 - SCALP_TP_PCT / 100) sl = entry_price * (1 + SCALP_SL_PCT / 100) # Parse opened_at from timestamp try: opened_at = datetime.fromisoformat(entry.get("timestamp", "")) except Exception: opened_at = datetime.now(VANCOUVER_TZ) trade_id = entry.get("trade_id", f"QT_{symbol}_recovered") pos = ScalpPosition( symbol=symbol, side=side, entry_price=entry_price, quantity=qty, tp_price=tp, sl_price=sl, opened_at=opened_at, trade_id=trade_id, ) # Try to find existing exchange TP orders (LIMIT reduceOnly) # Note: STOP_MARKET SL are algo orders, not visible in get_open_orders if SCALP_USE_EXCHANGE_ORDERS: open_orders = self.trader.get_open_orders(symbol) for o in open_orders: if o["type"] == "LIMIT" and o["status"] == "NEW": pos.tp_order_id = o["orderId"] # SL algo order can't be recovered, but we re-place everything below pos.use_exchange_orders = bool(pos.tp_order_id) # If orders missing, cancel stale and re-place if not pos.use_exchange_orders and SCALP_USE_EXCHANGE_ORDERS: import time as _time self.order_mgr.cancel_all_for_symbol(symbol) _time.sleep(2.0) # Let Binance fully process algo cancellation orders = self.order_mgr.place_tp_sl_orders( symbol, side, sl_price=sl, sl_quantity=qty, tp_levels=[(tp, qty)], ) pos.tp_order_id = orders["tp_order_ids"][0] if orders["tp_order_ids"] else None pos.sl_order_id = orders["sl_order_id"] pos.use_exchange_orders = bool(pos.tp_order_id and pos.sl_order_id) if pos.use_exchange_orders: logger.info(f"Re-placed exchange orders for recovered scalp {symbol}") self.positions[symbol] = pos logger.info( f"Recovered scalp: {side} {symbol} @ {entry_price}, " f"qty={qty}, age={pos.age_minutes:.0f}min, " f"exchange_orders={'YES' if pos.use_exchange_orders else 'NO'}" ) def can_open(self, symbol: str) -> tuple[bool, str]: if symbol in self.positions: return False, f"Already in {symbol}" # Cooldown check β€” prevent re-entering same symbol too soon now = datetime.now(VANCOUVER_TZ) cd = self.cooldowns.get(symbol) if cd and now < cd: remaining = (cd - now).total_seconds() / 60 return False, f"Cooldown {remaining:.0f}min left" # Double-check Binance β€” prevents duplicates after restarts existing = self.trader.get_position(symbol) if existing: logger.warning(f"Scalp {symbol}: position exists on Binance but not in manager β€” skip") return False, f"Position exists on Binance (untracked)" if len(self.positions) >= SCALP_MAX_POSITIONS: return False, f"Max scalp positions ({SCALP_MAX_POSITIONS})" balance = self.trader.get_account_balance() if balance < SCALP_SIZE_USDT: return False, f"Low balance: ${balance:.2f}" return True, "OK" async def open_scalp(self, signal: dict) -> bool: """Open a scalp trade from scanner signal.""" symbol = signal["symbol"] side = signal["side"] can, reason = self.can_open(symbol) if not can: logger.info(f"Scalp skip {symbol}: {reason}") return False # Execute on Binance result = self.trader.open_position(symbol, side, SCALP_SIZE_USDT, SCALP_LEVERAGE) if not result: await self.notify(f"❌ Scalp: Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ {side} {symbol}") return False entry = result["fill_price"] qty = result["quantity"] # Calculate TP/SL if side == "BUY": tp = entry * (1 + SCALP_TP_PCT / 100) sl = entry * (1 - SCALP_SL_PCT / 100) else: tp = entry * (1 - SCALP_TP_PCT / 100) sl = entry * (1 + SCALP_SL_PCT / 100) now = datetime.now(VANCOUVER_TZ) trade_id = f"QT_{symbol}_{now.strftime('%Y%m%d_%H%M%S')}" pos = ScalpPosition( symbol=symbol, side=side, entry_price=entry, quantity=qty, tp_price=tp, sl_price=sl, opened_at=now, trade_id=trade_id, signal_data=signal, ) # Place TP/SL on Binance (exchange-side execution) if SCALP_USE_EXCHANGE_ORDERS: orders = self.order_mgr.place_tp_sl_orders( symbol, side, sl_price=sl, sl_quantity=qty, tp_levels=[(tp, qty)], ) pos.tp_order_id = orders["tp_order_ids"][0] if orders["tp_order_ids"] else None pos.sl_order_id = orders["sl_order_id"] pos.use_exchange_orders = bool(pos.tp_order_id and pos.sl_order_id) if pos.use_exchange_orders: logger.info(f"Scalp {symbol}: exchange orders placed (TP #{pos.tp_order_id}, SL #{pos.sl_order_id})") else: logger.warning(f"Scalp {symbol}: exchange orders partially failed, using polling fallback") self.positions[symbol] = pos log_event("SCALP_ENTRY", { "trade_id": trade_id, "symbol": symbol, "side": side, "entry_price": entry, "quantity": qty, "leverage": SCALP_LEVERAGE, "tp_price": tp, "sl_price": sl, "rsi": signal.get("rsi"), "volume_ratio": signal.get("volume_ratio"), "bb_bandwidth_pct": signal.get("bb_bandwidth_pct"), }) direction = "LONG" if side == "BUY" else "SHORT" notional = qty * entry msg = ( f"⚑ SCALP {direction} {symbol}\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"πŸ’° Entry: ${entry:.6f}\n" f"πŸ“Š Size: {qty} ({SCALP_LEVERAGE}x, ${notional:.2f})\n" f"🎯 TP: ${tp:.6f} (+{SCALP_TP_PCT}%)\n" f"πŸ›‘ SL: ${sl:.6f} (-{SCALP_SL_PCT}%)\n" f"⏱ Time stop: {SCALP_TIME_STOP_MIN}ΠΌΠΈΠ½\n" f"πŸ“‰ RSI={signal.get('rsi', '?')} Vol={signal.get('volume_ratio', '?')}x\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) logger.info(f"Scalp opened: {direction} {symbol} @ {entry}") # TMM journal: tag trade if self.tmm: self.tmm.on_trade_opened(symbol, side, "SCALP", signal_info=f"[Scalp] {direction} RSI+BB+Vol+EMA") return True async def _close_position(self, pos: ScalpPosition, reason: str, current_price: float): """Close a scalp position and record result.""" result = self.trader.close_full(pos.symbol, pos.side) # Use actual fill price from Binance instead of mark price fill_price = current_price # fallback if result and result.get("fill_price"): fill_price = result["fill_price"] logger.info(f"Scalp close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f} slip={((fill_price - current_price) / current_price * 100):+.3f}%") # PnL based on actual fill price if pos.side == "BUY": pnl_pct = ((fill_price - pos.entry_price) / pos.entry_price) * 100 else: pnl_pct = ((pos.entry_price - fill_price) / pos.entry_price) * 100 pnl_usdt = pos.quantity * abs(fill_price - pos.entry_price) if pnl_pct < 0: pnl_usdt = -pnl_usdt # Deduct fees (open + close) open_fee = pos.quantity * pos.entry_price * TAKER_FEE_PCT / 100 close_fee = pos.quantity * fill_price * TAKER_FEE_PCT / 100 pnl_usdt -= (open_fee + close_fee) # Update stats if reason == "TP": self.stats["wins"] += 1 emoji = "🎯" elif reason == "SL": self.stats["losses"] += 1 emoji = "πŸ›‘" else: # TIME_STOP if pnl_pct >= 0: self.stats["wins"] += 1 else: self.stats["losses"] += 1 self.stats["time_stops"] += 1 emoji = "⏱" self.stats["total_pnl"] += pnl_usdt age = pos.age_minutes log_event(f"SCALP_{reason}", { "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, "pnl_pct": round(pnl_pct, 2), "pnl_usdt": round(pnl_usdt, 4), "age_minutes": round(age, 1), "fees": round(open_fee + close_fee, 4), }) total = self.stats["wins"] + self.stats["losses"] wr = (self.stats["wins"] / total * 100) if total > 0 else 0 msg = ( f"{emoji} SCALP {reason}: {pos.symbol}\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Entry: ${pos.entry_price:.6f}\n" f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n" f"{'⚠️ Slip: ' + f'{((fill_price - current_price) / current_price * 100):+.2f}%' + chr(10) if abs(fill_price - current_price) / current_price > 0.001 else ''}" f"πŸ’΅ PnL: ${pnl_usdt:+.4f}\n" f"⏱ {age:.0f}ΠΌΠΈΠ½\n" f"πŸ“Š WR: {wr:.0f}% ({self.stats['wins']}W/{self.stats['losses']}L)\n" f"πŸ’° Total: ${self.stats['total_pnl']:+.2f}\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) del self.positions[pos.symbol] self.cooldowns[pos.symbol] = datetime.now(VANCOUVER_TZ) + timedelta(minutes=SCALP_COOLDOWN_MIN) logger.info(f"Scalp {reason}: {pos.symbol} {pnl_pct:+.2f}% ${pnl_usdt:+.4f} ({age:.0f}min) [cd {SCALP_COOLDOWN_MIN}m]") async def _handle_exchange_tp_fill(self, pos: ScalpPosition, fill_price: float, mark_price: float): """Handle TP filled by exchange-side LIMIT order.""" if pos.side == "BUY": pnl_pct = ((fill_price - pos.entry_price) / pos.entry_price) * 100 else: pnl_pct = ((pos.entry_price - fill_price) / pos.entry_price) * 100 pnl_usdt = pos.quantity * abs(fill_price - pos.entry_price) if pnl_pct < 0: pnl_usdt = -pnl_usdt # TP limit = maker fee (0.02%), open was taker (0.04%) open_fee = pos.quantity * pos.entry_price * TAKER_FEE_PCT / 100 close_fee = pos.quantity * fill_price * MAKER_FEE_PCT / 100 pnl_usdt -= (open_fee + close_fee) self.stats["wins"] += 1 self.stats["total_pnl"] += pnl_usdt log_event("SCALP_TP", { "trade_id": pos.trade_id, "symbol": pos.symbol, "side": pos.side, "entry_price": pos.entry_price, "exit_price": fill_price, "mark_price": mark_price, "pnl_pct": round(pnl_pct, 2), "pnl_usdt": round(pnl_usdt, 4), "age_minutes": round(pos.age_minutes, 1), "fees": round(open_fee + close_fee, 4), "order_type": "EXCHANGE_LIMIT", }) total = self.stats["wins"] + self.stats["losses"] wr = (self.stats["wins"] / total * 100) if total > 0 else 0 msg = ( f"🎯 SCALP TP: {pos.symbol}\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Entry: ${pos.entry_price:.6f}\n" f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%) [LIMIT]\n" f"πŸ’΅ PnL: ${pnl_usdt:+.4f}\n" f"⏱ {pos.age_minutes:.0f}ΠΌΠΈΠ½\n" f"πŸ“Š WR: {wr:.0f}% ({self.stats['wins']}W/{self.stats['losses']}L)\n" f"πŸ’° Total: ${self.stats['total_pnl']:+.2f}\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) del self.positions[pos.symbol] self.cooldowns[pos.symbol] = datetime.now(VANCOUVER_TZ) + timedelta(minutes=SCALP_COOLDOWN_MIN) logger.info(f"Scalp TP (exchange): {pos.symbol} {pnl_pct:+.2f}% ${pnl_usdt:+.4f} [cd {SCALP_COOLDOWN_MIN}m]") async def _handle_exchange_sl_fill(self, pos: ScalpPosition, fill_price: float, mark_price: float): """Handle SL filled by STOP_MARKET algo order (detected via position gone).""" if pos.side == "BUY": pnl_pct = ((fill_price - pos.entry_price) / pos.entry_price) * 100 else: pnl_pct = ((pos.entry_price - fill_price) / pos.entry_price) * 100 pnl_usdt = pos.quantity * abs(fill_price - pos.entry_price) if pnl_pct < 0: pnl_usdt = -pnl_usdt # SL is STOP_MARKET = taker fee both sides open_fee = pos.quantity * pos.entry_price * TAKER_FEE_PCT / 100 close_fee = pos.quantity * fill_price * TAKER_FEE_PCT / 100 pnl_usdt -= (open_fee + close_fee) self.stats["losses"] += 1 self.stats["total_pnl"] += pnl_usdt log_event("SCALP_SL", { "trade_id": pos.trade_id, "symbol": pos.symbol, "side": pos.side, "entry_price": pos.entry_price, "exit_price": fill_price, "mark_price": mark_price, "pnl_pct": round(pnl_pct, 2), "pnl_usdt": round(pnl_usdt, 4), "age_minutes": round(pos.age_minutes, 1), "fees": round(open_fee + close_fee, 4), "order_type": "EXCHANGE_STOP", }) total = self.stats["wins"] + self.stats["losses"] wr = (self.stats["wins"] / total * 100) if total > 0 else 0 msg = ( f"πŸ›‘ SCALP SL: {pos.symbol}\n" f"━━━━━━━━━━━━━━━━━━━━\n" f"Entry: ${pos.entry_price:.6f}\n" f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%) [STOP]\n" f"πŸ’΅ PnL: ${pnl_usdt:+.4f}\n" f"⏱ {pos.age_minutes:.0f}ΠΌΠΈΠ½\n" f"πŸ“Š WR: {wr:.0f}% ({self.stats['wins']}W/{self.stats['losses']}L)\n" f"πŸ’° Total: ${self.stats['total_pnl']:+.2f}\n" f"━━━━━━━━━━━━━━━━━━━━" ) await self.notify(msg) del self.positions[pos.symbol] self.cooldowns[pos.symbol] = datetime.now(VANCOUVER_TZ) + timedelta(minutes=SCALP_COOLDOWN_MIN) logger.info(f"Scalp SL (exchange): {pos.symbol} {pnl_pct:+.2f}% ${pnl_usdt:+.4f} [cd {SCALP_COOLDOWN_MIN}m]") async def check_position(self, pos: ScalpPosition): """Check single position: exchange order fills β†’ TP β†’ SL β†’ Time stop.""" price = self.trader.get_mark_price(pos.symbol) if not price: return # === Exchange-side order mode === # TP = regular LIMIT (queryable), SL = STOP_MARKET algo (not queryable) # Detection: TP filled? β†’ win. Position gone + TP not filled? β†’ SL fired. if pos.use_exchange_orders: # 0. Detect externally cancelled orders β†’ re-place if pos.tp_order_id: tp_status = self.trader.get_order_status(pos.symbol, pos.tp_order_id) if tp_status and tp_status["status"] in ("CANCELED", "CANCELLED", "EXPIRED", "REJECTED"): logger.warning(f"Scalp {pos.symbol}: orders cancelled externally, re-placing") remaining_tps = [(pos.tp_price, pos.quantity)] orders = self.order_mgr.replace_sl_and_tps( pos.symbol, pos.side, pos.sl_price, pos.quantity, remaining_tps ) pos.sl_order_id = orders["sl_order_id"] pos.tp_order_id = orders["tp_order_ids"][0] if orders["tp_order_ids"] else None await self.notify(f"πŸ”„ Scalp {pos.symbol}: ΠΎΡ€Π΄Π΅Ρ€Π° пСрСставлСны (Π±Ρ‹Π»ΠΈ ΠΎΡ‚ΠΌΠ΅Π½Π΅Π½Ρ‹)") logger.info(f"Scalp {pos.symbol}: re-placed SL + TP") return # 1. Check TP order status (regular LIMIT β€” queryable) if pos.tp_order_id: tp_status = self.trader.get_order_status(pos.symbol, pos.tp_order_id) if tp_status and tp_status["status"] == "FILLED": fp = tp_status["fill_price"] if tp_status["fill_price"] > 0 else pos.tp_price # Cancel SL algo order via cancel_all self.order_mgr.cancel_all_for_symbol(pos.symbol) await self._handle_exchange_tp_fill(pos, fp, price) return # 2. Breakeven: move SL to entry once unrealized profit >= trigger if not pos.moved_to_be and SCALP_BE_TRIGGER_PCT > 0: if pos.side == "BUY": unreal_pct = ((price - pos.entry_price) / pos.entry_price) * 100 else: unreal_pct = ((pos.entry_price - price) / pos.entry_price) * 100 if unreal_pct >= SCALP_BE_TRIGGER_PCT: new_sl = pos.entry_price remaining_tps = [(pos.tp_price, pos.quantity)] try: orders = self.order_mgr.replace_sl_and_tps( pos.symbol, pos.side, new_sl, pos.quantity, remaining_tps ) pos.sl_price = new_sl pos.sl_order_id = orders["sl_order_id"] pos.tp_order_id = orders["tp_order_ids"][0] if orders["tp_order_ids"] else pos.tp_order_id pos.moved_to_be = True logger.info(f"Scalp {pos.symbol}: SL moved to BE @ {new_sl} (was +{unreal_pct:.2f}%)") await self.notify(f"πŸ”’ Scalp {pos.symbol}: SL β†’ breakeven (profit +{unreal_pct:.1f}%)") except Exception as e: logger.error(f"Scalp {pos.symbol}: failed to move SL to BE: {e}") # 3. Time stop: cancel all orders, close market if pos.age_minutes >= SCALP_TIME_STOP_MIN: self.order_mgr.cancel_all_for_symbol(pos.symbol) await self._close_position(pos, "TIME_STOP", price) return # 3. Position gone? Either SL fired or manual close binance_pos = self.trader.get_position(pos.symbol) if not binance_pos: # Clean up any remaining orders self.order_mgr.cancel_all_for_symbol(pos.symbol) # Check if TP was filled (might have been filled between checks) if pos.tp_order_id: tp_status = self.trader.get_order_status(pos.symbol, pos.tp_order_id) if tp_status and tp_status["status"] == "FILLED": fp = tp_status["fill_price"] if tp_status["fill_price"] > 0 else pos.tp_price await self._handle_exchange_tp_fill(pos, fp, price) return # TP not filled β†’ SL fired (STOP_MARKET executed on Binance) await self._handle_exchange_sl_fill(pos, price, price) return return # === Polling fallback mode (original logic) === # Safety: detect if position was closed externally (manually on exchange) binance_pos = self.trader.get_position(pos.symbol) if not binance_pos: 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 logger.warning(f"Scalp {pos.symbol} no longer on Binance β€” closed externally") log_event("SCALP_MANUAL", { "trade_id": pos.trade_id, "symbol": pos.symbol, "side": pos.side, "entry_price": pos.entry_price, "exit_price": price, "pnl_pct": round(pnl_pct, 2), "pnl_usdt": 0, # unknown exact PnL "age_minutes": round(pos.age_minutes, 1), "note": "closed_externally_on_exchange", }) await self.notify( f"πŸ‘‹ Scalp {pos.symbol} Π·Π°ΠΊΡ€Ρ‹Ρ‚Π° Π½Π° Π±ΠΈΡ€ΠΆΠ΅ Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ\n" f"Π£Π±ΠΈΡ€Π°ΡŽ ΠΈΠ· ΠΌΠΎΠ½ΠΈΡ‚ΠΎΡ€ΠΈΠ½Π³Π°.\n" f"ПослСдняя Ρ†Π΅Π½Π°: ${price:.6f} ({pnl_pct:+.2f}%)" ) del self.positions[pos.symbol] self.cooldowns[pos.symbol] = datetime.now(VANCOUVER_TZ) + timedelta(minutes=SCALP_COOLDOWN_MIN) return is_long = pos.side == "BUY" # 1. Check TP tp_hit = (price >= pos.tp_price) if is_long else (price <= pos.tp_price) if tp_hit: await self._close_position(pos, "TP", price) return # 2. Breakeven (polling mode) if not pos.moved_to_be and SCALP_BE_TRIGGER_PCT > 0: unreal_pct = ((price - pos.entry_price) / pos.entry_price * 100) if is_long else ((pos.entry_price - price) / pos.entry_price * 100) if unreal_pct >= SCALP_BE_TRIGGER_PCT: pos.sl_price = pos.entry_price pos.moved_to_be = True logger.info(f"Scalp {pos.symbol}: SL moved to BE @ {pos.entry_price} [polling] (+{unreal_pct:.2f}%)") # 3. Check SL sl_hit = (price <= pos.sl_price) if is_long else (price >= pos.sl_price) if sl_hit: await self._close_position(pos, "SL", price) return # 4. Time stop if pos.age_minutes >= SCALP_TIME_STOP_MIN: await self._close_position(pos, "TIME_STOP", price) return async def monitor_loop(self): """Check all scalp positions every N seconds.""" logger.info(f"Scalp monitor started (TP={SCALP_TP_PCT}%, SL={SCALP_SL_PCT}%, TimeStop={SCALP_TIME_STOP_MIN}min, BE@+{SCALP_BE_TRIGGER_PCT}%, cd={SCALP_COOLDOWN_MIN}m)") 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"Scalp monitor error: {e}", exc_info=True) await asyncio.sleep(SCALP_CHECK_INTERVAL) async def scan_loop(self, wt_positions: dict): """ Periodic market scan for Quick Take entries. Args: wt_positions: dict of WT strategy positions (to avoid conflicts) """ from scalp_scanner import scan_market logger.info(f"Scalp scanner started (interval={SCALP_SCAN_INTERVAL}s, top 40 pairs)") # Wait a bit before first scan (let bot initialize) await asyncio.sleep(10) while True: try: # Skip symbols we already have positions in (both scalp and WT) skip = set(self.positions.keys()) | set(wt_positions.keys()) async def on_signal(signal): await self.open_scalp(signal) await scan_market(on_signal, skip_symbols=skip) except Exception as e: logger.error(f"Scalp scan error: {e}", exc_info=True) await asyncio.sleep(SCALP_SCAN_INTERVAL) def format_positions_message(self) -> str: """Format scalp positions for Telegram.""" if not self.positions: return "⚑ НСт скальп-ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΉ" lines = ["⚑ Scalp ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ:\n━━━━━━━━━━━━━━━━━━━━"] for symbol, pos in self.positions.items(): price = self.trader.get_mark_price(symbol) or pos.entry_price 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 direction = "L" if pos.side == "BUY" else "S" emoji = "🟒" if pnl_pct >= 0 else "πŸ”΄" age = pos.age_minutes lines.append( f"{emoji} {direction} {symbol} | {pnl_pct:+.2f}% | " f"${price:.6f} | {age:.0f}ΠΌΠΈΠ½/{SCALP_TIME_STOP_MIN}" ) 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 /scalp_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 closing if pos.use_exchange_orders: self.order_mgr.cancel_all_for_symbol(symbol) await self._close_position(pos, "MANUAL", price) return True