← Back
β˜†
"""
Gerchik Position Manager β€” Position lifecycle with limit orders.

Manages Gerchik Level strategy positions:
- Limit order entry
- Stop-Limit SL with market fallback
- Limit TP1/TP2/TP3 with partial closes
- SL to BE after 2x SL in profit
- Trail SL behind new levels
"""

import asyncio
import logging
import os
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import Optional, Callable

from trader import BinanceFuturesTrader
from trade_log import log_event
from order_placer import ExchangeOrderManager
from gerchik_config import (
    GERCHIK_SIZE_USDT,
    GERCHIK_LEVERAGE,
    GERCHIK_MAX_POSITIONS,
    GERCHIK_CHECK_INTERVAL,
    GERCHIK_SCAN_INTERVAL,
    GERCHIK_TP1_CLOSE_PCT,
    GERCHIK_TP2_CLOSE_PCT,
    GERCHIK_BE_TRIGGER_STOPLOSS_MULT,
    GERCHIK_USE_LIMIT_ORDERS,
    GERCHIK_LIMIT_FALLBACK_SEC,
    GERCHIK_USE_EXCHANGE_ORDERS,
    TAKER_FEE_PCT,
    MAKER_FEE_PCT,
    MODEL_LABELS,
)
from gerchik_models import GerchikSignal

logger = logging.getLogger(__name__)

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


def now_van() -> datetime:
    return datetime.now(VANCOUVER_TZ)


def safe_pnl_pct(entry: float, exit_p: float, side: str) -> float:
    if entry == 0:
        return 0
    if side == "BUY":
        return ((exit_p - entry) / entry) * 100
    else:
        return ((entry - exit_p) / entry) * 100


@dataclass
class GerchikPosition:
    """A Gerchik level trade."""
    symbol: str
    side: str
    model: str              # A, B, C, D
    entry_price: float
    quantity: float
    total_quantity: float
    remaining_quantity: float
    sl_price: float
    tp1_price: float
    tp2_price: float
    tp3_price: float
    level_price: float
    level_strength: float
    trade_id: str
    opened_at: datetime
    tp1_hit: bool = False
    tp2_hit: bool = False
    realized_pnl: float = 0.0  # Accumulated from partial closes
    be_moved: bool = False      # Whether SL was moved to BE
    signal_data: dict = field(default_factory=dict)
    # 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

    @property
    def age_minutes(self) -> float:
        return (now_van() - self.opened_at).total_seconds() / 60


