← Back
β˜†
"""
Position Manager β€” monitors open positions and executes TP/SL strategy.

Strategy: Variant C Hybrid
  Entry β†’ signal from bot
  SL: -1.5%
  TP1: +2% β†’ close 50%, move SL to BE (0%)
  TP2: +3% β†’ close 25% (50% of remaining), move SL to +1.5%
  TP3: +5% β†’ close remaining 25%
"""

import asyncio
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta

from trader import BinanceFuturesTrader
from trade_log import log_event, get_open_trades, get_tp_state
from order_placer import ExchangeOrderManager, MAKER_FEE_PCT

logger = logging.getLogger(__name__)

VANCOUVER_TZ = timezone(timedelta(hours=-7))

# === Strategy Config (from env or defaults) ===
TRADE_SIZE_USDT = float(os.environ.get("TRADE_SIZE_USDT", "10"))
MAX_LEVERAGE = int(os.environ.get("MAX_LEVERAGE", "5"))
MAX_OPEN_POSITIONS = int(os.environ.get("MAX_OPEN_POSITIONS", "3"))

SL_PERCENT = float(os.environ.get("SL_PERCENT", "1.5"))
TP1_PERCENT = float(os.environ.get("TP1_PERCENT", "2.0"))
TP2_PERCENT = float(os.environ.get("TP2_PERCENT", "3.0"))
TP3_PERCENT = float(os.environ.get("TP3_PERCENT", "5.0"))

TP1_CLOSE_RATIO = 0.50   # Close 50% of total at TP1
TP2_CLOSE_RATIO = 0.50   # Close 50% of remaining at TP2 (= 25% of total)
# TP3 = close everything remaining

SL_AFTER_TP1_PCT = 0.0   # Move SL to breakeven after TP1
SL_AFTER_TP2_PCT = 1.5   # Move SL to +1.5% after TP2

PRICE_CHECK_INTERVAL = int(os.environ.get("PRICE_CHECK_INTERVAL", "3"))

# Exchange-side TP/SL
WT_USE_EXCHANGE_ORDERS = os.environ.get("WT_USE_EXCHANGE_ORDERS", "true").lower() == "true"

# Binance Futures taker fee: 0.04% per side (open + close = 0.08% total)
TAKER_FEE_PCT = 0.04


def safe_pnl_pct(entry_price: float, current_price: float, side: str) -> float:
    """Calculate PnL % safely (no division by zero)."""
    if entry_price <= 0:
        return 0.0
    if side == "BUY":
        return ((current_price - entry_price) / entry_price) * 100
    else:
        return ((entry_price - current_price) / entry_price) * 100


@dataclass
class Position:
    """Active trading position."""
    symbol: str
    side: str               # "BUY" (long) or "SELL" (short)
    entry_price: float
    total_quantity: float
    remaining_quantity: float

    sl_price: float
    tp1_price: float
    tp2_price: float
    tp3_price: float

    tp1_hit: bool = False
    tp2_hit: bool = False

    opened_at: str = ""
    signal_data: dict = field(default_factory=dict)
    trade_id: str = ""

    # Track realized PnL from partial closes
    realized_pnl: float = 0.0

    # Exchange-side order IDs
    sl_order_id: int | None = None
    tp1_order_id: int | None = None
    tp2_order_id: int | None = None
    tp3_order_id: int | None = None
    use_exchange_orders: bool = False


