← Назад
""" WT Bot v3 — Trade Manager ============================ Управление позициями: вход, SL/TP лимитки, отслеживание, закрытие. Пайплайн: 1. Скринер нашёл монету в WT зоне → вайтлист 2. Manager каждые 5 сек проверяет вайтлист на WT cross 3. Cross → вход по маркету + SL (STOP_MARKET) + TP (LIMIT) 4. Мониторинг: проверка что ордера на месте, детекция закрытия 5. Закрытие → лог, cooldown, убрать из позиций """ import json import time import logging import tempfile import os from datetime import datetime, timezone from src.config import ( SL_PCT, TP_PCT, TRADE_SIZE_USD, LEVERAGE, MAX_POSITIONS, TIMEFRAME, POSITIONS_FILE, TRADE_LOG_FILE, COOLDOWN_MIN, WT_ZONE_LOOKBACK, WT_OVERSOLD, WT_OVERBOUGHT, TP1_PCT, TP1_CLOSE_RATIO, TRAIL_CALLBACK_PCT, CIRCUIT_BREAKER_LOSSES, CIRCUIT_BREAKER_PAUSE_MIN, ) from src.indicators import calc_wavetrend_series, detect_wt_cross, is_in_wt_zone, calc_ema logger = logging.getLogger("manager") class Position: """Одна открытая позиция.""" def __init__(self, symbol, side, entry_price, qty, sl_price, tp_price, symbol_info, zone, wt1, wt2, opened_at=None): self.symbol = symbol self.side = side # "LONG" or "SHORT" self.entry_price = entry_price self.qty = qty # current qty (decreases after TP1) self.original_qty = qty # original full qty self.sl_price = sl_price self.tp_price = tp_price # TP1 price (1.0%) self.symbol_info = symbol_info self.zone = zone self.wt1 = wt1 self.wt2 = wt2 self.opened_at = opened_at or datetime.now(timezone.utc).isoformat() self.sl_order_placed = False self.tp_order_placed = False # TP1 partial + trailing self.tp1_hit = False # True after TP1 filled (50% closed) self.trail_high = 0.0 # best price seen since TP1 (for trailing) def to_dict(self): return { "symbol": self.symbol, "side": self.side, "entry_price": self.entry_price, "qty": self.qty, "original_qty": self.original_qty, "sl_price": self.sl_price, "tp_price": self.tp_price, "zone": self.zone, "wt1": self.wt1, "wt2": self.wt2, "opened_at": self.opened_at, "sl_order_placed": self.sl_order_placed, "tp_order_placed": self.tp_order_placed, "tp1_hit": self.tp1_hit, "trail_high": self.trail_high, } @classmethod def from_dict(cls, d, symbol_info=None): pos = cls( symbol=d["symbol"], side=d["side"], entry_price=d["entry_price"], qty=d["qty"], sl_price=d["sl_price"], tp_price=d["tp_price"], symbol_info=symbol_info, zone=d.get("zone", 0), wt1=d.get("wt1", 0), wt2=d.get("wt2", 0), opened_at=d.get("opened_at"), ) pos.original_qty = d.get("original_qty", d["qty"]) pos.sl_order_placed = d.get("sl_order_placed", False) pos.tp_order_placed = d.get("tp_order_placed", False) pos.tp1_hit = d.get("tp1_hit", False) pos.trail_high = d.get("trail_high", 0.0) return pos class TradeManager: def __init__(self, exchange, screener, notifier=None, tmm=None): self.exchange = exchange self.screener = screener self.notifier = notifier # Telegram notify function self.tmm = tmm # TMMClient instance self.positions = {} # symbol → Position self._symbol_info_cache = {} # symbol → info # Circuit breaker self._consecutive_losses = 0 self._circuit_breaker_until = 0 # timestamp when pause ends self._load_positions() # ============================================================ # MAIN LOOP: check whitelist for crosses # ============================================================ def check_whitelist_for_entries(self): """ Проверяет каждый символ в вайтлисте на WT cross. Если cross совпадает с зоной → вход. """ # Circuit breaker: пауза после серии лоссов now = time.time() if now < self._circuit_breaker_until: remaining = int((self._circuit_breaker_until - now) / 60) logger.debug(f"Circuit breaker active, {remaining}min left") return whitelist = self.screener.get_whitelist() if not whitelist: return if len(self.positions) >= MAX_POSITIONS: return for entry in whitelist: symbol = entry["symbol"] zone = entry["zone"] # Уже в позиции? if symbol in self.positions: continue # Макс позиций? if len(self.positions) >= MAX_POSITIONS: break try: # Получаем свежие klines для cross detection klines = self.exchange.get_klines(symbol, TIMEFRAME, limit=250) if len(klines) < 10: continue wt1_series, wt2_series = calc_wavetrend_series(klines) if wt1_series is None: continue cross = detect_wt_cross(wt1_series, wt2_series) # Нет cross — скип if cross == 0: continue # Cross direction must match zone from screener # zone=1 (oversold) → ждём cross_up (1) → LONG # zone=-1 (overbought) → ждём cross_down (-1) → SHORT if cross != zone: continue # ==================================================== # ZONE LOOKBACK: cross must happen IN or NEAR the zone # Check if WT was in oversold/overbought within last # WT_ZONE_LOOKBACK bars from the cross bar (-2 = last closed) # ==================================================== in_zone_recently = False for lb in range(0, WT_ZONE_LOOKBACK + 1): idx = -2 - lb # -2 is the cross bar (last closed candle) if abs(idx) > len(wt1_series): break w1 = float(wt1_series[idx]) w2 = float(wt2_series[idx]) if cross == 1 and (w1 < WT_OVERSOLD or w2 < WT_OVERSOLD): in_zone_recently = True break if cross == -1 and (w1 > WT_OVERBOUGHT or w2 > WT_OVERBOUGHT): in_zone_recently = True break if not in_zone_recently: logger.info(f"Skip {symbol}: cross={cross} but WT not in zone within {WT_ZONE_LOOKBACK} bars") continue # Перепроверяем EMA200 ema200 = calc_ema(klines, 200) if len(klines) >= 200 else None # Если не хватает данных для EMA200, берём из entry last_close = float(klines[-2][4]) # Закрытая свеча if ema200 is not None: if cross == 1 and last_close <= ema200: logger.info(f"Cross found but EMA200 blocks LONG {symbol} (price={last_close:.6f} < EMA={ema200:.6f})") self.screener.remove_from_whitelist(symbol) continue if cross == -1 and last_close >= ema200: logger.info(f"Cross found but EMA200 blocks SHORT {symbol} (price={last_close:.6f} > EMA={ema200:.6f})") self.screener.remove_from_whitelist(symbol) continue # ВХОД! self._open_position( symbol=symbol, side="LONG" if cross == 1 else "SHORT", wt1=float(wt1_series[-2]), wt2=float(wt2_series[-2]), zone=zone, atr_pct=entry.get("atr_pct", 0), ema200=ema200 or entry.get("ema200", 0), price=last_close, ) # Убираем из вайтлиста после входа self.screener.remove_from_whitelist(symbol) time.sleep(0.2) except Exception as e: logger.error(f"Entry check error {symbol}: {e}") continue # ============================================================ # OPEN POSITION # ============================================================ def _open_position(self, symbol, side, wt1, wt2, zone, atr_pct=0, ema200=0, price=0): """Открыть позицию: market order + SL + TP.""" try: # Защита от повторного входа: проверяем биржу exchange_positions = self.exchange.get_positions() for ep in exchange_positions: if ep["symbol"] == symbol and float(ep["positionAmt"]) != 0: logger.warning(f"Already have position on {symbol} on exchange, skipping") return # Symbol info sym_info = self._get_symbol_info(symbol) if not sym_info: logger.error(f"No symbol info for {symbol}") return # Set leverage self.exchange.set_leverage(symbol) self.exchange.set_margin_type(symbol) # Рассчитываем qty mark_price = self.exchange.get_mark_price(symbol) position_size = TRADE_SIZE_USD * LEVERAGE # $5 * 10 = $50 qty = position_size / mark_price qty = self.exchange.round_qty(sym_info, qty) if sym_info["min_qty"] and qty < sym_info["min_qty"]: logger.warning(f"Qty {qty} below min {sym_info['min_qty']} for {symbol}") return # Market order order_side = "BUY" if side == "LONG" else "SELL" order, fill_price = self.exchange.open_market(symbol, order_side, qty) if fill_price == 0: logger.error(f"Fill price 0 for {symbol}! Closing phantom position...") try: close_side = "SELL" if order_side == "BUY" else "BUY" self.exchange.close_position(symbol, close_side, qty) self._notify(f"⚠️ Phantom position closed: {symbol} (fill_price=0)") except Exception as ce: logger.error(f"Failed to close phantom {symbol}: {ce}") return # Рассчитываем SL и TP1 (partial) if side == "LONG": sl_price = fill_price * (1 - SL_PCT) tp1_price = fill_price * (1 + TP1_PCT) else: sl_price = fill_price * (1 + SL_PCT) tp1_price = fill_price * (1 - TP1_PCT) # Создаём позицию (tp_price = TP1 level) pos = Position( symbol=symbol, side=side, entry_price=fill_price, qty=qty, sl_price=sl_price, tp_price=tp1_price, symbol_info=sym_info, zone=zone, wt1=wt1, wt2=wt2, ) # TP1 qty = 50% of full position tp1_qty = self.exchange.round_qty(sym_info, qty * TP1_CLOSE_RATIO) # Ставим SL (full qty) + TP1 (partial qty) на бирже self._place_sl_tp(pos, tp_qty=tp1_qty) self.positions[symbol] = pos self._save_positions() msg = ( f"{'🟢' if side == 'LONG' else '🔴'} **{side} {symbol}**\n" f"Entry: {fill_price}\n" f"SL: {sl_price:.4f} (-{SL_PCT*100}%)\n" f"TP1: {tp1_price:.4f} (+{TP1_PCT*100}%) → 50% close\n" f"Trail: {TRAIL_CALLBACK_PCT*100}% callback after TP1\n" f"Qty: {qty} (${TRADE_SIZE_USD}×{LEVERAGE}x)\n" f"WT1: {wt1:.1f} | WT2: {wt2:.1f}" ) logger.info(msg.replace("**", "").replace("\n", " | ")) self._notify(msg) # TMM: tag trade with strategy + entry reason if self.tmm: try: self.tmm.on_trade_opened( symbol=symbol, side=side, wt1=wt1, wt2=wt2, zone=zone, atr_pct=atr_pct, ema200=ema200, price=price, ) except Exception as e: logger.warning(f"TMM tag error: {e}") except Exception as e: logger.error(f"Open position failed {symbol}: {e}") self._notify(f"❌ Open failed {symbol}: {e}") def _place_sl_tp(self, pos, tp_qty=None): """ Поставить SL и TP ордера на бирже. tp_qty: количество для TP ордера (для TP1 partial = 50% qty). Если None и tp1 уже hit — не ставим TP (trailing вместо этого). """ sym_info = pos.symbol_info or self._get_symbol_info(pos.symbol) close_side = "SELL" if pos.side == "LONG" else "BUY" # SL — всегда на полный текущий qty try: self.exchange.place_sl(pos.symbol, close_side, pos.qty, pos.sl_price, sym_info) pos.sl_order_placed = True except Exception as e: logger.error(f"SL placement failed {pos.symbol}: {e}") pos.sl_order_placed = False # TP — только если не в trailing режиме if not pos.tp1_hit: actual_tp_qty = tp_qty or pos.qty try: self.exchange.place_tp(pos.symbol, close_side, actual_tp_qty, pos.tp_price, sym_info) pos.tp_order_placed = True except Exception as e: logger.error(f"TP placement failed {pos.symbol}: {e}") pos.tp_order_placed = False else: # After TP1: no TP order, trailing handled in check_positions pos.tp_order_placed = True # suppress re-placement warnings # ============================================================ # MONITOR POSITIONS # ============================================================ def check_positions(self): """ Проверяет все открытые позиции: 1. Позиция закрыта? → лог 2. TP1 partial fill? → close 50%, SL→BE, start trailing 3. Trailing: update trail_high, check callback → close rest 4. Ордера на месте? """ if not self.positions: return exchange_positions = self.exchange.get_positions() exchange_map = {p["symbol"]: p for p in exchange_positions} closed = [] changed = False for symbol, pos in self.positions.items(): ex_pos = exchange_map.get(symbol) actual_qty = abs(float(ex_pos["positionAmt"])) if ex_pos else 0 # ── 1. Позиция полностью закрыта на бирже? ── if actual_qty == 0: result = self._determine_close_result(pos) self._log_trade(pos, result) closed.append(symbol) continue # ── 2. TP1 detection: qty decreased = partial TP filled ── if not pos.tp1_hit: # TP1 limit order was for 50% qty. If actual_qty dropped → TP1 hit expected_after_tp1 = self.exchange.round_qty( pos.symbol_info or self._get_symbol_info(symbol), pos.original_qty * (1 - TP1_CLOSE_RATIO) ) if actual_qty <= expected_after_tp1 + 0.0001 and actual_qty < pos.qty - 0.0001: # TP1 filled! pos.tp1_hit = True pos.qty = actual_qty # Move SL to breakeven pos.sl_price = pos.entry_price # Init trail_high to TP1 price pos.trail_high = pos.tp_price if pos.side == "LONG" else pos.tp_price # Cancel all old orders, place new SL at BE (no TP — trailing now) self.exchange.cancel_all_orders(symbol) self._place_sl_tp(pos) # SL only, tp1_hit=True skips TP changed = True tp1_pnl_pct = TP1_PCT * 100 tp1_pnl_usd = TRADE_SIZE_USD * LEVERAGE * TP1_PCT * TP1_CLOSE_RATIO msg = ( f"🎯 **TP1 {pos.side} {symbol}** +{tp1_pnl_pct:.1f}%\n" f"Closed {TP1_CLOSE_RATIO*100:.0f}% (${tp1_pnl_usd:+.2f})\n" f"SL → BE ({pos.entry_price})\n" f"Trailing {TRAIL_CALLBACK_PCT*100}% on remaining {actual_qty}" ) logger.info(f"TP1 {pos.side} {symbol} +{tp1_pnl_pct:.1f}% | SL→BE | trail rest") self._notify(msg) # ── 3. Trailing stop (after TP1) ── if pos.tp1_hit: try: mark = self.exchange.get_mark_price(symbol) if mark and mark > 0: if pos.side == "LONG": # Track highest price if mark > pos.trail_high: pos.trail_high = mark changed = True # Check callback: price dropped from high trail_sl = pos.trail_high * (1 - TRAIL_CALLBACK_PCT) if mark <= trail_sl and pos.trail_high > pos.entry_price: # Trail triggered → close remaining self._close_trailing(pos, mark) closed.append(symbol) continue else: # SHORT # Track lowest price if pos.trail_high == 0 or mark < pos.trail_high: pos.trail_high = mark changed = True # Check callback: price rose from low trail_sl = pos.trail_high * (1 + TRAIL_CALLBACK_PCT) if mark >= trail_sl and pos.trail_high < pos.entry_price: # Trail triggered → close remaining self._close_trailing(pos, mark) closed.append(symbol) continue except Exception as e: logger.debug(f"Trail check error {symbol}: {e}") # ── 4. Order health check (only pre-TP1) ── if not pos.tp1_hit: if not pos.sl_order_placed or not pos.tp_order_placed: logger.warning(f"Re-placing orders for {symbol}") self.exchange.cancel_all_orders(symbol) sym_info = pos.symbol_info or self._get_symbol_info(symbol) tp1_qty = self.exchange.round_qty(sym_info, pos.original_qty * TP1_CLOSE_RATIO) self._place_sl_tp(pos, tp_qty=tp1_qty) changed = True try: open_orders = self.exchange.get_open_orders(symbol) has_limit = any(o["type"] == "LIMIT" for o in open_orders) if not has_limit and pos.tp_order_placed: logger.warning(f"TP order missing for {symbol}, re-placing") self.exchange.cancel_all_orders(symbol) sym_info = pos.symbol_info or self._get_symbol_info(symbol) tp1_qty = self.exchange.round_qty(sym_info, pos.original_qty * TP1_CLOSE_RATIO) self._place_sl_tp(pos, tp_qty=tp1_qty) changed = True except Exception as e: logger.debug(f"Order check error {symbol}: {e}") # Cleanup closed for symbol in closed: del self.positions[symbol] self.exchange.cancel_all_orders(symbol) self.screener.add_cooldown(symbol) if closed or changed: self._save_positions() def _close_trailing(self, pos, mark_price): """Close remaining position via trailing stop.""" try: close_side = "SELL" if pos.side == "LONG" else "BUY" fill_price = self.exchange.close_position(pos.symbol, close_side, pos.qty) if pos.side == "LONG": trail_pnl_pct = (fill_price - pos.entry_price) / pos.entry_price * 100 else: trail_pnl_pct = (pos.entry_price - fill_price) / pos.entry_price * 100 remaining_ratio = 1 - TP1_CLOSE_RATIO trail_pnl_usd = TRADE_SIZE_USD * LEVERAGE * remaining_ratio * (trail_pnl_pct / 100) tp1_pnl_usd = TRADE_SIZE_USD * LEVERAGE * TP1_CLOSE_RATIO * TP1_PCT total_pnl_usd = tp1_pnl_usd + trail_pnl_usd # Log combined trade trade = { "symbol": pos.symbol, "side": pos.side, "entry_price": pos.entry_price, "sl_price": pos.sl_price, "tp_price": pos.tp_price, "qty": pos.original_qty, "result": "TRAIL", "pnl_pct": round(TP1_PCT * 100 * TP1_CLOSE_RATIO + trail_pnl_pct * remaining_ratio, 2), "pnl_usd": round(total_pnl_usd, 2), "trail_close_price": fill_price, "trail_high": pos.trail_high, "tp1_hit": True, "wt1": pos.wt1, "wt2": pos.wt2, "opened_at": pos.opened_at, "closed_at": datetime.now(timezone.utc).isoformat(), } self._append_trade_log(trade) emoji = "🏃" if total_pnl_usd > 0 else "⚠️" msg = ( f"{emoji} **TRAIL {pos.side} {pos.symbol}**\n" f"TP1: +{TP1_PCT*100:.1f}% (${tp1_pnl_usd:+.2f})\n" f"Trail: {trail_pnl_pct:+.1f}% (${trail_pnl_usd:+.2f})\n" f"**Total: ${total_pnl_usd:+.2f}**\n" f"Peak: {pos.trail_high:.6f} → Exit: {fill_price:.6f}" ) logger.info(f"TRAIL {pos.side} {pos.symbol} total=${total_pnl_usd:+.2f}") self._notify(msg) except Exception as e: logger.error(f"Trail close failed {pos.symbol}: {e}") self._log_trade(pos, "TRAIL_ERROR") def _determine_close_result(self, pos): """ Определяем как закрылась позиция: SL или TP. 1. Проверяем открытые ордера — если TP (LIMIT) ещё висит → SL сработал 2. Fallback: проверяем последнюю цену vs SL/TP уровни """ try: open_orders = self.exchange.get_open_orders(pos.symbol) has_tp = any(o["type"] == "LIMIT" for o in open_orders) if has_tp: return "SL" # Fallback: проверяем через mark price try: mark = self.exchange.get_mark_price(pos.symbol) if pos.side == "LONG": # TP выше entry, SL ниже dist_to_tp = abs(mark - pos.tp_price) dist_to_sl = abs(mark - pos.sl_price) else: # SHORT: TP ниже entry, SL выше dist_to_tp = abs(mark - pos.tp_price) dist_to_sl = abs(mark - pos.sl_price) if dist_to_tp < dist_to_sl: return "TP" else: return "SL" except Exception: return "TP" # Default: optimistic except Exception: return "UNKNOWN" # ============================================================ # TRADE LOG # ============================================================ def _append_trade_log(self, trade): """Append trade dict to trade_log.json (atomic write).""" try: with open(TRADE_LOG_FILE, "r") as f: log = json.load(f) except Exception: log = [] log.append(trade) try: dir_name = os.path.dirname(TRADE_LOG_FILE) fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp") with os.fdopen(fd, "w") as f: json.dump(log, f, indent=2) os.replace(tmp_path, TRADE_LOG_FILE) except Exception as e: logger.error(f"Error saving trade log: {e}") # Circuit breaker: track consecutive losses pnl = trade.get("pnl_usd", 0) if pnl < 0: self._consecutive_losses += 1 if self._consecutive_losses >= CIRCUIT_BREAKER_LOSSES: self._circuit_breaker_until = time.time() + CIRCUIT_BREAKER_PAUSE_MIN * 60 logger.warning( f"🛑 CIRCUIT BREAKER: {self._consecutive_losses} consecutive losses → " f"pausing entries for {CIRCUIT_BREAKER_PAUSE_MIN}min" ) self._notify( f"🛑 **Circuit Breaker активирован**\n" f"{self._consecutive_losses} лоссов подряд → пауза {CIRCUIT_BREAKER_PAUSE_MIN} мин" ) self._consecutive_losses = 0 # reset after triggering else: self._consecutive_losses = 0 # reset on any win/BE def _log_trade(self, pos, result): """Записать сделку в лог и отправить уведомление.""" if pos.tp1_hit: # Position was partially closed at TP1, rest hit SL at BE tp1_pnl = TRADE_SIZE_USD * LEVERAGE * TP1_CLOSE_RATIO * TP1_PCT # Remaining 50% hit SL (at breakeven if SL was moved to BE) if result == "SL" and abs(pos.sl_price - pos.entry_price) < pos.entry_price * 0.001: rest_pnl = 0 # BE stop result = "TP1+BE" else: rest_pnl = -(TRADE_SIZE_USD * LEVERAGE * (1 - TP1_CLOSE_RATIO) * SL_PCT) result = "TP1+SL" pnl_usd = tp1_pnl + rest_pnl pnl_pct = pnl_usd / (TRADE_SIZE_USD * LEVERAGE) * 100 elif result == "TP": # TP1 LIMIT filled + SL closed rest so fast that check_positions # saw qty=0 before detecting TP1. Estimate as TP1 only (conservative). pnl_pct = TP1_PCT * 100 * TP1_CLOSE_RATIO # +0.5% pnl_usd = TRADE_SIZE_USD * LEVERAGE * TP1_CLOSE_RATIO * TP1_PCT # +$0.25 result = "TP1+BE" # most likely: TP1 hit, rest stopped at BE logger.info(f"Fast TP1 detected {pos.symbol}: tp1_hit was false, estimating TP1+BE") elif result == "SL": pnl_pct = -SL_PCT * 100 pnl_usd = -(TRADE_SIZE_USD * LEVERAGE * SL_PCT) else: pnl_pct = 0 pnl_usd = 0 trade = { "symbol": pos.symbol, "side": pos.side, "entry_price": pos.entry_price, "sl_price": pos.sl_price, "tp_price": pos.tp_price, "qty": pos.original_qty, "result": result, "pnl_pct": round(pnl_pct, 2), "pnl_usd": round(pnl_usd, 2), "tp1_hit": pos.tp1_hit, "wt1": pos.wt1, "wt2": pos.wt2, "opened_at": pos.opened_at, "closed_at": datetime.now(timezone.utc).isoformat(), } self._append_trade_log(trade) # Notify if "TP1+BE" in result: emoji = "✅" elif "TP1" in result: emoji = "🟡" elif result == "SL": emoji = "❌" else: emoji = "⚠️" msg = ( f"{emoji} **{result} {pos.side} {pos.symbol}**\n" f"Entry: {pos.entry_price}\n" f"PnL: {pnl_pct:+.1f}% (${pnl_usd:+.2f})\n" f"TP1 hit: {'Yes' if pos.tp1_hit else 'No'}" ) logger.info(f"{result} {pos.side} {pos.symbol} PnL={pnl_pct:+.1f}%") self._notify(msg) # ============================================================ # STATS # ============================================================ def get_pnl_summary(self): """Сводка PnL из trade_log.""" try: with open(TRADE_LOG_FILE, "r") as f: log = json.load(f) except Exception: return "No trades yet." if not log: return "No trades yet." total_pnl = sum(t.get("pnl_usd", 0) for t in log) wins = [t for t in log if t.get("pnl_usd", 0) > 0] losses = [t for t in log if t.get("pnl_usd", 0) <= 0] wr = len(wins) / len(log) * 100 if log else 0 return ( f"📊 **PnL Summary**\n" f"Trades: {len(log)} ({len(wins)}W / {len(losses)}L)\n" f"Win Rate: {wr:.1f}%\n" f"Total PnL: ${total_pnl:+.2f}\n" f"Last: {log[-1]['symbol']} {log[-1]['result']} {log[-1].get('pnl_pct', 0):+.1f}%" ) # ============================================================ # PERSISTENCE # ============================================================ def _save_positions(self): """Atomic write — сначала в tmp файл, потом rename.""" try: data = {s: p.to_dict() for s, p in self.positions.items()} dir_name = os.path.dirname(POSITIONS_FILE) fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp") with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2) os.replace(tmp_path, POSITIONS_FILE) except Exception as e: logger.error(f"Error saving positions: {e}") def _load_positions(self): try: with open(POSITIONS_FILE, "r") as f: data = json.load(f) if isinstance(data, dict) and data: for symbol, d in data.items(): sym_info = self._get_symbol_info(symbol) self.positions[symbol] = Position.from_dict(d, sym_info) logger.info(f"Loaded {len(self.positions)} positions from file") except Exception: self.positions = {} def _get_symbol_info(self, symbol): if symbol not in self._symbol_info_cache: info = self.exchange.get_symbol_info(symbol) if info: self._symbol_info_cache[symbol] = info return self._symbol_info_cache.get(symbol) # ============================================================ # RECOVERY (after restart) # ============================================================ def recovery(self): """ При старте бота: проверяем позиции из файла vs биржа. Если позиция есть в файле но нет на бирже → закрылась пока бот был выключен. Если позиция есть на бирже → переставляем ордера. """ if not self.positions: return logger.info(f"Recovery: checking {len(self.positions)} saved positions...") exchange_positions = self.exchange.get_positions() exchange_symbols = {p["symbol"] for p in exchange_positions if float(p["positionAmt"]) != 0} closed = [] for symbol, pos in self.positions.items(): if symbol not in exchange_symbols: logger.info(f"Recovery: {symbol} closed while bot was down") self._log_trade(pos, "UNKNOWN") closed.append(symbol) else: # Проверяем qty — если частично закрыта (TP1 hit), обновляем for ep in exchange_positions: if ep["symbol"] == symbol: actual_qty = abs(float(ep["positionAmt"])) if actual_qty != pos.qty and actual_qty > 0: logger.warning(f"Recovery: {symbol} qty mismatch: saved={pos.qty} actual={actual_qty}") # Detect TP1 hit during downtime expected_after_tp1 = self.exchange.round_qty( pos.symbol_info or self._get_symbol_info(symbol), pos.original_qty * (1 - TP1_CLOSE_RATIO) ) if actual_qty <= expected_after_tp1 + 0.0001 and not pos.tp1_hit: logger.info(f"Recovery: TP1 was hit for {symbol} while bot was down") pos.tp1_hit = True pos.sl_price = pos.entry_price # BE pos.trail_high = pos.tp_price pos.qty = actual_qty break # Переставляем ордера logger.info(f"Recovery: re-placing orders for {symbol}") self.exchange.cancel_all_orders(symbol) if not pos.tp1_hit: sym_info = pos.symbol_info or self._get_symbol_info(symbol) tp1_qty = self.exchange.round_qty(sym_info, pos.original_qty * TP1_CLOSE_RATIO) self._place_sl_tp(pos, tp_qty=tp1_qty) else: self._place_sl_tp(pos) # SL only at BE for symbol in closed: del self.positions[symbol] self.exchange.cancel_all_orders(symbol) if closed: self._save_positions() # ============================================================ # NOTIFY # ============================================================ def _notify(self, msg): if self.notifier: try: self.notifier(msg) except Exception as e: logger.debug(f"Notify error: {e}")