← Back
"""
Squeeze-VWAP Bot — Trade Manager
===================================
Entry: score >= 3 из combo signal (Squeeze + Z-VWAP + Waddah + ADX)
Exit: TP1 partial (50% at 1.2%) + trailing (0.7% callback) on rest
      Dynamic TP still active on remaining 50% (Z fair value)
      Time stop on remaining 50% if no movement

v2 (4 Apr 2026): TP1 partial + trailing replaces old BE-only logic.
"""

import json
import time
import logging
import tempfile
import os
from datetime import datetime, timezone

from src.config import (
    SL_PCT, MAX_TP_PCT, TRADE_SIZE_USD, LEVERAGE, MAX_POSITIONS,
    TIMEFRAME, POSITIONS_FILE, TRADE_LOG_FILE,
    ZVWAP_EXIT_THRESHOLD, BE_TRIGGER_PCT,
    TP1_PCT, TP1_CLOSE_RATIO, TRAIL_CALLBACK_PCT,
)
from src.indicators import calc_combo_signal, calc_z_vwap

logger = logging.getLogger("manager")


MIN_ENTRY_SCORE = 3  # Минимальный score для входа


class Position:
    """Одна открытая позиция."""

    def __init__(self, symbol, side, entry_price, qty, sl_price, tp_price,
                 symbol_info, direction, z_score, score, reasons,
                 opened_at=None):
        self.symbol = symbol
        self.side = side            # "LONG" or "SHORT"
        self.entry_price = entry_price
        self.qty = qty
        self.sl_price = sl_price
        self.tp_price = tp_price    # Max TP (cap)
        self.symbol_info = symbol_info
        self.direction = direction  # 1=long, -1=short
        self.z_score = z_score      # Z-VWAP at entry
        self.score = score
        self.reasons = reasons
        self.opened_at = opened_at or datetime.now(timezone.utc).isoformat()
        self.sl_order_placed = False
        self.tp_order_placed = False
        self.moved_to_be = False    # SL перенесён на entry
        # TP1 partial + trailing (v2)
        self.original_qty = qty     # full entry qty
        self.tp1_hit = False        # True after TP1 partial fill
        self.trail_high = 0.0       # best price since TP1

    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,
            "direction": self.direction,
            "z_score": self.z_score,
            "score": self.score,
            "reasons": self.reasons,
            "opened_at": self.opened_at,
            "sl_order_placed": self.sl_order_placed,
            "tp_order_placed": self.tp_order_placed,
            "moved_to_be": self.moved_to_be,
            "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,
            direction=d.get("direction", 0),
            z_score=d.get("z_score", 0),
            score=d.get("score", 0),
            reasons=d.get("reasons", []),
            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.moved_to_be = d.get("moved_to_be", 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
        self.tmm = tmm  # TMMClient for journal tagging
        self.positions = {}
        self._symbol_info_cache = {}
        self._load_positions()

    # ============================================================
    # MAIN: Check watchlist for entry signals
    # ============================================================

    def check_watchlist_for_entries(self):
        """
        Проверяет watchlist — если score >= 3 и свежий, входим.
        Также перепроверяет combo signal на свежих данных.
        """
        watchlist = self.screener.get_watchlist()
        if not watchlist:
            return

        if len(self.positions) >= MAX_POSITIONS:
            return

        for entry in watchlist:
            symbol = entry["symbol"]

            if symbol in self.positions:
                continue
            if len(self.positions) >= MAX_POSITIONS:
                break

            try:
                # Пересчитываем combo signal на свежих данных
                klines = self.exchange.get_klines(symbol, TIMEFRAME, limit=300)
                if len(klines) < 100:
                    continue

                combo = calc_combo_signal(klines)
                if combo is None:
                    continue

                # Нужен score >= 3 для входа
                if combo['score'] < MIN_ENTRY_SCORE:
                    continue

                # Direction должен совпадать с watchlist
                if combo['direction'] != entry['direction']:
                    logger.info(f"Direction changed for {symbol}, removing from watchlist")
                    self.screener.remove_from_watchlist(symbol)
                    continue

                # ВХОД!
                side = "LONG" if combo['direction'] == 1 else "SHORT"
                self._open_position(
                    symbol=symbol,
                    side=side,
                    z_score=combo['zvwap']['z_score'],
                    score=combo['score'],
                    reasons=combo['reasons'],
                    direction=combo['direction'],
                )
                self.screener.remove_from_watchlist(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, z_score, score, reasons, direction):
        """Открыть позицию: market + SL + TP."""
        try:
            # Safety: check exchange
            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}, skipping")
                    return

            sym_info = self._get_symbol_info(symbol)
            if not sym_info:
                return

            actual_leverage = self.exchange.set_leverage(symbol) or LEVERAGE
            self.exchange.set_margin_type(symbol)

            # Calculate qty — keep target notional constant even if leverage capped
            mark_price = self.exchange.get_mark_price(symbol)
            target_notional = TRADE_SIZE_USD * LEVERAGE  # always $50 ($5×10x)
            qty = target_notional / 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 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}!")
                try:
                    close_side = "SELL" if order_side == "BUY" else "BUY"
                    self.exchange.close_position(symbol, close_side, qty)
                except Exception:
                    pass
                return

            # SL + TP1 (partial at 1.2%)
            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)

            pos = Position(
                symbol=symbol, side=side,
                entry_price=fill_price, qty=qty,
                sl_price=sl_price, tp_price=tp1_price,  # tp_price = TP1 level
                symbol_info=sym_info,
                direction=direction, z_score=z_score,
                score=score, reasons=reasons,
            )

            # TP1 qty = 50% of position
            tp1_qty = self.exchange.round_qty(sym_info, qty * TP1_CLOSE_RATIO)

            # Place SL (full) + TP1 (partial) on exchange
            self._place_sl_tp(pos, tp_qty=tp1_qty)

            self.positions[symbol] = pos
            self._save_positions()

            reasons_str = " | ".join(reasons[:3])
            msg = (
                f"{'🟢' if side == 'LONG' else '🔴'} *{side} {symbol}*\n"
                f"Score: {score}/5\n"
                f"Entry: {fill_price}\n"
                f"SL: {sl_price:.6f} (-{SL_PCT*100}%)\n"
                f"TP1: {tp1_price:.6f} (+{TP1_PCT*100}%) → 50% close\n"
                f"Trail: {TRAIL_CALLBACK_PCT*100}% callback after TP1\n"
                f"Z-VWAP: {z_score:+.2f}\n"
                f"Qty: {qty} (${target_notional:.0f} notional, {actual_leverage}x)\n"
                f"📋 {reasons_str}"
            )
            logger.info(f"OPEN {side} {symbol} score={score} Z={z_score:+.2f}")
            self._notify(msg)

            # TMM: tag trade in journal
            if self.tmm:
                try:
                    self.tmm.on_trade_opened(symbol, side, score, z_score, reasons)
                except Exception as te:
                    logger.warning(f"TMM tag error {symbol}: {te}")

        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):
        """
        Place SL and TP orders on exchange.
        tp_qty: qty for TP order (TP1 partial = 50%). None after TP1 = SL only.
        """
        sym_info = pos.symbol_info or self._get_symbol_info(pos.symbol)
        close_side = "SELL" if pos.side == "LONG" else "BUY"

        # SL — always on full current 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 failed {pos.symbol}: {e}")
            pos.sl_order_placed = False

        # TP — only pre-TP1 (after TP1: trailing handles exit)
        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 failed {pos.symbol}: {e}")
                pos.tp_order_placed = False
        else:
            pos.tp_order_placed = True  # suppress re-place warnings

    # ============================================================
    # MONITOR POSITIONS
    # ============================================================

    def check_positions(self):
        """
        Каждые 5 сек:
        1. Позиция закрыта? → лог
        2. TP1 partial fill detection → close 50%, SL→BE, start trailing
        3. Trailing: track peak, callback → close rest
        4. Dynamic TP (Z fair value) — only on remaining 50% after TP1
        5. Time stop — only on remaining 50% after TP1
        6. Order health check
        """
        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. Position fully closed ──
            if actual_qty == 0:
                result = self._determine_close_result(pos)
                self._log_trade(pos, result)
                closed.append(symbol)
                continue

            mark_price = float(ex_pos.get("markPrice", 0))
            if mark_price == 0:
                try:
                    mark_price = self.exchange.get_mark_price(symbol)
                except Exception:
                    continue

            # ── 2. TP1 detection: qty dropped = partial fill ──
            if not pos.tp1_hit:
                sym_info = pos.symbol_info or self._get_symbol_info(symbol)
                expected_after_tp1 = self.exchange.round_qty(
                    sym_info, pos.original_qty * (1 - TP1_CLOSE_RATIO)
                )
                if actual_qty <= expected_after_tp1 + 0.0001 and actual_qty < pos.qty - 0.0001:
                    pos.tp1_hit = True
                    pos.qty = actual_qty
                    pos.sl_price = pos.entry_price  # SL → BE
                    pos.moved_to_be = True
                    pos.trail_high = mark_price

                    # Cancel all, re-place SL at BE only (no TP — trailing now)
                    self.exchange.cancel_all_orders(symbol)
                    self._place_sl_tp(pos)
                    changed = True

                    tp1_pnl_usd = TRADE_SIZE_USD * LEVERAGE * TP1_PCT * TP1_CLOSE_RATIO
                    msg = (
                        f"🎯 *TP1 {pos.side} {symbol}* +{TP1_PCT*100:.1f}%\n"
                        f"Closed {TP1_CLOSE_RATIO*100:.0f}% (${tp1_pnl_usd:+.2f})\n"
                        f"SL → BE | Trail {TRAIL_CALLBACK_PCT*100}% on rest ({actual_qty})"
                    )
                    logger.info(f"TP1 {pos.side} {symbol} +{TP1_PCT*100:.1f}% | trail rest")
                    self._notify(msg)

            # ── 3. Trailing stop (after TP1) ──
            if pos.tp1_hit:
                if pos.side == "LONG":
                    if mark_price > pos.trail_high:
                        pos.trail_high = mark_price
                        changed = True
                    trail_sl = pos.trail_high * (1 - TRAIL_CALLBACK_PCT)
                    if mark_price <= trail_sl and pos.trail_high > pos.entry_price:
                        self._close_trailing(pos, mark_price)
                        closed.append(symbol)
                        continue
                else:  # SHORT
                    if pos.trail_high == 0 or mark_price < pos.trail_high:
                        pos.trail_high = mark_price
                        changed = True
                    trail_sl = pos.trail_high * (1 + TRAIL_CALLBACK_PCT)
                    if mark_price >= trail_sl and pos.trail_high < pos.entry_price:
                        self._close_trailing(pos, mark_price)
                        closed.append(symbol)
                        continue

            # ── 4. Dynamic TP: Z-VWAP returned to fair value (on remaining after TP1) ──
            if pos.tp1_hit:
                try:
                    klines = self.exchange.get_klines(symbol, TIMEFRAME, limit=100)
                    if len(klines) >= 60:
                        zvwap = calc_z_vwap(klines)
                        if zvwap:
                            current_z = zvwap['z_score']
                            if pos.side == "LONG" and current_z > -ZVWAP_EXIT_THRESHOLD:
                                pnl_pct = (mark_price - pos.entry_price) / pos.entry_price * 100
                                if pnl_pct > 0.2:
                                    logger.info(f"Dynamic TP (post-TP1) {symbol}: Z={current_z:.2f}")
                                    self._close_trailing(pos, mark_price, reason="DYNAMIC_TP")
                                    closed.append(symbol)
                                    continue
                            elif pos.side == "SHORT" and current_z < ZVWAP_EXIT_THRESHOLD:
                                pnl_pct = (pos.entry_price - mark_price) / pos.entry_price * 100
                                if pnl_pct > 0.2:
                                    logger.info(f"Dynamic TP (post-TP1) {symbol}: Z={current_z:.2f}")
                                    self._close_trailing(pos, mark_price, reason="DYNAMIC_TP")
                                    closed.append(symbol)
                                    continue
                except Exception as e:
                    logger.debug(f"Z-VWAP check error {symbol}: {e}")

            # ── 4b. Dynamic TP pre-TP1 (full position) ──
            if not pos.tp1_hit:
                try:
                    klines = self.exchange.get_klines(symbol, TIMEFRAME, limit=100)
                    if len(klines) >= 60:
                        zvwap = calc_z_vwap(klines)
                        if zvwap:
                            current_z = zvwap['z_score']
                            if pos.side == "LONG" and current_z > -ZVWAP_EXIT_THRESHOLD:
                                pnl_pct = (mark_price - pos.entry_price) / pos.entry_price * 100
                                if pnl_pct > 0.2:
                                    logger.info(f"Dynamic TP {symbol}: Z={current_z:.2f}")
                                    self._close_position_market(pos, "DYNAMIC_TP", mark_price)
                                    closed.append(symbol)
                                    continue
                            elif pos.side == "SHORT" and current_z < ZVWAP_EXIT_THRESHOLD:
                                pnl_pct = (pos.entry_price - mark_price) / pos.entry_price * 100
                                if pnl_pct > 0.2:
                                    logger.info(f"Dynamic TP {symbol}: Z={current_z:.2f}")
                                    self._close_position_market(pos, "DYNAMIC_TP", mark_price)
                                    closed.append(symbol)
                                    continue
                except Exception as e:
                    logger.debug(f"Z-VWAP check error {symbol}: {e}")

            # ── 5. Time stop — DISABLED (let trailing handle exit) ──

            # ── 6. Order health check (pre-TP1 only, every 60s) ──
            if not pos.tp1_hit and pos.sl_order_placed and pos.tp_order_placed:
                last_check = getattr(pos, '_last_order_check', 0)
                now_ts = time.time()
                if now_ts - last_check >= 60:
                    pos._last_order_check = now_ts
                    try:
                        open_orders = self.exchange.get_open_orders(symbol)
                        if open_orders is not None and len(open_orders) == 0:
                            logger.warning(f"Orders missing {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
                        elif open_orders:
                            has_limit = any(o["type"] == "LIMIT" for o in open_orders)
                            if not has_limit:
                                logger.warning(f"TP missing {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
        for symbol in closed:
            if symbol in self.positions:
                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, reason="TRAIL"):
        """Close remaining position after TP1 (trailing/dynamic TP/time 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
            total_pnl_pct = TP1_PCT * 100 * TP1_CLOSE_RATIO + trail_pnl_pct * remaining_ratio

            trade = {
                "symbol": pos.symbol,
                "side": pos.side,
                "entry_price": pos.entry_price,
                "close_price": fill_price,
                "sl_price": pos.sl_price,
                "tp_price": pos.tp_price,
                "qty": pos.original_qty,
                "result": reason,
                "pnl_pct": round(total_pnl_pct, 2),
                "pnl_usd": round(total_pnl_usd, 2),
                "trail_high": pos.trail_high,
                "tp1_hit": True,
                "z_score_entry": pos.z_score,
                "score": pos.score,
                "reasons": pos.reasons,
                "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} *{reason} {pos.side} {pos.symbol}*\n"
                f"TP1: +{TP1_PCT*100:.1f}% (${tp1_pnl_usd:+.2f})\n"
                f"Rest: {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"{reason} {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, f"{reason}_ERROR")

    def _close_position_market(self, pos, reason, mark_price):
        """Close position via market order (for dynamic TP / time 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":
                pnl_pct = (fill_price - pos.entry_price) / pos.entry_price * 100
            else:
                pnl_pct = (pos.entry_price - fill_price) / pos.entry_price * 100

            pnl_usd = TRADE_SIZE_USD * LEVERAGE * (pnl_pct / 100)

            self._log_trade_manual(pos, reason, pnl_pct, pnl_usd, fill_price)
        except Exception as e:
            logger.error(f"Market close failed {pos.symbol}: {e}")
            self._log_trade(pos, reason)

    def _determine_close_result(self, pos):
        """SL or TP? Check if TP order still hanging → SL hit."""
        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"

            try:
                mark = self.exchange.get_mark_price(pos.symbol)
                dist_tp = abs(mark - pos.tp_price)
                dist_sl = abs(mark - pos.sl_price)
                return "TP" if dist_tp < dist_sl else "SL"
            except Exception:
                return "TP"
        except Exception:
            return "UNKNOWN"

    # ============================================================
    # TRADE LOG
    # ============================================================

    def _log_trade_manual(self, pos, result, pnl_pct, pnl_usd, close_price):
        """Log trade with exact PnL (for market closes)."""
        trade = {
            "symbol": pos.symbol,
            "side": pos.side,
            "entry_price": pos.entry_price,
            "close_price": close_price,
            "sl_price": pos.sl_price,
            "tp_price": pos.tp_price,
            "qty": pos.qty,
            "result": result,
            "pnl_pct": round(pnl_pct, 2),
            "pnl_usd": round(pnl_usd, 2),
            "z_score_entry": pos.z_score,
            "score": pos.score,
            "reasons": pos.reasons,
            "opened_at": pos.opened_at,
            "closed_at": datetime.now(timezone.utc).isoformat(),
        }
        self._append_trade_log(trade)

        emoji = "🎯" if result == "DYNAMIC_TP" else "⏱️" if result == "TIME_STOP" else "⚠️"
        msg = (
            f"{emoji} *{result} {pos.side} {pos.symbol}*\n"
            f"Entry: {pos.entry_price} → {close_price}\n"
            f"PnL: {pnl_pct:+.1f}% (${pnl_usd:+.2f})\n"
            f"Z entry: {pos.z_score:+.2f} | Score: {pos.score}/5"
        )
        logger.info(f"{result} {pos.side} {pos.symbol} PnL={pnl_pct:+.1f}%")
        self._notify(msg)

    def _log_trade(self, pos, result):
        """Log trade with estimated PnL (for exchange-closed positions)."""
        if pos.tp1_hit:
            # TP1 already taken — remaining closed by SL@BE or SL
            tp1_pnl_usd = TRADE_SIZE_USD * LEVERAGE * TP1_CLOSE_RATIO * TP1_PCT
            remaining_ratio = 1 - TP1_CLOSE_RATIO
            if result == "SL":
                # SL@BE: remaining at entry → 0 PnL on rest
                rest_pnl_usd = -(TRADE_SIZE_USD * LEVERAGE * remaining_ratio * SL_PCT)
                total_pnl_usd = tp1_pnl_usd + rest_pnl_usd
                total_pnl_pct = TP1_PCT * 100 * TP1_CLOSE_RATIO - SL_PCT * 100 * remaining_ratio
                result = "TP1+SL"
            else:
                # BE or unknown — assume rest at 0
                total_pnl_usd = tp1_pnl_usd
                total_pnl_pct = TP1_PCT * 100 * TP1_CLOSE_RATIO
                result = "TP1+BE"
        elif result == "TP":
            total_pnl_pct = MAX_TP_PCT * 100
            total_pnl_usd = TRADE_SIZE_USD * LEVERAGE * MAX_TP_PCT
        elif result == "SL":
            total_pnl_pct = -SL_PCT * 100
            total_pnl_usd = -(TRADE_SIZE_USD * LEVERAGE * SL_PCT)
        else:
            total_pnl_pct = 0
            total_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(total_pnl_pct, 2),
            "pnl_usd": round(total_pnl_usd, 2),
            "tp1_hit": pos.tp1_hit,
            "z_score_entry": pos.z_score,
            "score": pos.score,
            "opened_at": pos.opened_at,
            "closed_at": datetime.now(timezone.utc).isoformat(),
        }
        self._append_trade_log(trade)

        emoji = "✅" if "TP" in result else "❌" if result == "SL" else "⚠️"
        msg = (
            f"{emoji} *{result} {pos.side} {pos.symbol}*\n"
            f"Entry: {pos.entry_price}\n"
            f"PnL: {total_pnl_pct:+.1f}% (${total_pnl_usd:+.2f})\n"
            f"Z entry: {pos.z_score:+.2f} | Score: {pos.score}/5"
        )
        logger.info(f"{result} {pos.side} {pos.symbol} PnL={total_pnl_pct:+.1f}%")
        self._notify(msg)

    def _append_trade_log(self, trade):
        """Atomic append to trade log."""
        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}")

    # ============================================================
    # STATS
    # ============================================================

    def get_pnl_summary(self):
        """PnL summary from 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

        # By result type
        dynamic_tps = [t for t in log if t.get("result") == "DYNAMIC_TP"]
        time_stops = [t for t in log if t.get("result") == "TIME_STOP"]
        sl_hits = [t for t in log if t.get("result") == "SL"]
        tp_caps = [t for t in log if t.get("result") == "TP"]

        return (
            f"📊 *Squeeze-VWAP PnL*\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"🎯 Dynamic TP: {len(dynamic_tps)} | "
            f"⏱️ Time Stop: {len(time_stops)}\n"
            f"❌ SL: {len(sl_hits)} | ✅ TP Cap: {len(tp_caps)}"
        )

    def get_positions_info(self):
        """Info about open positions."""
        if not self.positions:
            return "No open positions."

        lines = ["📊 *Open Positions*\n"]
        for symbol, pos in self.positions.items():
            try:
                mark = self.exchange.get_mark_price(symbol)
                if pos.side == "LONG":
                    pnl = (mark - pos.entry_price) / pos.entry_price * 100
                else:
                    pnl = (pos.entry_price - mark) / pos.entry_price * 100

                age = ""
                try:
                    opened = datetime.fromisoformat(pos.opened_at)
                    age_min = (datetime.now(timezone.utc) - opened).total_seconds() / 60
                    age = f" ({age_min:.0f}min)"
                except Exception:
                    pass

                tp1_flag = " 🎯TP1" if pos.tp1_hit else ""
                be_flag = " 🔄BE" if pos.moved_to_be else ""
                trail_info = f" trail:{pos.trail_high:.4f}" if pos.tp1_hit else ""
                lines.append(
                    f"{'🟢' if pos.side == 'LONG' else '🔴'} {symbol} "
                    f"PnL:{pnl:+.1f}%{tp1_flag}{be_flag}{age}\n"
                    f"  Z:{pos.z_score:+.2f} Score:{pos.score}/5{trail_info}"
                )
            except Exception:
                lines.append(f"{'🟢' if pos.side == 'LONG' else '🔴'} {symbol}")

        return "\n".join(lines)

    # ============================================================
    # PERSISTENCE
    # ============================================================

    def _save_positions(self):
        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")
        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
    # ============================================================

    def recovery(self):
        """Check saved positions vs exchange after restart."""
        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:
                # Update qty + detect TP1 partial fill
                for ep in exchange_positions:
                    if ep["symbol"] == symbol:
                        actual_qty = abs(float(ep["positionAmt"]))
                        if actual_qty > 0 and actual_qty < pos.original_qty * 0.75 and not pos.tp1_hit:
                            # Qty dropped significantly → TP1 was hit
                            pos.tp1_hit = True
                            pos.qty = actual_qty
                            pos.sl_price = pos.entry_price  # SL → BE
                            pos.moved_to_be = True
                            mark = float(ep.get("markPrice", 0))
                            pos.trail_high = mark if mark > 0 else pos.entry_price
                            logger.info(f"Recovery: {symbol} TP1 detected (qty {pos.original_qty}→{actual_qty})")
                        elif actual_qty != pos.qty and actual_qty > 0:
                            pos.qty = actual_qty
                        break
                # Re-place orders
                logger.info(f"Recovery: re-placing orders for {symbol}")
                self.exchange.cancel_all_orders(symbol)
                if pos.tp1_hit:
                    self._place_sl_tp(pos)  # SL only (no TP after TP1)
                else:
                    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)

        for symbol in closed:
            del self.positions[symbol]
            self.exchange.cancel_all_orders(symbol)

        if closed:
            self._save_positions()

    def get_open_positions(self):
        """Return positions dict (for screener callback)."""
        return self.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...