def calculate_levels(entry_price: float, side: str) -> dict:
    """
    Calculate SL and TP price levels.

    For LONG (BUY):
      SL = entry * (1 - SL%)
      TP = entry * (1 + TP%)

    For SHORT (SELL):
      SL = entry * (1 + SL%)
      TP = entry * (1 - TP%)
    """
    if side == "BUY":
        sl = entry_price * (1 - SL_PERCENT / 100)
        tp1 = entry_price * (1 + TP1_PERCENT / 100)
        tp2 = entry_price * (1 + TP2_PERCENT / 100)
        tp3 = entry_price * (1 + TP3_PERCENT / 100)
    else:  # SELL (short)
        sl = entry_price * (1 + SL_PERCENT / 100)
        tp1 = entry_price * (1 - TP1_PERCENT / 100)
        tp2 = entry_price * (1 - TP2_PERCENT / 100)
        tp3 = entry_price * (1 - TP3_PERCENT / 100)

    return {"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3}


class PositionManager:
    """Manages all open positions and runs the monitor loop."""

    def __init__(self, trader: BinanceFuturesTrader, notify_fn, tmm=None):
        """
        Args:
            trader: authenticated Binance client
            notify_fn: async function to send Telegram alerts
            tmm: optional TMMClient for journal integration
        """
        self.trader = trader
        self.notify = notify_fn
        self.tmm = tmm
        self.positions: dict[str, Position] = {}  # symbol -> Position
        self.order_mgr = ExchangeOrderManager(trader)

    def can_open_position(self, symbol: str) -> tuple[bool, str]:
        """Check if we can open a new position."""
        if symbol in self.positions:
            return False, f"Already have position in {symbol}"

        # Double-check Binance β€” prevents duplicates after restarts
        existing = self.trader.get_position(symbol)
        if existing:
            logger.warning(f"WT {symbol}: position exists on Binance but not in manager β€” skip")
            return False, f"Position exists on Binance (untracked)"

        if len(self.positions) >= MAX_OPEN_POSITIONS:
            return False, f"Max positions reached ({MAX_OPEN_POSITIONS})"

        balance = self.trader.get_account_balance()
        if balance < TRADE_SIZE_USDT:
            return False, f"Insufficient balance: ${balance:.2f} < ${TRADE_SIZE_USDT}"

        return True, "OK"

    async def open_trade(self, symbol: str, side: str, signal_data: dict) -> bool:
        """
        Open a new trade.

        Returns True if position was opened successfully.
        """
        can_open, reason = self.can_open_position(symbol)
        if not can_open:
            logger.info(f"Cannot open {symbol}: {reason}")
            await self.notify(f"⏭ Skip {symbol}: {reason}")
            return False

        # Execute on Binance
        result = self.trader.open_position(symbol, side, TRADE_SIZE_USDT, MAX_LEVERAGE)
        if not result:
            await self.notify(f"❌ Failed to open {side} {symbol}")
            log_event("ERROR", {"symbol": symbol, "side": side, "error": "open_position failed"})
            return False

        entry_price = result["fill_price"]
        quantity = result["quantity"]

        # Calculate levels
        levels = calculate_levels(entry_price, side)

        now = datetime.now(VANCOUVER_TZ)
        trade_id = f"{symbol}_{now.strftime('%Y%m%d_%H%M%S')}"

        # Create position object
        pos = Position(
            symbol=symbol,
            side=side,
            entry_price=entry_price,
            total_quantity=quantity,
            remaining_quantity=quantity,
            sl_price=levels["sl"],
            tp1_price=levels["tp1"],
            tp2_price=levels["tp2"],
            tp3_price=levels["tp3"],
            opened_at=now.isoformat(),
            signal_data=signal_data,
            trade_id=trade_id,
        )

        # Deduct open commission from realized PnL (so total PnL matches exchange)
        open_fee = quantity * entry_price * TAKER_FEE_PCT / 100
        pos.realized_pnl = -open_fee

        self.positions[symbol] = pos

        # Place exchange-side TP/SL orders
        if WT_USE_EXCHANGE_ORDERS:
            self._place_initial_orders(pos)

        # Log entry (include WT signal data for post-analysis)
        log_event("ENTRY", {
            "trade_id": trade_id,
            "symbol": symbol,
            "side": side,
            "entry_price": entry_price,
            "quantity": quantity,
            "leverage": MAX_LEVERAGE,
            "margin_usdt": TRADE_SIZE_USDT,
            "sl_price": levels["sl"],
            "tp1_price": levels["tp1"],
            "tp2_price": levels["tp2"],
            "tp3_price": levels["tp3"],
            "exchange_orders": pos.use_exchange_orders,
            "wt_15m_signal": signal_data.get("wt_15m_signal", ""),
            "wt1_15m": signal_data.get("wt1_15m", 0),
            "wt1_1h": signal_data.get("wt1_1h", 0),
            "wt_1h_signal": signal_data.get("wt_1h_signal", ""),
        })

        # Notify Rick
        direction = "LONG" if side == "BUY" else "SHORT"
        order_tag = " [EX]" if pos.use_exchange_orders else ""
        notional = quantity * entry_price
        msg = (
            f"{'🟒' if side == 'BUY' else 'πŸ”΄'} TRADE OPENED: {direction} {symbol}{order_tag}\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"πŸ’° Entry: ${entry_price:.6f}\n"
            f"πŸ“Š Size: {quantity} ({MAX_LEVERAGE}x, ${notional:.2f})\n"
            f"πŸ›‘ SL: ${levels['sl']:.6f} (-{SL_PERCENT}%)\n"
            f"🎯 TP1: ${levels['tp1']:.6f} (+{TP1_PERCENT}%) β†’ 50%\n"
            f"🎯 TP2: ${levels['tp2']:.6f} (+{TP2_PERCENT}%) β†’ 25%\n"
            f"🎯 TP3: ${levels['tp3']:.6f} (+{TP3_PERCENT}%) β†’ 25%\n"
            f"━━━━━━━━━━━━━━━━━━━━"
        )
        await self.notify(msg)

        logger.info(f"Trade opened: {direction} {symbol} @ {entry_price}, qty={quantity}")

        # TMM journal: tag trade
        if self.tmm:
            wt_sig = signal_data.get("wt_15m_signal", "")
            self.tmm.on_trade_opened(symbol, side, "WT",
                signal_info=f"[WT] {direction} via {wt_sig}")

        return True

    async def open_trade_with_levels(
        self,
        symbol: str,
        side: str,
        sl_price: float,
        tp1_price: float,
        tp2_price: float,
        tp3_price: float,
        signal_data: dict,
    ) -> bool:
        """
        Open a trade with custom SL/TP levels (for Digash formations).

        Instead of calculating levels from fixed percentages, uses
        specific price levels based on formation analysis.
        """
        can_open, reason = self.can_open_position(symbol)
        if not can_open:
            logger.info(f"Cannot open {symbol}: {reason}")
            await self.notify(f"⏭ Skip {symbol}: {reason}")
            return False

        # Execute on Binance
        result = self.trader.open_position(symbol, side, TRADE_SIZE_USDT, MAX_LEVERAGE)
        if not result:
            await self.notify(f"❌ Failed to open {side} {symbol}")
            log_event("ERROR", {"symbol": symbol, "side": side, "error": "open_position failed"})
            return False

        entry_price = result["fill_price"]
        quantity = result["quantity"]

        now = datetime.now(VANCOUVER_TZ)
        trade_id = f"DG_{symbol}_{now.strftime('%Y%m%d_%H%M%S')}"

        pos = Position(
            symbol=symbol,
            side=side,
            entry_price=entry_price,
            total_quantity=quantity,
            remaining_quantity=quantity,
            sl_price=sl_price,
            tp1_price=tp1_price,
            tp2_price=tp2_price,
            tp3_price=tp3_price,
            opened_at=now.isoformat(),
            signal_data=signal_data,
            trade_id=trade_id,
        )

        # Deduct open commission
        open_fee = quantity * entry_price * TAKER_FEE_PCT / 100
        pos.realized_pnl = -open_fee

        # Place exchange-side TP/SL orders
        if WT_USE_EXCHANGE_ORDERS:
            self._place_initial_orders(pos)

        self.positions[symbol] = pos

        # Calculate SL/TP distances for display
        sl_dist = safe_pnl_pct(entry_price, sl_price, side)
        tp1_dist = safe_pnl_pct(entry_price, tp1_price, "SELL" if side == "BUY" else "BUY")
        tp3_dist = safe_pnl_pct(entry_price, tp3_price, "SELL" if side == "BUY" else "BUY")

        log_event("ENTRY", {
            "trade_id": trade_id,
            "symbol": symbol,
            "side": side,
            "entry_price": entry_price,
            "quantity": quantity,
            "leverage": MAX_LEVERAGE,
            "margin_usdt": TRADE_SIZE_USDT,
            "sl_price": sl_price,
            "tp1_price": tp1_price,
            "tp2_price": tp2_price,
            "tp3_price": tp3_price,
            "source": "digash",
            "formation": signal_data.get("formation", ""),
        })

        direction = "LONG" if side == "BUY" else "SHORT"
        notional = quantity * entry_price
        formation = signal_data.get("formation", "?")
        msg = (
            f"{'🟒' if side == 'BUY' else 'πŸ”΄'} DIGASH {direction} {symbol}\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"πŸ“ Formation: {formation}\n"
            f"πŸ’° Entry: ${entry_price:.6f}\n"
            f"πŸ“Š Size: {quantity} ({MAX_LEVERAGE}x, ${notional:.2f})\n"
            f"πŸ›‘ SL: ${sl_price:.6f} ({sl_dist:+.2f}%)\n"
            f"🎯 TP1: ${tp1_price:.6f} β†’ 50%\n"
            f"🎯 TP2: ${tp2_price:.6f} β†’ 25%\n"
            f"🎯 TP3: ${tp3_price:.6f} β†’ 25%\n"
            f"━━━━━━━━━━━━━━━━━━━━"
        )
        await self.notify(msg)

        logger.info(f"Digash trade opened: {direction} {symbol} @ {entry_price}, formation={formation}")
        return True

    def _place_initial_orders(self, pos: Position):
        """Place TP1/TP2/TP3 + SL orders on Binance after position opens."""
        tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO)
        tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * TP2_CLOSE_RATIO)
        tp3_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity - tp1_qty - tp2_qty)

        orders = self.order_mgr.place_tp_sl_orders(
            pos.symbol, pos.side,
            sl_price=pos.sl_price,
            sl_quantity=pos.total_quantity,
            tp_levels=[
                (pos.tp1_price, tp1_qty),
                (pos.tp2_price, tp2_qty),
                (pos.tp3_price, tp3_qty),
            ],
        )

        pos.sl_order_id = orders["sl_order_id"]
        ids = orders["tp_order_ids"]
        pos.tp1_order_id = ids[0] if len(ids) > 0 else None
        pos.tp2_order_id = ids[1] if len(ids) > 1 else None
        pos.tp3_order_id = ids[2] if len(ids) > 2 else None

        pos.use_exchange_orders = bool(pos.sl_order_id and pos.tp1_order_id)
        if pos.use_exchange_orders:
            logger.info(f"WT {pos.symbol}: exchange orders placed (SL #{pos.sl_order_id}, TP1 #{pos.tp1_order_id}, TP2 #{pos.tp2_order_id}, TP3 #{pos.tp3_order_id})")
        else:
            logger.warning(f"WT {pos.symbol}: exchange orders failed, using polling fallback")

    def _place_recovery_orders(self, pos: Position):
        """Place exchange orders for a recovered position (respects TP state)."""
        remaining = pos.remaining_quantity

        # Build TP levels based on what hasn't been hit yet
        tp_levels = []
        if pos.tp2_hit:
            # Only TP3 remaining
            tp_levels.append((pos.tp3_price, remaining))
        elif pos.tp1_hit:
            # TP2 + TP3 remaining
            tp2_qty = self.trader.round_quantity(pos.symbol, remaining * TP2_CLOSE_RATIO)
            tp3_qty = self.trader.round_quantity(pos.symbol, remaining - tp2_qty)
            if tp2_qty > 0:
                tp_levels.append((pos.tp2_price, tp2_qty))
            if tp3_qty > 0:
                tp_levels.append((pos.tp3_price, tp3_qty))
        else:
            # All TPs remaining β€” use remaining_quantity (not total)
            tp1_qty = self.trader.round_quantity(pos.symbol, remaining * TP1_CLOSE_RATIO)
            tp2_qty = self.trader.round_quantity(pos.symbol, (remaining - tp1_qty) * TP2_CLOSE_RATIO)
            tp3_qty = self.trader.round_quantity(pos.symbol, remaining - tp1_qty - tp2_qty)
            if tp1_qty > 0:
                tp_levels.append((pos.tp1_price, tp1_qty))
            if tp2_qty > 0:
                tp_levels.append((pos.tp2_price, tp2_qty))
            if tp3_qty > 0:
                tp_levels.append((pos.tp3_price, tp3_qty))

        orders = self.order_mgr.place_tp_sl_orders(
            pos.symbol, pos.side,
            sl_price=pos.sl_price,
            sl_quantity=remaining,
            tp_levels=tp_levels,
        )

        pos.sl_order_id = orders["sl_order_id"]
        ids = orders["tp_order_ids"]

        # Assign TP order IDs based on state
        if pos.tp2_hit:
            pos.tp3_order_id = ids[0] if len(ids) > 0 else None
        elif pos.tp1_hit:
            pos.tp2_order_id = ids[0] if len(ids) > 0 else None
            pos.tp3_order_id = ids[1] if len(ids) > 1 else None
        else:
            pos.tp1_order_id = ids[0] if len(ids) > 0 else None
            pos.tp2_order_id = ids[1] if len(ids) > 1 else None
            pos.tp3_order_id = ids[2] if len(ids) > 2 else None

        pos.use_exchange_orders = bool(pos.sl_order_id and len(ids) > 0)
        if pos.use_exchange_orders:
            logger.info(f"WT {pos.symbol}: recovery orders placed (SL #{pos.sl_order_id}, TPs: {ids})")
        else:
            logger.warning(f"WT {pos.symbol}: recovery orders failed, using polling fallback")

    async def _handle_sl(self, pos: Position, current_price: float):
        """Handle stop loss hit."""
        # Close everything
        result = self.trader.close_full(pos.symbol, pos.side)

        # Use actual fill price from Binance
        fill_price = current_price  # fallback
        if result and result.get("fill_price"):
            fill_price = result["fill_price"]
            logger.info(f"SL close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f} slip={((fill_price - current_price) / current_price * 100):+.3f}%")

        # Calculate PnL (with commission) using actual fill price
        pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)

        pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price)
        if pnl_pct < 0:
            pnl_usdt = -pnl_usdt

        # Subtract close commission (open commission already paid)
        close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100
        pnl_usdt -= close_fee

        total_pnl = pos.realized_pnl + pnl_usdt

        log_event("SL_HIT", {
            "trade_id": pos.trade_id,
            "symbol": pos.symbol,
            "side": pos.side,
            "entry_price": pos.entry_price,
            "exit_price": fill_price,
            "mark_price": current_price,
            "slippage_pct": round((fill_price - current_price) / current_price * 100, 3) if current_price else 0,
            "closed_quantity": pos.remaining_quantity,
            "pnl_pct": round(pnl_pct, 2),
            "realized_pnl_usdt": round(pnl_usdt, 2),
            "total_trade_pnl_usdt": round(total_pnl, 2),
            "tp1_was_hit": pos.tp1_hit,
            "tp2_was_hit": pos.tp2_hit,
        })

        sl_type = "BE" if pos.tp1_hit else "SL"
        emoji = "🟑" if pos.tp1_hit else "πŸ”΄"

        slip_line = ""
        if abs(fill_price - current_price) / current_price > 0.001:
            slip_line = f"⚠️ Slip: {((fill_price - current_price) / current_price * 100):+.2f}%\n"

        msg = (
            f"{emoji} {sl_type} HIT: {pos.symbol}\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"Entry: ${pos.entry_price:.6f}\n"
            f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n"
            f"{slip_line}"
            f"πŸ’΅ PnL этой части: ${pnl_usdt:+.2f}\n"
            f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ ΠΏΠΎ сдСлкС: ${total_pnl:+.2f}\n"
            f"{'βœ… TP1 Π±Ρ‹Π» взят' if pos.tp1_hit else ''}"
            f"{'βœ… TP2 Π±Ρ‹Π» взят' if pos.tp2_hit else ''}\n"
            f"━━━━━━━━━━━━━━━━━━━━"
        )
        await self.notify(msg)

        del self.positions[pos.symbol]
        logger.info(f"SL hit for {pos.symbol}: {pnl_pct:+.2f}%, PnL ${total_pnl:+.2f}")

    async def _handle_tp1(self, pos: Position, current_price: float):
        """Handle TP1: close 50%, move SL to BE."""
        close_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO)
        if close_qty <= 0:
            close_qty = pos.remaining_quantity  # fallback: close all

        result = self.trader.close_partial(pos.symbol, pos.side, close_qty)
        if not result:
            logger.error(f"Failed to close partial at TP1 for {pos.symbol}")
            return

        # Use actual fill price from Binance
        fill_price = result.get("fill_price") or current_price
        if fill_price != current_price:
            logger.info(f"TP1 close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}")

        # Update position
        pos.tp1_hit = True
        pos.remaining_quantity -= close_qty

        # Calculate partial PnL (with commission) using actual fill price
        pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)

        pnl_usdt = close_qty * abs(fill_price - pos.entry_price)
        close_fee = close_qty * fill_price * TAKER_FEE_PCT / 100
        pnl_usdt -= close_fee
        pos.realized_pnl += pnl_usdt

        # Move SL to breakeven
        pos.sl_price = pos.entry_price  # BE = entry price

        log_event("TP1_HIT", {
            "trade_id": pos.trade_id,
            "symbol": pos.symbol,
            "price": fill_price,
            "closed_quantity": close_qty,
            "remaining_quantity": pos.remaining_quantity,
            "pnl_pct": round(pnl_pct, 2),
            "realized_pnl_usdt": round(pnl_usdt, 2),
            "new_sl_price": pos.sl_price,
            "new_sl_pct": SL_AFTER_TP1_PCT,
        })

        msg = (
            f"🎯 TP1 HIT: {pos.symbol} (+{TP1_PERCENT}%)\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: 50% ({close_qty})\n"
            f"πŸ’΅ +${pnl_usdt:.2f}\n"
            f"πŸ›‘ SL β†’ BE (${pos.entry_price:.6f})\n"
            f"ΠžΡΡ‚Π°Π»ΠΎΡΡŒ: {pos.remaining_quantity} β†’ TP2/TP3\n"
            f"━━━━━━━━━━━━━━━━━━━━"
        )
        await self.notify(msg)

        logger.info(f"TP1 hit for {pos.symbol}: closed {close_qty}, SL β†’ BE")

    async def _handle_tp2(self, pos: Position, current_price: float):
        """Handle TP2: close 50% of remaining (25% of total), move SL to +1.5%."""
        close_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO)
        if close_qty <= 0:
            close_qty = pos.remaining_quantity

        result = self.trader.close_partial(pos.symbol, pos.side, close_qty)
        if not result:
            logger.error(f"Failed to close partial at TP2 for {pos.symbol}")
            return

        # Use actual fill price from Binance
        fill_price = result.get("fill_price") or current_price
        if fill_price != current_price:
            logger.info(f"TP2 close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}")

        pos.tp2_hit = True
        pos.remaining_quantity -= close_qty

        # Calculate partial PnL (with commission) using actual fill price
        pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)

        pnl_usdt = close_qty * abs(fill_price - pos.entry_price)
        close_fee = close_qty * fill_price * TAKER_FEE_PCT / 100
        pnl_usdt -= close_fee
        pos.realized_pnl += pnl_usdt

        # Move SL to +1.5%
        if pos.side == "BUY":
            pos.sl_price = pos.entry_price * (1 + SL_AFTER_TP2_PCT / 100)
        else:
            pos.sl_price = pos.entry_price * (1 - SL_AFTER_TP2_PCT / 100)

        log_event("TP2_HIT", {
            "trade_id": pos.trade_id,
            "symbol": pos.symbol,
            "price": current_price,
            "closed_quantity": close_qty,
            "remaining_quantity": pos.remaining_quantity,
            "pnl_pct": round(pnl_pct, 2),
            "realized_pnl_usdt": round(pnl_usdt, 2),
            "new_sl_price": pos.sl_price,
            "new_sl_pct": SL_AFTER_TP2_PCT,
        })

        msg = (
            f"🎯🎯 TP2 HIT: {pos.symbol} (+{TP2_PERCENT}%)\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: Π΅Ρ‰Ρ‘ 25% ({close_qty})\n"
            f"πŸ’΅ +${pnl_usdt:.2f}\n"
            f"πŸ›‘ SL β†’ +{SL_AFTER_TP2_PCT}% (${pos.sl_price:.6f})\n"
            f"ΠžΡΡ‚Π°Π»ΠΎΡΡŒ: {pos.remaining_quantity} β†’ TP3\n"
            f"━━━━━━━━━━━━━━━━━━━━"
        )
        await self.notify(msg)

        logger.info(f"TP2 hit for {pos.symbol}: closed {close_qty}, SL β†’ +{SL_AFTER_TP2_PCT}%")

    async def _handle_tp3(self, pos: Position, current_price: float):
        """Handle TP3: close everything remaining."""
        result = self.trader.close_full(pos.symbol, pos.side)

        # Use actual fill price from Binance
        fill_price = current_price  # fallback
        if result and result.get("fill_price"):
            fill_price = result["fill_price"]
            logger.info(f"TP3 close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}")

        # Calculate PnL (with commission) using actual fill price
        pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)

        pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price)
        close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100
        pnl_usdt -= close_fee
        total_pnl = pos.realized_pnl + pnl_usdt

        log_event("TP3_HIT", {
            "trade_id": pos.trade_id,
            "symbol": pos.symbol,
            "side": pos.side,
            "entry_price": pos.entry_price,
            "exit_price": fill_price,
            "mark_price": current_price,
            "closed_quantity": pos.remaining_quantity,
            "pnl_pct": round(pnl_pct, 2),
            "realized_pnl_usdt": round(pnl_usdt, 2),
            "total_trade_pnl_usdt": round(total_pnl, 2),
        })

        msg = (
            f"πŸ† TP3 FULL HIT: {pos.symbol} (+{TP3_PERCENT}%)\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"Entry: ${pos.entry_price:.6f}\n"
            f"Exit: ${fill_price:.6f}\n"
            f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ PnL: ${total_pnl:+.2f}\n"
            f"━━━━━━━━━━━━━━━━━━━━"
        )
        await self.notify(msg)

        del self.positions[pos.symbol]
        logger.info(f"TP3 full hit for {pos.symbol}: total PnL ${total_pnl:+.2f}")

    def _build_current_tps(self, pos: Position) -> list[tuple]:
        """Build list of (price, qty) for TPs that haven't been hit yet."""
        tps = []
        if not pos.tp1_hit:
            tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO)
            tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * TP2_CLOSE_RATIO)
            tp3_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity - tp1_qty - tp2_qty)
            if tp1_qty > 0:
                tps.append((pos.tp1_price, tp1_qty))
            if tp2_qty > 0:
                tps.append((pos.tp2_price, tp2_qty))
            if tp3_qty > 0:
                tps.append((pos.tp3_price, tp3_qty))
        elif not pos.tp2_hit:
            tp2_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO)
            tp3_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity - tp2_qty)
            if tp2_qty > 0:
                tps.append((pos.tp2_price, tp2_qty))
            if tp3_qty > 0:
                tps.append((pos.tp3_price, tp3_qty))
        else:
            if pos.remaining_quantity > 0:
                tps.append((pos.tp3_price, pos.remaining_quantity))
        return tps

    async def _check_exchange_orders(self, pos: Position, price: float):
        """Check exchange-side order fills and react.

        TP = regular LIMIT (queryable), SL = STOP_MARKET algo (NOT queryable).
        Detection pattern:
          - TP filled? β†’ check LIMIT order status (queryable)
          - SL fired? β†’ position gone on Binance + TP not filled
        After TP1/TP2: cancel_all + re-place SL + remaining TPs via replace_sl_and_tps().
        """
        # 0. Detect externally cancelled orders β†’ re-place all
        first_tp_id = pos.tp1_order_id or pos.tp2_order_id or pos.tp3_order_id
        if first_tp_id:
            st = self.trader.get_order_status(pos.symbol, first_tp_id)
            if st and st["status"] in ("CANCELED", "CANCELLED", "EXPIRED", "REJECTED"):
                logger.warning(f"WT {pos.symbol}: orders cancelled externally, re-placing")
                remaining_tps = self._build_current_tps(pos)
                orders = self.order_mgr.replace_sl_and_tps(
                    pos.symbol, pos.side, pos.sl_price, pos.remaining_quantity, remaining_tps
                )
                pos.sl_order_id = orders["sl_order_id"]
                ids = orders["tp_order_ids"]
                if not pos.tp1_hit:
                    pos.tp1_order_id = ids[0] if len(ids) > 0 else None
                    pos.tp2_order_id = ids[1] if len(ids) > 1 else None
                    pos.tp3_order_id = ids[2] if len(ids) > 2 else None
                elif not pos.tp2_hit:
                    pos.tp2_order_id = ids[0] if len(ids) > 0 else None
                    pos.tp3_order_id = ids[1] if len(ids) > 1 else None
                else:
                    pos.tp3_order_id = ids[0] if len(ids) > 0 else None
                await self.notify(f"πŸ”„ WT {pos.symbol}: ΠΎΡ€Π΄Π΅Ρ€Π° пСрСставлСны (Π±Ρ‹Π»ΠΈ ΠΎΡ‚ΠΌΠ΅Π½Π΅Π½Ρ‹)")
                logger.info(f"WT {pos.symbol}: re-placed SL + {len(ids)} TPs")
                return

        # 1. Check TP1 fill (regular LIMIT β€” queryable)
        if not pos.tp1_hit and pos.tp1_order_id:
            tp1_st = self.trader.get_order_status(pos.symbol, pos.tp1_order_id)
            if tp1_st and tp1_st["status"] == "FILLED":
                fill_price = tp1_st["fill_price"] if tp1_st["fill_price"] > 0 else pos.tp1_price
                close_qty = tp1_st["executedQty"] or self.trader.round_quantity(
                    pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO)

                pos.tp1_hit = True
                pos.remaining_quantity -= close_qty

                pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
                pnl_usdt = close_qty * abs(fill_price - pos.entry_price)
                close_fee = close_qty * fill_price * MAKER_FEE_PCT / 100  # maker!
                pnl_usdt -= close_fee
                pos.realized_pnl += pnl_usdt

                # Move SL to BE + re-place remaining TPs
                new_sl = pos.entry_price
                pos.sl_price = new_sl

                # Build remaining TP levels
                remaining_tps = []
                tp2_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO)
                tp3_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity - tp2_qty)
                if tp2_qty > 0:
                    remaining_tps.append((pos.tp2_price, tp2_qty))
                if tp3_qty > 0:
                    remaining_tps.append((pos.tp3_price, tp3_qty))

                orders = self.order_mgr.replace_sl_and_tps(
                    pos.symbol, pos.side, new_sl, pos.remaining_quantity, remaining_tps
                )
                pos.sl_order_id = orders["sl_order_id"]
                ids = orders["tp_order_ids"]
                pos.tp1_order_id = None  # already filled
                pos.tp2_order_id = ids[0] if len(ids) > 0 else None
                pos.tp3_order_id = ids[1] if len(ids) > 1 else None

                log_event("TP1_HIT", {
                    "trade_id": pos.trade_id, "symbol": pos.symbol,
                    "price": fill_price, "closed_quantity": close_qty,
                    "remaining_quantity": pos.remaining_quantity,
                    "pnl_pct": round(pnl_pct, 2),
                    "realized_pnl_usdt": round(pnl_usdt, 2),
                    "new_sl_price": new_sl,
                    "order_type": "EXCHANGE_LIMIT",
                })

                msg = (
                    f"🎯 TP1 HIT: {pos.symbol} [LIMIT]\n"
                    f"━━━━━━━━━━━━━━━━━━━━\n"
                    f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: 50% ({close_qty})\n"
                    f"πŸ’΅ +${pnl_usdt:.2f}\n"
                    f"πŸ›‘ SL β†’ BE (${new_sl:.6f})\n"
                    f"━━━━━━━━━━━━━━━━━━━━"
                )
                await self.notify(msg)
                logger.info(f"WT TP1 (exchange): {pos.symbol} closed {close_qty}, SL→BE")
                return

        # 2. Check TP2 fill
        if pos.tp1_hit and not pos.tp2_hit and pos.tp2_order_id:
            tp2_st = self.trader.get_order_status(pos.symbol, pos.tp2_order_id)
            if tp2_st and tp2_st["status"] == "FILLED":
                fill_price = tp2_st["fill_price"] if tp2_st["fill_price"] > 0 else pos.tp2_price
                close_qty = tp2_st["executedQty"] or self.trader.round_quantity(
                    pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO)

                pos.tp2_hit = True
                pos.remaining_quantity -= close_qty

                pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
                pnl_usdt = close_qty * abs(fill_price - pos.entry_price)
                close_fee = close_qty * fill_price * MAKER_FEE_PCT / 100
                pnl_usdt -= close_fee
                pos.realized_pnl += pnl_usdt

                # Move SL to +1.5% + re-place remaining TP3
                if pos.side == "BUY":
                    new_sl = pos.entry_price * (1 + SL_AFTER_TP2_PCT / 100)
                else:
                    new_sl = pos.entry_price * (1 - SL_AFTER_TP2_PCT / 100)
                pos.sl_price = new_sl

                remaining_tps = []
                if pos.remaining_quantity > 0:
                    remaining_tps.append((pos.tp3_price, pos.remaining_quantity))

                orders = self.order_mgr.replace_sl_and_tps(
                    pos.symbol, pos.side, new_sl, pos.remaining_quantity, remaining_tps
                )
                pos.sl_order_id = orders["sl_order_id"]
                ids = orders["tp_order_ids"]
                pos.tp2_order_id = None  # already filled
                pos.tp3_order_id = ids[0] if len(ids) > 0 else None

                log_event("TP2_HIT", {
                    "trade_id": pos.trade_id, "symbol": pos.symbol,
                    "price": fill_price, "closed_quantity": close_qty,
                    "remaining_quantity": pos.remaining_quantity,
                    "pnl_pct": round(pnl_pct, 2),
                    "realized_pnl_usdt": round(pnl_usdt, 2),
                    "new_sl_price": new_sl,
                    "order_type": "EXCHANGE_LIMIT",
                })

                msg = (
                    f"🎯🎯 TP2 HIT: {pos.symbol} [LIMIT]\n"
                    f"━━━━━━━━━━━━━━━━━━━━\n"
                    f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: Π΅Ρ‰Ρ‘ 25% ({close_qty})\n"
                    f"πŸ’΅ +${pnl_usdt:.2f}\n"
                    f"πŸ›‘ SL β†’ +{SL_AFTER_TP2_PCT}% (${new_sl:.6f})\n"
                    f"━━━━━━━━━━━━━━━━━━━━"
                )
                await self.notify(msg)
                logger.info(f"WT TP2 (exchange): {pos.symbol} closed {close_qty}")
                return

        # 3. Check TP3 fill
        if pos.tp2_hit and pos.tp3_order_id:
            tp3_st = self.trader.get_order_status(pos.symbol, pos.tp3_order_id)
            if tp3_st and tp3_st["status"] == "FILLED":
                fill_price = tp3_st["fill_price"] if tp3_st["fill_price"] > 0 else pos.tp3_price

                pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
                pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price)
                close_fee = pos.remaining_quantity * fill_price * MAKER_FEE_PCT / 100
                pnl_usdt -= close_fee
                total_pnl = pos.realized_pnl + pnl_usdt

                # Cancel SL algo order via cancel_all
                self.order_mgr.cancel_all_for_symbol(pos.symbol)

                log_event("TP3_HIT", {
                    "trade_id": pos.trade_id, "symbol": pos.symbol,
                    "entry_price": pos.entry_price, "exit_price": fill_price,
                    "total_trade_pnl_usdt": round(total_pnl, 2),
                    "order_type": "EXCHANGE_LIMIT",
                })

                msg = (
                    f"πŸ† TP3 FULL HIT: {pos.symbol} [LIMIT]\n"
                    f"━━━━━━━━━━━━━━━━━━━━\n"
                    f"Entry: ${pos.entry_price:.6f}\n"
                    f"Exit: ${fill_price:.6f}\n"
                    f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ PnL: ${total_pnl:+.2f}\n"
                    f"━━━━━━━━━━━━━━━━━━━━"
                )
                await self.notify(msg)
                del self.positions[pos.symbol]
                logger.info(f"WT TP3 (exchange): {pos.symbol} total PnL ${total_pnl:+.2f}")
                return

        # 4. Position gone? Either SL fired (STOP_MARKET algo) or manual close
        binance_pos = self.trader.get_position(pos.symbol)
        if not binance_pos:
            # Clean up all orders (both regular LIMIT and algo STOP_MARKET)
            self.order_mgr.cancel_all_for_symbol(pos.symbol)

            # Check if any TP was filled between checks (race condition)
            any_tp_filled = False
            for tp_id, tp_price, label in [
                (pos.tp1_order_id, pos.tp1_price, "TP1"),
                (pos.tp2_order_id, pos.tp2_price, "TP2"),
                (pos.tp3_order_id, pos.tp3_price, "TP3"),
            ]:
                if tp_id:
                    st = self.trader.get_order_status(pos.symbol, tp_id)
                    if st and st["status"] == "FILLED":
                        any_tp_filled = True
                        logger.info(f"WT {pos.symbol}: {label} was filled (detected via position-gone)")

            if not any_tp_filled:
                # SL fired via STOP_MARKET algo order
                fill_price = pos.sl_price  # best estimate (algo orders don't give fill info)
                pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
                pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price)
                if pnl_pct < 0:
                    pnl_usdt = -pnl_usdt
                close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100
                pnl_usdt -= close_fee
                total_pnl = pos.realized_pnl + pnl_usdt

                sl_type = "BE" if pos.tp1_hit else "SL"
                emoji = "🟑" if pos.tp1_hit else "πŸ”΄"

                log_event("SL_HIT", {
                    "trade_id": pos.trade_id, "symbol": pos.symbol,
                    "side": pos.side, "entry_price": pos.entry_price,
                    "exit_price": fill_price, "mark_price": price,
                    "pnl_pct": round(pnl_pct, 2),
                    "total_trade_pnl_usdt": round(total_pnl, 2),
                    "tp1_was_hit": pos.tp1_hit, "tp2_was_hit": pos.tp2_hit,
                    "order_type": "EXCHANGE_STOP",
                })

                msg = (
                    f"{emoji} {sl_type} HIT: {pos.symbol} [STOP]\n"
                    f"━━━━━━━━━━━━━━━━━━━━\n"
                    f"Entry: ${pos.entry_price:.6f}\n"
                    f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n"
                    f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ: ${total_pnl:+.2f}\n"
                    f"━━━━━━━━━━━━━━━━━━━━"
                )
                await self.notify(msg)
            else:
                # TP was filled but we missed it β€” log as manual/race
                pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)
                log_event("MANUAL_CLOSE", {
                    "trade_id": pos.trade_id, "symbol": pos.symbol,
                    "exit_price": price, "pnl_pct": round(pnl_pct, 2),
                    "note": "tp_filled_race_condition",
                })
                await self.notify(
                    f"πŸ‘‹ {pos.symbol} Π·Π°ΠΊΡ€Ρ‹Ρ‚Π° (TP fill + position gone)\n"
                    f"ПослСдняя Ρ†Π΅Π½Π°: ${price:.6f} ({pnl_pct:+.2f}%)"
                )

            del self.positions[pos.symbol]

    async def check_position(self, pos: Position):
        """
        Check a single position against current mark price.
        Order of checks: SL β†’ TP3 β†’ TP2 β†’ TP1 (safety first).
        """
        price = self.trader.get_mark_price(pos.symbol)
        if price is None:
            return

        # === Exchange-side order mode ===
        if pos.use_exchange_orders:
            await self._check_exchange_orders(pos, price)
            return

        # === Polling fallback mode ===

        # Safety: detect if position was closed externally (manually on exchange)
        binance_pos = self.trader.get_position(pos.symbol)
        if not binance_pos:
            pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)
            logger.warning(f"Position {pos.symbol} no longer exists on Binance β€” closed externally")

            log_event("MANUAL_CLOSE", {
                "trade_id": pos.trade_id,
                "symbol": pos.symbol,
                "side": pos.side,
                "exit_price": price,
                "pnl_pct": round(pnl_pct, 2),
                "note": "closed_externally_on_exchange",
                "tp1_was_hit": pos.tp1_hit,
                "tp2_was_hit": pos.tp2_hit,
            })

            await self.notify(
                f"πŸ‘‹ {pos.symbol} Π·Π°ΠΊΡ€Ρ‹Ρ‚Π° Π½Π° Π±ΠΈΡ€ΠΆΠ΅ Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ\n"
                f"Π£Π±ΠΈΡ€Π°ΡŽ ΠΈΠ· ΠΌΠΎΠ½ΠΈΡ‚ΠΎΡ€ΠΈΠ½Π³Π°.\n"
                f"ПослСдняя Ρ†Π΅Π½Π°: ${price:.6f} ({pnl_pct:+.2f}%)"
            )

            del self.positions[pos.symbol]
            return

        # Safety: if entry_price is 0, try to recover from Binance
        if pos.entry_price <= 0:
            binance_pos = self.trader.get_position(pos.symbol)
            if binance_pos and binance_pos["entry_price"] > 0:
                pos.entry_price = binance_pos["entry_price"]
                levels = calculate_levels(pos.entry_price, pos.side)
                pos.sl_price = levels["sl"]
                pos.tp1_price = levels["tp1"]
                pos.tp2_price = levels["tp2"]
                pos.tp3_price = levels["tp3"]
                logger.info(f"Recovered entry price for {pos.symbol}: ${pos.entry_price:.6f}")
                await self.notify(
                    f"πŸ”§ Recovered {pos.symbol} entry: ${pos.entry_price:.6f}\n"
                    f"SL: ${pos.sl_price:.6f} | TP1: ${pos.tp1_price:.6f}"
                )
            else:
                logger.warning(f"Cannot recover entry price for {pos.symbol}, skipping check")
                return

        is_long = pos.side == "BUY"

        # 1. Check SL
        sl_hit = (price <= pos.sl_price) if is_long else (price >= pos.sl_price)
        if sl_hit:
            await self._handle_sl(pos, price)
            return

        # 2. Check TP3 (close all remaining)
        tp3_hit = (price >= pos.tp3_price) if is_long else (price <= pos.tp3_price)
        if tp3_hit:
            await self._handle_tp3(pos, price)
            return

        # 3. Check TP2 (if TP1 already hit)
        if pos.tp1_hit and not pos.tp2_hit:
            tp2_hit = (price >= pos.tp2_price) if is_long else (price <= pos.tp2_price)
            if tp2_hit:
                await self._handle_tp2(pos, price)
                return

        # 4. Check TP1
        if not pos.tp1_hit:
            tp1_hit = (price >= pos.tp1_price) if is_long else (price <= pos.tp1_price)
            if tp1_hit:
                await self._handle_tp1(pos, price)
                return

    async def close_trade_manual(self, symbol: str) -> bool:
        """Manually close a position (via /close command)."""
        if symbol not in self.positions:
            return False

        pos = self.positions[symbol]
        price = self.trader.get_mark_price(symbol)
        if not price:
            return False

        # Cancel exchange orders before market close
        if pos.use_exchange_orders:
            self.order_mgr.cancel_all_for_symbol(symbol)

        result = self.trader.close_full(symbol, pos.side)

        if pos.side == "BUY":
            pnl_pct = ((price - pos.entry_price) / pos.entry_price) * 100
        else:
            pnl_pct = ((pos.entry_price - price) / pos.entry_price) * 100

        pnl_usdt = pos.remaining_quantity * abs(price - pos.entry_price)
        if pnl_pct < 0:
            pnl_usdt = -pnl_usdt
        close_fee = pos.remaining_quantity * price * TAKER_FEE_PCT / 100
        pnl_usdt -= close_fee
        total_pnl = pos.realized_pnl + pnl_usdt

        log_event("MANUAL_CLOSE", {
            "trade_id": pos.trade_id,
            "symbol": symbol,
            "exit_price": price,
            "pnl_pct": round(pnl_pct, 2),
            "realized_pnl_usdt": round(pnl_usdt, 2),
            "total_trade_pnl_usdt": round(total_pnl, 2),
        })

        await self.notify(
            f"πŸ– Manual close: {symbol}\n"
            f"Exit: ${price:.6f} ({pnl_pct:+.2f}%)\n"
            f"πŸ’° PnL: ${total_pnl:+.2f}"
        )

        del self.positions[symbol]
        return True

    def recover_positions(self):
        """
        On startup, check for open positions on Binance
        and reconstruct Position objects from trade log.
        """
        open_trades = get_open_trades()
        if not open_trades:
            logger.info("No open trades to recover")
            return

        for trade in open_trades:
            symbol = trade.get("symbol", "")
            if not symbol:
                continue

            # Check if position actually exists on Binance
            binance_pos = self.trader.get_position(symbol)
            if not binance_pos:
                logger.info(f"Trade log has {symbol} but no Binance position β€” skipping")
                continue

            entry_price = trade.get("entry_price", binance_pos["entry_price"])
            side = trade.get("side", binance_pos["side"])
            levels = calculate_levels(entry_price, side)

            # Check TP state from log
            tp_state = get_tp_state(symbol)

            pos = Position(
                symbol=symbol,
                side=side,
                entry_price=entry_price,
                total_quantity=float(trade.get("quantity", binance_pos["quantity"])),
                remaining_quantity=binance_pos["quantity"],
                sl_price=levels["sl"],
                tp1_price=levels["tp1"],
                tp2_price=levels["tp2"],
                tp3_price=levels["tp3"],
                tp1_hit=tp_state["tp1_hit"],
                tp2_hit=tp_state["tp2_hit"],
                opened_at=trade.get("timestamp", ""),
                trade_id=trade.get("trade_id", symbol),
            )

            # Adjust SL based on TP state
            if pos.tp2_hit:
                if side == "BUY":
                    pos.sl_price = entry_price * (1 + SL_AFTER_TP2_PCT / 100)
                else:
                    pos.sl_price = entry_price * (1 - SL_AFTER_TP2_PCT / 100)
            elif pos.tp1_hit:
                pos.sl_price = entry_price  # BE

            # Recover or re-place exchange orders
            if WT_USE_EXCHANGE_ORDERS:
                # Cancel any stale orders from before restart
                self.order_mgr.cancel_all_for_symbol(symbol)
                import time; time.sleep(2.0)  # Let Binance fully process algo order cancellation

                # Re-place orders based on current TP state
                self._place_recovery_orders(pos)
                logger.info(f"Re-placed exchange orders for recovered WT {symbol}")

            self.positions[symbol] = pos
            logger.info(
                f"Recovered position: {side} {symbol} @ {entry_price}, "
                f"remaining={pos.remaining_quantity}, tp1={pos.tp1_hit}, tp2={pos.tp2_hit}, "
                f"exchange_orders={'YES' if pos.use_exchange_orders else 'NO'}"
            )

    async def monitor_loop(self):
        """Price monitor loop β€” check all positions every N seconds."""
        logger.info(f"Position monitor started (interval: {PRICE_CHECK_INTERVAL}s)")

        while True:
            try:
                # Copy keys to avoid dict-changed-during-iteration
                symbols = list(self.positions.keys())

                for symbol in symbols:
                    pos = self.positions.get(symbol)
                    if pos:
                        await self.check_position(pos)

            except Exception as e:
                logger.error(f"Error in monitor loop: {e}", exc_info=True)

            await asyncio.sleep(PRICE_CHECK_INTERVAL)

    def format_positions_message(self) -> str:
        """Format open positions for Telegram /positions command."""
        if not self.positions:
            return "πŸ“­ НСт ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚Ρ‹Ρ… ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΉ"

        lines = ["πŸ“Š ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚Ρ‹Π΅ ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ:\n━━━━━━━━━━━━━━━━━━━━"]

        for symbol, pos in self.positions.items():
            price = self.trader.get_mark_price(symbol)
            if not price:
                price = pos.entry_price

            if pos.entry_price > 0:
                if pos.side == "BUY":
                    pnl_pct = ((price - pos.entry_price) / pos.entry_price) * 100
                else:
                    pnl_pct = ((pos.entry_price - price) / pos.entry_price) * 100
            else:
                pnl_pct = 0

            direction = "LONG" if pos.side == "BUY" else "SHORT"
            emoji = "🟒" if pnl_pct >= 0 else "πŸ”΄"

            tp_status = ""
            if pos.tp2_hit:
                tp_status = "βœ…βœ… TP1+TP2"
            elif pos.tp1_hit:
                tp_status = "βœ… TP1"
            else:
                tp_status = "⏳ waiting"

            lines.append(
                f"\n{emoji} {direction} {symbol}\n"
                f"  Entry: ${pos.entry_price:.6f}\n"
                f"  Now: ${price:.6f} ({pnl_pct:+.2f}%)\n"
                f"  SL: ${pos.sl_price:.6f}\n"
                f"  Status: {tp_status}\n"
                f"  Remaining: {pos.remaining_quantity}"
            )

        lines.append("\n━━━━━━━━━━━━━━━━━━━━")
        return "\n".join(lines)

πŸ“œ Git History

c6f6bd5chore: initial commit β€” version control setup5 weeks ago
Show last diff
Loading...