class GerchikManager:
    """Manages Gerchik Level strategy positions."""

    def __init__(self, trader: BinanceFuturesTrader, notify_fn: Callable, tmm=None):
        self.trader = trader
        self.notify = notify_fn
        self.tmm = tmm
        self.order_mgr = ExchangeOrderManager(trader)
        self.positions: dict[str, GerchikPosition] = {}
        self.stats = {"wins": 0, "losses": 0, "total_pnl": 0.0}
        self._cooldowns: dict[str, datetime] = {}  # symbol -> last close time

    async def _open_with_limit(self, symbol: str, side: str, qty: float,
                               limit_price: float) -> dict | None:
        """
        Place limit order, wait for fill, fallback to market if timeout.

        Returns dict with fill_price, quantity, and is_maker flag.
        """
        order = self.trader.open_limit_order(symbol, side, qty, limit_price)
        if not order:
            logger.warning(f"Limit order failed for {symbol}, trying market")
            r = self.trader.open_position(symbol, side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE)
            if r:
                r["is_maker"] = False
            return r

        order_id = order["orderId"]
        status = order["status"]

        # If filled immediately (unlikely but possible)
        if status == "FILLED":
            fp = order.get("fill_price") or limit_price
            logger.info(f"Limit order filled immediately: {symbol} @ ${fp:.6f}")
            return {"fill_price": fp, "quantity": order["quantity"], "is_maker": True}

        # Poll for fill within timeout
        elapsed = 0
        poll_interval = 1  # 1 second
        while elapsed < GERCHIK_LIMIT_FALLBACK_SEC:
            await asyncio.sleep(poll_interval)
            elapsed += poll_interval

            check = self.trader.get_order_status(symbol, order_id)
            if not check:
                continue

            if check["status"] == "FILLED":
                fp = check["fill_price"] if check["fill_price"] > 0 else limit_price
                logger.info(f"Limit filled after {elapsed}s: {symbol} @ ${fp:.6f}")
                return {"fill_price": fp, "quantity": check["executedQty"], "is_maker": True}

            if check["status"] in ("CANCELED", "EXPIRED", "REJECTED"):
                logger.warning(f"Limit order {check['status']} for {symbol}, trying market")
                r = self.trader.open_position(symbol, side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE)
                if r:
                    r["is_maker"] = False
                return r

        # Timeout β€” cancel limit, go market
        logger.info(f"Limit timeout ({GERCHIK_LIMIT_FALLBACK_SEC}s) for {symbol}, cancel β†’ market")

        # Check one more time (might have filled during cancel)
        check = self.trader.get_order_status(symbol, order_id)
        if check and check["status"] == "FILLED":
            fp = check["fill_price"] if check["fill_price"] > 0 else limit_price
            return {"fill_price": fp, "quantity": check["executedQty"], "is_maker": True}

        # Partially filled?
        if check and check["executedQty"] > 0:
            # Cancel remaining, keep what we got
            self.trader.cancel_order(symbol, order_id)
            fp = check["fill_price"] if check["fill_price"] > 0 else limit_price
            logger.info(f"Limit partial fill {check['executedQty']}/{check['origQty']} for {symbol}")
            return {"fill_price": fp, "quantity": check["executedQty"], "is_maker": True}

        # Nothing filled β€” cancel and market
        self.trader.cancel_order(symbol, order_id)
        r = self.trader.open_position(symbol, side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE)
        if r:
            r["is_maker"] = False
        return r

    async def open_trade(self, signal: GerchikSignal) -> bool:
        """Open a new Gerchik level trade (limit order with market fallback)."""
        symbol = signal.symbol

        if symbol in self.positions:
            logger.debug(f"Already have position in {symbol}")
            return False

        # Double-check: verify no position exists on Binance (prevents duplicates on restart)
        existing = self.trader.get_position(symbol)
        if existing:
            logger.warning(f"GR {symbol}: position exists on Binance ({existing['quantity']} qty) but not in manager β€” skip open")
            return False

        if len(self.positions) >= GERCHIK_MAX_POSITIONS:
            logger.debug(f"Max Gerchik positions reached ({GERCHIK_MAX_POSITIONS})")
            return False

        # Cooldown: don't re-enter same symbol within 15 min
        if symbol in self._cooldowns:
            elapsed = (now_van() - self._cooldowns[symbol]).total_seconds()
            if elapsed < 900:  # 15 min
                return False

        # Set leverage and margin type first
        if not self.trader.set_leverage(symbol, GERCHIK_LEVERAGE):
            return False
        self.trader.set_margin_type(symbol, "ISOLATED")  # May fail if position exists, non-critical

        # Get current price for qty calculation
        price = self.trader.get_mark_price(symbol)
        if not price:
            return False

        qty = self.trader.calculate_quantity(symbol, GERCHIK_SIZE_USDT, price, GERCHIK_LEVERAGE)
        if qty <= 0:
            logger.error(f"Invalid quantity for {symbol}")
            return False

        if GERCHIK_USE_LIMIT_ORDERS:
            # Limit price: slightly better than current for sniper entry
            # BUY β†’ bid slightly below current | SELL β†’ ask slightly above current
            tick_improve = price * 0.0002  # 0.02% improvement
            if signal.side == "BUY":
                limit_price = price - tick_improve
            else:
                limit_price = price + tick_improve

            result = await self._open_with_limit(symbol, signal.side, qty, limit_price)
        else:
            result = self.trader.open_position(
                symbol, signal.side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE
            )

        if not result:
            logger.error(f"Failed to open Gerchik {signal.side} {symbol}")
            return False

        entry = result["fill_price"]
        qty = result["quantity"]

        # Recalculate SL/TP from actual entry (not from signal's current_price)
        if signal.side == "BUY":
            sl_dist = entry - signal.sl_price
            tp1 = entry + sl_dist * (signal.tp1_price - signal.entry_price) / (signal.entry_price - signal.sl_price)
            tp2 = entry + sl_dist * (signal.tp2_price - signal.entry_price) / (signal.entry_price - signal.sl_price)
            tp3 = entry + sl_dist * (signal.tp3_price - signal.entry_price) / (signal.entry_price - signal.sl_price)
        else:
            sl_dist = signal.sl_price - entry
            tp1 = entry - sl_dist * (signal.entry_price - signal.tp1_price) / (signal.sl_price - signal.entry_price)
            tp2 = entry - sl_dist * (signal.entry_price - signal.tp2_price) / (signal.sl_price - signal.entry_price)
            tp3 = entry - sl_dist * (signal.entry_price - signal.tp3_price) / (signal.sl_price - signal.entry_price)

        # Open fee (maker if limit filled, taker if market fallback)
        is_maker = result.get("is_maker", False)
        entry_fee_pct = MAKER_FEE_PCT if is_maker else TAKER_FEE_PCT
        open_fee = qty * entry * entry_fee_pct / 100
        order_type = "LIMIT" if is_maker else "MARKET"

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

        pos = GerchikPosition(
            symbol=symbol,
            side=signal.side,
            model=signal.model,
            entry_price=entry,
            quantity=qty,
            total_quantity=qty,
            remaining_quantity=qty,
            sl_price=signal.sl_price,
            tp1_price=tp1,
            tp2_price=tp2,
            tp3_price=tp3,
            level_price=signal.level.price,
            level_strength=signal.level.strength,
            trade_id=trade_id,
            opened_at=now,
            realized_pnl=-open_fee,
            signal_data={
                "model": signal.model,
                "pattern": signal.candle_pattern,
                "trend": signal.trend,
                "volume_ratio": signal.volume_ratio,
                "level_type": signal.level.level_type,
                "level_touches": signal.level.touches,
            },
        )

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

        self.positions[symbol] = pos

        # Log
        log_event("GR_ENTRY", {
            "trade_id": trade_id,
            "symbol": symbol,
            "side": signal.side,
            "model": signal.model,
            "model_label": MODEL_LABELS.get(signal.model, "?"),
            "entry_price": entry,
            "quantity": qty,
            "leverage": GERCHIK_LEVERAGE,
            "margin_usdt": GERCHIK_SIZE_USDT,
            "sl_price": signal.sl_price,
            "tp1_price": tp1,
            "tp2_price": tp2,
            "tp3_price": tp3,
            "level_price": signal.level.price,
            "level_strength": signal.level.strength,
            "level_type": signal.level.level_type,
            "level_touches": signal.level.touches,
            "trend": signal.trend,
            "pattern": signal.candle_pattern,
            "order_type": order_type,
            "entry_fee_pct": entry_fee_pct,
        })

        direction = "LONG" if signal.side == "BUY" else "SHORT"
        model_label = MODEL_LABELS.get(signal.model, "?")
        level_type = signal.level.level_type
        sl_pct = signal.sl_distance_pct

        notional = qty * entry
        msg = (
            f"πŸ“ GERCHIK МодСль {signal.model} ({model_label}): {direction} {symbol}\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"πŸ“Š Π£Ρ€ΠΎΠ²Π΅Π½ΡŒ: ${signal.level.price:.6f} ({signal.level.touches} кас., {level_type})\n"
            f"πŸ’ͺ Π‘ΠΈΠ»Π°: {signal.level.strength:.0f}/100\n"
            f"πŸ’° Entry: ${entry:.6f} ({order_type})\n"
            f"πŸ“Š Size: {qty} ({GERCHIK_LEVERAGE}x, ${notional:.2f})\n"
            f"πŸ›‘ SL: ${signal.sl_price:.6f} (-{sl_pct:.2f}%)\n"
            f"🎯 TP1: ${tp1:.6f} (3:1) β†’ 50%\n"
            f"🎯 TP2: ${tp2:.6f} (4:1) β†’ 25%\n"
            f"🎯 TP3: ${tp3:.6f} (5:1) β†’ 25%\n"
            f"πŸ“ˆ Π’Ρ€Π΅Π½Π΄: {signal.trend} | πŸ“ {signal.candle_pattern}\n"
            f"━━━━━━━━━━━━━━━━━━━━"
        )
        await self.notify(msg)
        logger.info(f"Gerchik trade opened: {direction} {symbol} model={signal.model} level=${signal.level.price:.6f}")

        # TMM journal: tag trade
        if self.tmm:
            model_name = MODEL_LABELS.get(signal.model, signal.model)
            self.tmm.on_trade_opened(symbol, signal.side, "GERCHIK", model=signal.model,
                signal_info=f"[GR-{signal.model}] {direction} {model_name} @ level ${signal.level.price:.6f}")

        return True

    def _place_initial_orders(self, pos: GerchikPosition):
        """Place TP1/TP2/TP3 + SL orders on Binance after position opens."""
        tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT)
        tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * GERCHIK_TP2_CLOSE_PCT)
        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"GR {pos.symbol}: exchange orders placed (SL #{pos.sl_order_id}, TP1 #{pos.tp1_order_id})")
        else:
            logger.warning(f"GR {pos.symbol}: exchange orders failed, using polling fallback")

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

        tp_levels = []
        if pos.tp2_hit:
            tp_levels.append((pos.tp3_price, remaining))
        elif pos.tp1_hit:
            tp2_qty = self.trader.round_quantity(pos.symbol, remaining * GERCHIK_TP2_CLOSE_PCT)
            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:
            tp1_qty = self.trader.round_quantity(pos.symbol, remaining * GERCHIK_TP1_CLOSE_PCT)
            tp2_qty = self.trader.round_quantity(pos.symbol, (remaining - tp1_qty) * GERCHIK_TP2_CLOSE_PCT)
            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"]

        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"GR {pos.symbol}: recovery orders placed (SL #{pos.sl_order_id}, TPs: {ids})")
        else:
            logger.warning(f"GR {pos.symbol}: recovery orders failed, using polling fallback")

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

        fill_price = current_price
        if result and result.get("fill_price"):
            fill_price = result["fill_price"]
            logger.info(f"GR SL {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}")

        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

        self.stats["losses"] += 1
        self.stats["total_pnl"] += total_pnl

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

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

        slip_line = ""
        if current_price and 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} GR {sl_type}: {pos.symbol} (МодСль {pos.model})\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"Entry: ${pos.entry_price:.6f}\n"
            f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n"
            f"{slip_line}"
            f"πŸ’΅ PnL: ${total_pnl:+.2f}\n"
            f"πŸ“Š Π£Ρ€ΠΎΠ²Π΅Π½ΡŒ: ${pos.level_price:.6f}\n"
            f"━━━━━━━━━━━━━━━━━━━━"
        )
        await self.notify(msg)

        self._cooldowns[pos.symbol] = now_van()
        del self.positions[pos.symbol]
        logger.info(f"GR SL: {pos.symbol} {pnl_pct:+.2f}% ${total_pnl:+.2f}")

    async def _handle_tp1(self, pos: GerchikPosition, current_price: float):
        """Handle TP1: close 50%, SL β†’ BE."""
        close_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT)
        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 GR TP1 for {pos.symbol}")
            return

        fill_price = result.get("fill_price") or current_price

        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 * TAKER_FEE_PCT / 100
        pnl_usdt -= close_fee
        pos.realized_pnl += pnl_usdt

        # Move SL to breakeven
        pos.sl_price = pos.entry_price

        log_event("GR_TP1_HIT", {
            "trade_id": pos.trade_id,
            "symbol": pos.symbol,
            "model": pos.model,
            "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,
        })

        msg = (
            f"🎯 GR TP1: {pos.symbol} (МодСль {pos.model})\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"GR TP1: {pos.symbol} closed {close_qty}, SL→BE")

    async def _handle_tp2(self, pos: GerchikPosition, current_price: float):
        """Handle TP2: close 50% of remaining, SL β†’ +1.5 SL."""
        close_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * GERCHIK_TP2_CLOSE_PCT)
        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 GR TP2 for {pos.symbol}")
            return

        fill_price = result.get("fill_price") or current_price

        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 * TAKER_FEE_PCT / 100
        pnl_usdt -= close_fee
        pos.realized_pnl += pnl_usdt

        # Move SL to +1.5 SL distance (lock in profit)
        sl_dist = abs(pos.entry_price - pos.level_price)
        if pos.side == "BUY":
            pos.sl_price = pos.entry_price + sl_dist * 1.5
        else:
            pos.sl_price = pos.entry_price - sl_dist * 1.5

        log_event("GR_TP2_HIT", {
            "trade_id": pos.trade_id,
            "symbol": pos.symbol,
            "model": pos.model,
            "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,
        })

        msg = (
            f"🎯🎯 GR TP2: {pos.symbol} (МодСль {pos.model})\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: Π΅Ρ‰Ρ‘ 25% ({close_qty})\n"
            f"πŸ’΅ +${pnl_usdt:.2f}\n"
            f"πŸ›‘ SL β†’ ${pos.sl_price:.6f} (Π»ΠΎΠΊ ΠΏΡ€ΠΎΡ„ΠΈΡ‚Π°)\n"
            f"ΠžΡΡ‚Π°Π»ΠΎΡΡŒ: {pos.remaining_quantity} β†’ TP3\n"
            f"━━━━━━━━━━━━━━━━━━━━"
        )
        await self.notify(msg)
        logger.info(f"GR TP2: {pos.symbol} closed {close_qty}")

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

        fill_price = current_price
        if result and result.get("fill_price"):
            fill_price = result["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

        self.stats["wins"] += 1
        self.stats["total_pnl"] += total_pnl

        log_event("GR_TP3_HIT", {
            "trade_id": pos.trade_id,
            "symbol": pos.symbol,
            "model": pos.model,
            "entry_price": pos.entry_price,
            "exit_price": fill_price,
            "pnl_pct": round(pnl_pct, 2),
            "total_trade_pnl_usdt": round(total_pnl, 2),
        })

        msg = (
            f"πŸ† GR TP3 FULL: {pos.symbol} (МодСль {pos.model})\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)

        self._cooldowns[pos.symbol] = now_van()
        del self.positions[pos.symbol]
        logger.info(f"GR TP3: {pos.symbol} total PnL ${total_pnl:+.2f}")

    def _build_current_tps(self, pos: GerchikPosition) -> 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 * GERCHIK_TP1_CLOSE_PCT)
            tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * GERCHIK_TP2_CLOSE_PCT)
            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 * GERCHIK_TP2_CLOSE_PCT)
            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: GerchikPosition, price: float):
        """Check exchange-side order fills for Gerchik position.

        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().
        """
        is_long = pos.side == "BUY"

        # 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"GR {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"πŸ”„ GR {pos.symbol}: ΠΎΡ€Π΄Π΅Ρ€Π° пСрСставлСны (Π±Ρ‹Π»ΠΈ ΠΎΡ‚ΠΌΠ΅Π½Π΅Π½Ρ‹)")
                logger.info(f"GR {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 * GERCHIK_TP1_CLOSE_PCT)

                pos.tp1_hit = True
                pos.remaining_quantity -= close_qty

                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

                # SL β†’ BE + re-place remaining TPs
                new_sl = pos.entry_price
                pos.sl_price = new_sl

                remaining_tps = []
                tp2_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * GERCHIK_TP2_CLOSE_PCT)
                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("GR_TP1_HIT", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
                    "model": pos.model, "price": fill_price,
                    "closed_quantity": close_qty, "new_sl_price": new_sl,
                    "order_type": "EXCHANGE_LIMIT",
                })

                msg = (
                    f"🎯 GR TP1: {pos.symbol} (М{pos.model}) [LIMIT]\n"
                    f"━━━━━━━━━━━━━━━━━━━━\n"
                    f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: 50% ({close_qty})\nπŸ’΅ +${pnl_usdt:.2f}\n"
                    f"πŸ›‘ SL β†’ BE (${new_sl:.6f})\n━━━━━━━━━━━━━━━━━━━━"
                )
                await self.notify(msg)
                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 * GERCHIK_TP2_CLOSE_PCT)

                pos.tp2_hit = True
                pos.remaining_quantity -= close_qty

                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

                # SL β†’ entry + 1.5x SL distance + re-place remaining TP3
                sl_dist = abs(pos.entry_price - pos.level_price)
                if pos.side == "BUY":
                    new_sl = pos.entry_price + sl_dist * 1.5
                else:
                    new_sl = pos.entry_price - sl_dist * 1.5
                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("GR_TP2_HIT", {
                    "trade_id": pos.trade_id, "symbol": pos.symbol,
                    "model": pos.model, "price": fill_price,
                    "closed_quantity": close_qty, "new_sl_price": new_sl,
                    "order_type": "EXCHANGE_LIMIT",
                })

                msg = (
                    f"🎯🎯 GR TP2: {pos.symbol} (М{pos.model}) [LIMIT]\n"
                    f"━━━━━━━━━━━━━━━━━━━━\n"
                    f"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: 25% ({close_qty})\nπŸ’΅ +${pnl_usdt:.2f}\n"
                    f"πŸ›‘ SL β†’ ${new_sl:.6f}\n━━━━━━━━━━━━━━━━━━━━"
                )
                await self.notify(msg)
                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_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)

                self.stats["wins"] += 1
                self.stats["total_pnl"] += total_pnl

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

                msg = (
                    f"πŸ† GR TP3: {pos.symbol} (М{pos.model}) [LIMIT]\n"
                    f"━━━━━━━━━━━━━━━━━━━━\n"
                    f"πŸ’° Π˜Ρ‚ΠΎΠ³ΠΎ PnL: ${total_pnl:+.2f}\n━━━━━━━━━━━━━━━━━━━━"
                )
                await self.notify(msg)
                self._cooldowns[pos.symbol] = now_van()
                del self.positions[pos.symbol]
                return

        # 4. BE move (polling-based trailing β€” acceptable, doesn't need order query)
        if not pos.tp1_hit and not pos.be_moved:
            sl_dist = abs(pos.entry_price - pos.level_price)
            be_trigger = pos.entry_price + sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT if is_long \
                else pos.entry_price - sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT

            if (price >= be_trigger) if is_long else (price <= be_trigger):
                new_sl = pos.entry_price
                pos.sl_price = new_sl
                pos.be_moved = True

                # Build remaining TP levels for re-placement
                remaining_tps = []
                tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT)
                tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * GERCHIK_TP2_CLOSE_PCT)
                tp3_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity - tp1_qty - tp2_qty)
                if pos.tp1_order_id and tp1_qty > 0:
                    remaining_tps.append((pos.tp1_price, tp1_qty))
                if pos.tp2_order_id and tp2_qty > 0:
                    remaining_tps.append((pos.tp2_price, tp2_qty))
                if pos.tp3_order_id and 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"]
                idx = 0
                if pos.tp1_order_id:
                    pos.tp1_order_id = ids[idx] if idx < len(ids) else None
                    idx += 1
                if pos.tp2_order_id:
                    pos.tp2_order_id = ids[idx] if idx < len(ids) else None
                    idx += 1
                if pos.tp3_order_id:
                    pos.tp3_order_id = ids[idx] if idx < len(ids) else None

                logger.info(f"GR BE (exchange): {pos.symbol} SL moved to BE")

        # 5. 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, label in [
                (pos.tp1_order_id, "TP1"),
                (pos.tp2_order_id, "TP2"),
                (pos.tp3_order_id, "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"GR {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
                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

                if pnl_pct >= 0:
                    self.stats["wins"] += 1
                else:
                    self.stats["losses"] += 1
                self.stats["total_pnl"] += total_pnl

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

                log_event("GR_SL_HIT", {
                    "trade_id": pos.trade_id, "symbol": pos.symbol,
                    "model": pos.model, "entry_price": pos.entry_price,
                    "exit_price": fill_price, "pnl_pct": round(pnl_pct, 2),
                    "total_trade_pnl_usdt": round(total_pnl, 2),
                    "order_type": "EXCHANGE_STOP",
                })

                msg = (
                    f"{emoji} GR {sl_type}: {pos.symbol} (М{pos.model}) [STOP]\n"
                    f"━━━━━━━━━━━━━━━━━━━━\n"
                    f"Entry: ${pos.entry_price:.6f}\nExit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n"
                    f"πŸ’΅ PnL: ${total_pnl:+.2f}\n━━━━━━━━━━━━━━━━━━━━"
                )
                await self.notify(msg)
            else:
                pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)
                log_event("GR_MANUAL_CLOSE", {
                    "trade_id": pos.trade_id, "symbol": pos.symbol,
                    "model": pos.model, "exit_price": price,
                    "pnl_pct": round(pnl_pct, 2),
                    "note": "tp_filled_race_condition",
                })
                await self.notify(
                    f"πŸ‘‹ GR {pos.symbol} Π·Π°ΠΊΡ€Ρ‹Ρ‚ (TP fill + position gone)")

            self._cooldowns[pos.symbol] = now_van()
            del self.positions[pos.symbol]

    async def check_position(self, pos: GerchikPosition):
        """Check a single position: exchange orders or polling fallback."""
        price = self.trader.get_mark_price(pos.symbol)
        if not price:
            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
        binance_pos = self.trader.get_position(pos.symbol)
        if not binance_pos:
            pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)
            pnl_usdt = pos.remaining_quantity * abs(price - pos.entry_price)
            if pnl_pct < 0:
                pnl_usdt = -pnl_usdt
            total_pnl = pos.realized_pnl + pnl_usdt

            log_event("GR_MANUAL_CLOSE", {
                "trade_id": pos.trade_id,
                "symbol": pos.symbol,
                "model": pos.model,
                "exit_price": price,
                "pnl_pct": round(pnl_pct, 2),
                "total_pnl_usdt": round(total_pnl, 2),
            })

            await self.notify(
                f"πŸ‘‹ GR Manual: {pos.symbol} Π·Π°ΠΊΡ€Ρ‹Ρ‚ Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ\n"
                f"PnL: ${total_pnl:+.2f} ({pnl_pct:+.2f}%)"
            )
            self._cooldowns[pos.symbol] = now_van()
            del self.positions[pos.symbol]
            return

        is_long = pos.side == "BUY"

        # 1. SL check
        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. TP3 (close all remaining)
        if not pos.tp2_hit:
            pass  # Can't hit TP3 without TP2
        else:
            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. TP2
        if not pos.tp1_hit:
            pass  # Can't hit TP2 without TP1
        elif 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. 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

        # 5. Move SL to BE after 2x SL distance in profit (before TP1)
        if not pos.tp1_hit and not pos.be_moved:
            sl_dist = abs(pos.entry_price - pos.level_price)
            be_trigger = pos.entry_price + sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT if is_long \
                else pos.entry_price - sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT

            be_triggered = (price >= be_trigger) if is_long else (price <= be_trigger)
            if be_triggered:
                pos.sl_price = pos.entry_price
                pos.be_moved = True
                logger.info(f"GR BE: {pos.symbol} SL moved to BE @ ${pos.entry_price:.6f}")

    async def monitor_loop(self):
        """Check all Gerchik positions every N seconds."""
        logger.info(f"Gerchik position monitor started (interval: {GERCHIK_CHECK_INTERVAL}s)")

        while True:
            try:
                symbols = list(self.positions.keys())
                for symbol in symbols:
                    pos = self.positions.get(symbol)
                    if pos:
                        await self.check_position(pos)
            except Exception as e:
                logger.error(f"Gerchik monitor error: {e}", exc_info=True)

            await asyncio.sleep(GERCHIK_CHECK_INTERVAL)

    async def scan_loop(self, wt_positions: dict, scalp_positions: dict):
        """
        Periodic scan for Gerchik level signals.

        Args:
            wt_positions: WT strategy positions
            scalp_positions: Scalp strategy positions
        """
        from gerchik_scanner import scan_for_gerchik_signals

        logger.info(f"Gerchik scanner started (interval={GERCHIK_SCAN_INTERVAL}s)")

        # Track previously alerted nearby levels (avoid spam)
        self._alerted_nearby: set[str] = set()
        scan_count = 0

        # Wait for init
        await asyncio.sleep(15)

        while True:
            try:
                skip = (
                    set(self.positions.keys())
                    | set(wt_positions.keys())
                    | set(scalp_positions.keys())
                )

                async def on_signal(signal: GerchikSignal):
                    if len(self.positions) < GERCHIK_MAX_POSITIONS:
                        await self.open_trade(signal)

                result = await scan_for_gerchik_signals(
                    self.trader.client,
                    on_signal,
                    skip,
                )

                scan_count += 1
                nearby = result.get("nearby", [])

                # Alert on new nearby levels (price approaching strong level)
                new_nearby = []
                for n in nearby:
                    key = f"{n.symbol}_{n.level_price:.6f}"
                    if key not in self._alerted_nearby:
                        self._alerted_nearby.add(key)
                        new_nearby.append(n)

                if new_nearby:
                    lines = ["πŸ‘€ Π¦Π΅Π½Π° Ρƒ ΡΠΈΠ»ΡŒΠ½Ρ‹Ρ… ΡƒΡ€ΠΎΠ²Π½Π΅ΠΉ:\n━━━━━━━━━━━━━━━━━━━━"]
                    for n in new_nearby[:5]:
                        direction = "⬆️" if n.side == "BUY" else "⬇️"
                        lines.append(
                            f"{direction} {n.symbol} ${n.current_price:.4f}\n"
                            f"   πŸ“Š Π£Ρ€ΠΎΠ²Π΅Π½ΡŒ ${n.level_price:.4f} ({n.level_type}, "
                            f"{n.level_touches}кас, πŸ’ͺ{n.level_strength:.0f})\n"
                            f"   πŸ“ РасстояниС: {n.distance_pct:.2f}%"
                        )
                    lines.append("━━━━━━━━━━━━━━━━━━━━")
                    await self.notify("\n".join(lines))

                # Clean up stale alerts every 12 scans (~1 hour)
                if scan_count % 12 == 0:
                    self._alerted_nearby.clear()

            except Exception as e:
                logger.error(f"Gerchik scan error: {e}", exc_info=True)

            await asyncio.sleep(GERCHIK_SCAN_INTERVAL)

    def recover_positions(self):
        """Recover Gerchik positions from trade log + Binance API on restart."""
        from trade_log import load_trade_log

        log = load_trade_log()
        open_trades = {}
        close_events = {"GR_SL_HIT", "GR_TP3_HIT", "GR_MANUAL_CLOSE"}

        for event in log:
            evt = event.get("event", "")
            tid = event.get("trade_id", "")

            if not tid or not tid.startswith("GR_"):
                continue

            if evt == "GR_ENTRY":
                open_trades[tid] = event
            elif evt in close_events and tid in open_trades:
                del open_trades[tid]

        for tid, entry in open_trades.items():
            symbol = entry.get("symbol", "")
            if not symbol:
                continue

            # Verify on Binance
            binance_pos = self.trader.get_position(symbol)
            if not binance_pos:
                logger.info(f"GR recovery: {symbol} not found on Binance, skipping")
                continue

            # Get TP state from log
            tp1_hit = False
            tp2_hit = False
            sl_price = entry.get("sl_price", 0)
            realized_pnl = 0.0

            for e in log:
                if e.get("trade_id") != tid:
                    continue
                evt = e.get("event", "")
                if evt == "GR_TP1_HIT":
                    tp1_hit = True
                    sl_price = e.get("new_sl_price", sl_price)
                    realized_pnl += e.get("realized_pnl_usdt", 0)
                elif evt == "GR_TP2_HIT":
                    tp2_hit = True
                    sl_price = e.get("new_sl_price", sl_price)
                    realized_pnl += e.get("realized_pnl_usdt", 0)

            opened_at = datetime.fromisoformat(entry["timestamp"])

            entry_price = entry.get("entry_price", binance_pos["entry_price"])
            level_price = entry.get("level_price", 0)

            # Detect if BE was already moved: SL at entry price or price past BE trigger
            be_already = False
            if sl_price and entry_price and abs(sl_price - entry_price) < entry_price * 0.001:
                be_already = True
            elif level_price and not tp1_hit:
                # Check if current price is past BE trigger
                sl_dist = abs(entry_price - level_price)
                be_trigger_mult = GERCHIK_BE_TRIGGER_STOPLOSS_MULT
                current_price = self.trader.get_mark_price(symbol)
                if current_price:
                    side = entry.get("side", binance_pos["side"])
                    is_long = side == "BUY"
                    be_trigger = entry_price + sl_dist * be_trigger_mult if is_long else entry_price - sl_dist * be_trigger_mult
                    if (is_long and current_price >= be_trigger) or (not is_long and current_price <= be_trigger):
                        be_already = True
                        sl_price = entry_price  # Set SL to BE

            pos = GerchikPosition(
                symbol=symbol,
                side=entry.get("side", binance_pos["side"]),
                model=entry.get("model", "?"),
                entry_price=entry_price,
                quantity=entry.get("quantity", binance_pos["quantity"]),
                total_quantity=entry.get("quantity", binance_pos["quantity"]),
                remaining_quantity=binance_pos["quantity"],
                sl_price=sl_price,
                tp1_price=entry.get("tp1_price", 0),
                tp2_price=entry.get("tp2_price", 0),
                tp3_price=entry.get("tp3_price", 0),
                level_price=level_price,
                level_strength=entry.get("level_strength", 0),
                trade_id=tid,
                opened_at=opened_at,
                tp1_hit=tp1_hit,
                tp2_hit=tp2_hit,
                realized_pnl=realized_pnl,
            )
            pos.be_moved = be_already
            if be_already:
                logger.info(f"GR recovery: {symbol} BE already triggered, SL at entry")
            self.positions[symbol] = pos

            # Place exchange-side TP/SL orders for recovered position
            if GERCHIK_USE_EXCHANGE_ORDERS:
                try:
                    self.order_mgr.cancel_all_for_symbol(symbol)
                    time.sleep(2.0)  # Let Binance fully process algo order cancellation
                    self._place_recovery_orders(pos)
                    logger.info(
                        f"Recovered GR position: {pos.side} {symbol} @ {pos.entry_price}, "
                        f"model={pos.model}, tp1={tp1_hit}, tp2={tp2_hit}, exchange_orders=YES"
                    )
                except Exception as e:
                    logger.error(f"GR recovery orders failed for {symbol}: {e}", exc_info=True)
            else:
                logger.info(
                    f"Recovered GR position: {pos.side} {symbol} @ {pos.entry_price}, "
                    f"model={pos.model}, tp1={tp1_hit}, tp2={tp2_hit}"
                )

    def format_positions_message(self) -> str:
        """Format Gerchik positions for Telegram."""
        if not self.positions:
            return "πŸ“ НСт Gerchik-ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΉ"

        lines = ["πŸ“ Gerchik ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ:\n━━━━━━━━━━━━━━━━━━━━"]

        for symbol, pos in self.positions.items():
            price = self.trader.get_mark_price(symbol) or pos.entry_price
            pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)

            direction = "L" if pos.side == "BUY" else "S"
            emoji = "🟒" if pnl_pct >= 0 else "πŸ”΄"
            model_lbl = MODEL_LABELS.get(pos.model, "?")

            tp_status = ""
            if pos.tp2_hit:
                tp_status = " TP2βœ…"
            elif pos.tp1_hit:
                tp_status = " TP1βœ…"

            lines.append(
                f"{emoji} {direction} {symbol} | М{pos.model} | {pnl_pct:+.2f}%{tp_status}\n"
                f"   Π£Ρ€ΠΎΠ²Π΅Π½ΡŒ: ${pos.level_price:.4f} | {pos.age_minutes:.0f}ΠΌΠΈΠ½"
            )

        total = self.stats["wins"] + self.stats["losses"]
        wr = (self.stats["wins"] / total * 100) if total > 0 else 0
        lines.append(f"\nπŸ“Š WR: {wr:.0f}% ({self.stats['wins']}W/{self.stats['losses']}L)")
        lines.append(f"πŸ’° PnL: ${self.stats['total_pnl']:+.2f}")
        lines.append("━━━━━━━━━━━━━━━━━━━━")
        return "\n".join(lines)

    async def close_manual(self, symbol: str) -> bool:
        """Manual close from 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)
        fill_price = price
        if result and result.get("fill_price"):
            fill_price = result["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
        close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100
        pnl_usdt -= close_fee
        total_pnl = pos.realized_pnl + pnl_usdt

        if pnl_pct >= 0:
            self.stats["wins"] += 1
        else:
            self.stats["losses"] += 1
        self.stats["total_pnl"] += total_pnl

        log_event("GR_MANUAL_CLOSE", {
            "trade_id": pos.trade_id,
            "symbol": pos.symbol,
            "model": pos.model,
            "exit_price": fill_price,
            "pnl_pct": round(pnl_pct, 2),
            "total_pnl_usdt": round(total_pnl, 2),
        })

        await self.notify(
            f"πŸ‘‹ GR Manual: {pos.symbol} (М{pos.model})\n"
            f"PnL: ${total_pnl:+.2f} ({pnl_pct:+.2f}%)"
        )

        self._cooldowns[pos.symbol] = now_van()
        del self.positions[pos.symbol]
        return True

πŸ“œ Git History

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