← Back
"""
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}")

📜 Git History

c6f6bd5chore: initial commit — version control setup5 weeks ago
Show last diff
Loading...