#!/usr/bin/env python3
"""
UNIFIED BINANCE FUTURES ADAPTIVE ENGINE (SEALABS X-TRADE) - HEDGE MODE
V12.8 Adaptive Market Brain / WebSocket Hybrid / Predictive + Continuation / Protected-Winner Engine (built on V9.2 risk management).
V9.0 Opportunity Lifecycle Direct Market Engine: Trend, Range/Mean-Reversion, Transition, Micro-Reversion, and event-driven Smart Boundary entries.
Adds strategy routing, chop detection, opportunity ranking, cost-aware short-horizon trading,
cross-symbol context, multi-candle confirmation, and strategy analytics.
Adds WATCH → ARMED → COMMIT → CONFIRMED → EXECUTED lifecycle, structural invalidation cooldowns, reclaim-triggered entries, and idempotent smart-fill processing.
Refactored for GUI / .exe distribution.
"""

import os
import sys
import time
import math
import json
import sqlite3
import logging
import threading
import urllib.parse
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, List, Tuple, Any

import pandas as pd
try:
    from binance.client import Client
    from binance.exceptions import BinanceAPIException, BinanceOrderException
    BINANCE_LIBRARY_AVAILABLE = True
except ImportError:
    Client = None
    BinanceAPIException = Exception
    BinanceOrderException = Exception
    BINANCE_LIBRARY_AVAILABLE = False

logger = logging.getLogger("SEALABS_X-TRADE")
logger.setLevel(logging.INFO)
if not logger.handlers:
    ch = logging.StreamHandler()
    ch.setFormatter(logging.Formatter('%(asctime)s | %(levelname)s | %(message)s'))
    logger.addHandler(ch)

# =============================================================================
# DATABASE FUNCTIONS (with regime, profit protection columns)
# =============================================================================

def db(db_path):
    conn = sqlite3.connect(db_path, timeout=30)
    conn.execute("PRAGMA journal_mode=WAL")
    return conn

def init_database(db_path) -> None:
    with db(db_path) as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS trades (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                symbol TEXT NOT NULL,
                side TEXT NOT NULL,
                entry_time TEXT NOT NULL,
                entry_price REAL NOT NULL,
                entry_quantity REAL NOT NULL,
                entry_order_id TEXT,
                exit_time TEXT,
                exit_price REAL,
                exit_quantity REAL,
                exit_order_id TEXT,
                gross_pnl REAL,
                commission REAL,
                funding REAL,
                net_pnl REAL,
                duration_minutes INTEGER,
                exit_reason TEXT,
                recovered INTEGER DEFAULT 0,
                regime TEXT DEFAULT 'unknown',
                created_at TEXT DEFAULT CURRENT_TIMESTAMP
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS feature_snapshots (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT NOT NULL,
                symbol TEXT NOT NULL,
                timeframe TEXT NOT NULL,
                price REAL,
                ema20 REAL,
                ema50 REAL,
                atr REAL,
                rsi REAL,
                adx REAL,
                volume_ratio REAL,
                momentum REAL,
                trend INTEGER,
                signal TEXT,
                confidence REAL,
                exit_score REAL,
                exit_reason TEXT
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS open_positions (
                symbol TEXT NOT NULL,
                side TEXT NOT NULL,
                entry_time TEXT NOT NULL,
                entry_price REAL NOT NULL,
                quantity REAL NOT NULL,
                notional REAL NOT NULL,
                entry_order_id TEXT,
                highest_price REAL,
                lowest_price REAL,
                recovered INTEGER DEFAULT 0,
                last_exit_score REAL DEFAULT 0,
                last_exit_reason TEXT,
                last_update TEXT NOT NULL,
                peak_net_pnl REAL DEFAULT 0,
                profit_lock_active INTEGER DEFAULT 0,
                profit_floor REAL DEFAULT 0,
                regime TEXT DEFAULT 'unknown',
                source TEXT DEFAULT 'BOT',
                entry_quality_score INTEGER DEFAULT 0,
                last_direction_confidence INTEGER DEFAULT 0,
                PRIMARY KEY(symbol, side)
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS smart_orders (
                symbol TEXT NOT NULL,
                side TEXT NOT NULL,
                strategy TEXT NOT NULL,
                order_id TEXT,
                status TEXT NOT NULL DEFAULT 'ARMED',
                limit_price REAL NOT NULL,
                zone_low REAL,
                zone_high REAL,
                range_high REAL,
                range_low REAL,
                target_price REAL,
                stop_price REAL,
                notional REAL NOT NULL,
                expected_net REAL DEFAULT 0,
                required_net REAL DEFAULT 0,
                quality REAL DEFAULT 0,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL,
                candle_time INTEGER DEFAULT 0,
                reason TEXT DEFAULT '',
                PRIMARY KEY(symbol, side, strategy)
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS smart_fill_events (
                order_id TEXT PRIMARY KEY,
                symbol TEXT NOT NULL,
                side TEXT NOT NULL,
                processed_at TEXT NOT NULL
            )
        """)
        # V8 persistence fields. Existing databases are migrated in-place.
        smart_existing = {row[1] for row in conn.execute("PRAGMA table_info(smart_orders)").fetchall()}
        for col, defn in (
            ("residence_started_at", "TEXT"),
            ("last_revalidated_at", "TEXT"),
            ("soft_fail_started_at", "TEXT"),
            ("revalidation_failures", "INTEGER DEFAULT 0"),
            ("original_quality", "REAL DEFAULT 0"),
            ("original_rr", "REAL DEFAULT 0"),
            ("original_expected_net", "REAL DEFAULT 0"),
            ("last_cancel_reason", "TEXT DEFAULT ''"),
            ("v8_state", "TEXT DEFAULT 'WATCH'"),
            ("trigger_price", "REAL DEFAULT 0"),
            ("armed_at", "TEXT"),
            ("commit_at", "TEXT"),
            ("invalidation_cooldown_until", "TEXT"),
            ("thesis_id", "TEXT DEFAULT ''"),
        ):
            if col not in smart_existing:
                conn.execute(f"ALTER TABLE smart_orders ADD COLUMN {col} {defn}")

        # Add missing columns if any
        existing = {row[1] for row in conn.execute("PRAGMA table_info(open_positions)").fetchall()}
        for col, defn in (("peak_net_pnl", "REAL DEFAULT 0"),
                          ("profit_lock_active", "INTEGER DEFAULT 0"),
                          ("profit_floor", "REAL DEFAULT 0"),
                          ("regime", "TEXT DEFAULT 'unknown'"),
                          ("source", "TEXT DEFAULT 'BOT'"),
                          ("entry_quality_score", "INTEGER DEFAULT 0"),
                          ("last_direction_confidence", "INTEGER DEFAULT 0"),
                          ("v11_pair_id", "TEXT DEFAULT ''"),
                          ("v11_role", "TEXT DEFAULT ''"),
                          ("v11_analysed_side", "TEXT DEFAULT ''"),
                          ("v11_direction", "REAL DEFAULT 0"),
                          ("v11_observation_started", "TEXT"),
                          ("v11_observation_until", "TEXT"),
                          ("v11_survivor", "INTEGER DEFAULT 0")):
            if col not in existing:
                conn.execute(f"ALTER TABLE open_positions ADD COLUMN {col} {defn}")
        existing_trades = {row[1] for row in conn.execute("PRAGMA table_info(trades)").fetchall()}
        if 'regime' not in existing_trades:
            conn.execute("ALTER TABLE trades ADD COLUMN regime TEXT DEFAULT 'unknown'")
        if 'source' not in existing_trades:
            conn.execute("ALTER TABLE trades ADD COLUMN source TEXT DEFAULT 'BOT'")

def save_open_position(p: "Position", db_path) -> None:
    with db(db_path) as conn:
        conn.execute("""
            INSERT OR REPLACE INTO open_positions
            (symbol, side, entry_time, entry_price, quantity, notional,
             entry_order_id, highest_price, lowest_price, recovered,
             last_exit_score, last_exit_reason, last_update,
             peak_net_pnl, profit_lock_active, profit_floor, regime, source,
             entry_quality_score, last_direction_confidence, v11_pair_id, v11_role,
             v11_analysed_side, v11_direction, v11_observation_started,
             v11_observation_until, v11_survivor)
            VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
        """, (
            p.symbol, p.side, p.entry_time, p.entry_price, p.quantity, p.notional,
            p.entry_order_id, p.highest_price, p.lowest_price, int(p.recovered),
            p.last_exit_score, p.last_exit_reason, iso_now(),
            p.peak_net_pnl, int(p.profit_lock_active), p.profit_floor,
            p.regime, p.source, p.entry_quality_score, p.last_direction_confidence,
            getattr(p, "_v11_pair_id", ""), getattr(p, "_v11_role", ""),
            getattr(p, "_v11_analysed_side", ""), safe_float(getattr(p, "_v11_direction", 0)),
            getattr(p, "_v11_observation_started", ""),
            getattr(p, "_v11_observation_until", ""), int(bool(getattr(p, "_v11_survivor", False)))
        ))

def delete_open_position(symbol: str, side: str, db_path) -> None:
    with db(db_path) as conn:
        conn.execute("DELETE FROM open_positions WHERE symbol=? AND side=?", (symbol, side))

def load_saved_position(symbol: str, side: str, live: dict, db_path) -> Optional["Position"]:
    with db(db_path) as conn:
        cols = [desc[0] for desc in conn.execute("PRAGMA table_info(open_positions)").fetchall()]
        row = conn.execute(
            "SELECT * FROM open_positions WHERE symbol=? AND side=?",
            (symbol, side)
        ).fetchone()
    if not row:
        return None
    data = dict(zip(cols, row))
    
    # Safety: if entry_time is missing, log and return None
    if 'entry_time' not in data:
        logger.error("load_saved_position: missing entry_time for %s %s", symbol, side)
        return None

    p = Position(
        symbol=symbol,
        side=side,
        entry_time=data["entry_time"],
        entry_price=live["entry_price"],
        quantity=live["quantity"],
        notional=live["quantity"] * live["entry_price"],
        entry_order_id=data.get("entry_order_id"),
        highest_price=safe_float(data.get("highest_price"), live["mark_price"]),
        lowest_price=safe_float(data.get("lowest_price"), live["mark_price"]),
        recovered=True,
        current_price=live["mark_price"],
        last_exit_score=safe_float(data.get("last_exit_score", 0)),
        last_exit_reason=data.get("last_exit_reason", ""),
        peak_net_pnl=safe_float(data.get("peak_net_pnl", 0)),
        profit_lock_active=bool(data.get("profit_lock_active", False)),
        profit_floor=safe_float(data.get("profit_floor", 0)),
        regime=data.get("regime", "unknown"),
        source=data.get("source", "BOT"),
        entry_quality_score=int(data.get("entry_quality_score", 0)),
        last_direction_confidence=int(data.get("last_direction_confidence", 0)),
    )
    # Restore V11 experiment metadata so a restart cannot accidentally turn a
    # paired position back into an ordinary independently-managed position.
    if data.get("v11_pair_id"):
        p._v11_pair_id = data.get("v11_pair_id", "")
        p._v11_role = data.get("v11_role", "")
        p._v11_analysed_side = data.get("v11_analysed_side", "")
        p._v11_direction = safe_float(data.get("v11_direction", 0))
        p._v11_observation_started = data.get("v11_observation_started", "")
        p._v11_observation_until = data.get("v11_observation_until", "")
        p._v11_survivor = bool(data.get("v11_survivor", 0))
    return p

def insert_trade(data: dict, db_path) -> None:
    with db(db_path) as conn:
        conn.execute("""
            INSERT INTO trades
            (symbol, side, entry_time, entry_price, entry_quantity, entry_order_id,
             exit_time, exit_price, exit_quantity, exit_order_id, gross_pnl,
             commission, funding, net_pnl, duration_minutes, exit_reason,
             recovered, regime, source)
            VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
        """, (
            data["symbol"], data["side"], data["entry_time"], data["entry_price"],
            data["entry_quantity"], data.get("entry_order_id"), data.get("exit_time"),
            data.get("exit_price"), data.get("exit_quantity"), data.get("exit_order_id"),
            data.get("gross_pnl"), data.get("commission"), data.get("funding"),
            data.get("net_pnl"), data.get("duration_minutes"), data.get("exit_reason"),
            int(bool(data.get("recovered", False))),
            data.get("regime", "unknown"),
            data.get("source", "BOT")
        ))

def save_feature(symbol: str, timeframe: str, f: dict, db_path,
                 signal="HOLD", confidence=0, exit_score=0, exit_reason="") -> None:
    with db(db_path) as conn:
        conn.execute("""
            INSERT INTO feature_snapshots
            (timestamp,symbol,timeframe,price,ema20,ema50,atr,rsi,adx,volume_ratio,
             momentum,trend,signal,confidence,exit_score,exit_reason)
            VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
        """, (
            iso_now(), symbol, timeframe, f.get("price"), f.get("ema20"), f.get("ema50"),
            f.get("atr"), f.get("rsi"), f.get("adx"), f.get("volume_ratio"),
            f.get("momentum"), f.get("trend"), signal, confidence, exit_score, exit_reason
        ))

# =============================================================================
# BASIC HELPERS
# =============================================================================

def utc_now() -> datetime:
    return datetime.now(timezone.utc)

def iso_now() -> str:
    return utc_now().isoformat()

def safe_float(value, default=0.0) -> float:
    try:
        return float(value)
    except (TypeError, ValueError):
        return default

def start_of_day_utc() -> datetime:
    """Return midnight (00:00:00) of the current day in UTC."""
    return utc_now().replace(hour=0, minute=0, second=0, microsecond=0)


def get_writable_data_dir() -> str:
    """Return a directory where the app can write its database.

    On Android this is the app's private files directory (permanent, writable).
    On desktop this falls back to the current working directory.
    """
    if 'ANDROID_ARGUMENT' in os.environ or 'ANDROID_PRIVATE' in os.environ:
        path = os.environ.get('ANDROID_PRIVATE') or os.environ.get('ANDROID_APP_PATH', '.')
        try:
            os.makedirs(path, exist_ok=True)
        except Exception:
            pass
        return path
    return os.getcwd()

# =============================================================================
# POSITION MODEL (extended with regime, profit protection, quality)
# =============================================================================

@dataclass
class Position:
    symbol: str
    side: str
    entry_time: str
    entry_price: float
    quantity: float
    notional: float
    entry_order_id: Optional[str] = None
    highest_price: float = 0.0
    lowest_price: float = 0.0
    current_price: float = 0.0
    recovered: bool = False
    closed: bool = False
    last_exit_score: float = 0.0
    last_exit_reason: str = ""
    exit_confirmation_count: int = 0
    peak_net_pnl: float = 0.0
    profit_lock_active: bool = False
    profit_floor: float = 0.0
    last_direction_confidence: int = 0
    entry_quality_score: int = 0
    regime: str = 'unknown'
    source: str = 'BOT'
    _temp_loss_budget: Optional[float] = None  # transient, not saved

    @property
    def position_side(self) -> str:
        return "LONG" if self.side == "BUY" else "SHORT"

    @property
    def entry_timestamp_ms(self) -> int:
        try:
            return int(datetime.fromisoformat(self.entry_time).timestamp() * 1000)
        except Exception:
            return int(time.time() * 1000)

    def update_extremes(self) -> None:
        if self.current_price <= 0:
            return
        if self.highest_price <= 0:
            self.highest_price = self.current_price
        if self.lowest_price <= 0:
            self.lowest_price = self.current_price
        self.highest_price = max(self.highest_price, self.current_price)
        self.lowest_price = min(self.lowest_price, self.current_price)

    def pnl_estimate(self) -> float:
        if self.side == "BUY":
            return (self.current_price - self.entry_price) * self.quantity
        return (self.entry_price - self.current_price) * self.quantity

    def estimated_net_pnl(self, commission_rate=0.0006) -> float:
        gross = self.pnl_estimate()
        fee = (self.entry_price * self.quantity + self.current_price * self.quantity) * commission_rate
        return gross - fee

    def age_seconds(self) -> float:
        try:
            return max(0.0, (utc_now() - datetime.fromisoformat(self.entry_time)).total_seconds())
        except Exception:
            return 0.0

# =============================================================================
# MAIN TRADING ENGINE
# =============================================================================


# =============================================================================
# V12.7.1 WEBSOCKET MARKET + USER DATA BRIDGE
# =============================================================================
class BinanceWebSocketBridge:
    """Low-REST Binance Futures bridge.

    Market data (mark price + 5m candles) is maintained from one combined
    WebSocket connection.  Account/position/order events are maintained from
    the Futures user-data stream. REST remains the recovery/verification path.
    """
    def __init__(self, engine):
        self.engine = engine
        self.stop_event = threading.Event()
        self.market_thread = None
        self.user_thread = None
        self.keepalive_thread = None
        self.market_ws = None
        self.user_ws = None
        self.listen_key = None
        self.lock = threading.RLock()
        self.market_healthy = False
        self.user_healthy = False
        self.last_market_event = 0.0
        self.last_user_event = 0.0
        self.last_user_event_id = 0
        self._ws_error_last = 0.0
        self._order_updates = {}

        try:
            import websocket as websocket_client
            self.websocket_client = websocket_client
            self.available = True
        except ImportError:
            self.websocket_client = None
            self.available = False

        self.testnet = engine.mode == "TESTNET"
        self.ws_base = "wss://stream.binancefuture.com" if self.testnet else "wss://fstream.binance.com"
        self.rest_base = "https://testnet.binancefuture.com" if self.testnet else "https://fapi.binance.com"

    def start(self):
        if self.engine.mode == "PAPER" or not self.available:
            if not self.available:
                logger.warning("V12.7.1 WebSocket disabled: websocket-client is not installed.")
            return
        self.stop_event.clear()
        self.market_thread = threading.Thread(target=self._market_loop, daemon=True, name="qt-market-ws")
        self.market_thread.start()
        self.user_thread = threading.Thread(target=self._user_loop, daemon=True, name="qt-user-ws")
        self.user_thread.start()
        self.keepalive_thread = threading.Thread(target=self._keepalive_loop, daemon=True, name="qt-user-ws-keepalive")
        self.keepalive_thread.start()
        logger.info("V12.7.1 WebSocket bridge started: market + user data.")

    def stop(self):
        self.stop_event.set()
        for ws in (self.market_ws, self.user_ws):
            try:
                if ws:
                    ws.close()
            except Exception:
                pass
        self.market_healthy = False
        self.user_healthy = False

    def _log_ws_error(self, kind, exc):
        now = time.time()
        if now - self._ws_error_last >= 60:
            self._ws_error_last = now
            logger.warning("V12.7.1 %s WebSocket: %s", kind, exc)

    def _market_url(self):
        streams = []
        for symbol in self.engine.symbols:
            low = symbol.lower()
            streams.append(f"{low}@markPrice")
            streams.append(f"{low}@kline_5m")
        return self.ws_base + "/stream?streams=" + "/".join(streams)

    def _market_message(self, _ws, message):
        try:
            payload = json.loads(message)
            data = payload.get("data", payload)
            event = data.get("e", "")
            now = time.time()
            self.last_market_event = now
            self.market_healthy = True
            symbol = str(data.get("s", "")).upper()
            if event == "markPriceUpdate" and symbol in self.engine.symbols:
                price = safe_float(data.get("p"))
                if price > 0:
                    self.engine.cache_set(f"mark:{symbol}", price)
                return
            if event == "kline" and symbol in self.engine.symbols:
                k = data.get("k", {})
                if str(k.get("i", "")) != "5m":
                    return
                self._apply_kline(symbol, k)
        except Exception as exc:
            self._log_ws_error("market message", exc)

    def _apply_kline(self, symbol, k):
        key = f"klines:{symbol}:5m:100"
        now = time.time()
        with self.lock:
            existing = self.engine._cache.get(key, (0, None))[1]
            if not isinstance(existing, pd.DataFrame) or existing.empty:
                return
            row = {
                "time": int(safe_float(k.get("t"))),
                "open": safe_float(k.get("o")),
                "high": safe_float(k.get("h")),
                "low": safe_float(k.get("l")),
                "close": safe_float(k.get("c")),
                "volume": safe_float(k.get("v")),
                "close_time": int(safe_float(k.get("T"))),
                "qav": safe_float(k.get("q")),
                "trades": int(safe_float(k.get("n"))),
                "tb_base": safe_float(k.get("V")),
                "tb_quote": safe_float(k.get("Q")),
                "ignore": 0,
            }
            df = existing.copy()
            # V12.8.1: normalize candle dtypes before WebSocket assignment.
            # REST-loaded frames may infer numeric columns as object/string;
            # explicit coercion prevents pandas incompatible-dtype warnings.
            float_cols = ["open", "high", "low", "close", "volume", "qav", "tb_base", "tb_quote"]
            int_cols = ["time", "close_time", "trades", "ignore"]
            for col in float_cols:
                if col not in df.columns:
                    df[col] = pd.Series(dtype="float64")
                df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64")
            for col in int_cols:
                if col not in df.columns:
                    df[col] = pd.Series(dtype="int64")
                df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64")
            if len(df) and int(safe_float(df.iloc[-1]["time"])) == row["time"]:
                for col, val in row.items():
                    df.at[df.index[-1], col] = val
            else:
                df = pd.concat([df, pd.DataFrame([row])], ignore_index=True)
                if len(df) > 100:
                    df = df.iloc[-100:].reset_index(drop=True)
            self.engine.cache_set(key, df)
            # Also refresh smaller limits from the same authoritative 100-candle cache.
            for lim in (80, 60, 3):
                k2 = f"klines:{symbol}:5m:{lim}"
                self.engine.cache_set(k2, df.iloc[-lim:].copy())

    def _market_loop(self):
        while not self.stop_event.is_set():
            try:
                self.market_healthy = False
                url = self._market_url()
                ws = self.websocket_client.WebSocketApp(
                    url,
                    on_open=lambda _ws: logger.info("V12.7.1 market WebSocket connected."),
                    on_message=self._market_message,
                    on_error=lambda _ws, exc: self._log_ws_error("market", exc),
                    on_close=lambda _ws, code, msg: logger.warning("V12.7.1 market WebSocket closed: %s %s", code, msg),
                )
                self.market_ws = ws
                ws.run_forever(ping_interval=120, ping_timeout=30)
            except Exception as exc:
                self._log_ws_error("market loop", exc)
            finally:
                self.market_healthy = False
                self.market_ws = None
            if not self.stop_event.is_set():
                time.sleep(3)

    def _get_listen_key(self):
        import requests
        r = requests.post(
            self.rest_base + "/fapi/v1/listenKey",
            headers={"X-MBX-APIKEY": self.engine.api_key},
            timeout=(5, 10),
        )
        r.raise_for_status()
        data = r.json()
        key = data.get("listenKey")
        if not key:
            raise RuntimeError("Binance returned no Futures listenKey")
        return key

    def _keepalive_once(self):
        if not self.listen_key:
            return
        import requests
        r = requests.put(
            self.rest_base + "/fapi/v1/listenKey",
            headers={"X-MBX-APIKEY": self.engine.api_key},
            timeout=(5, 10),
        )
        if not r.ok:
            raise RuntimeError(f"listenKey keepalive HTTP {r.status_code}: {r.text[:160]}")

    def _user_message(self, _ws, message):
        try:
            data = json.loads(message)
            event = data.get("e", "")
            self.last_user_event = time.time()
            self.user_healthy = True
            self.last_user_event_id = max(self.last_user_event_id, int(safe_float(data.get("E"), 0)))
            if event == "ACCOUNT_UPDATE":
                self._apply_account_update(data)
            elif event == "ORDER_TRADE_UPDATE":
                self._apply_order_update(data)
            elif event == "listenKeyExpired":
                self.user_healthy = False
                self.listen_key = None
        except Exception as exc:
            self._log_ws_error("user message", exc)

    def _apply_account_update(self, data):
        account = data.get("a", {})
        for bal in account.get("B", []) or []:
            if str(bal.get("a", "")) == "USDT":
                wb = safe_float(bal.get("wb"))
                if wb > 0:
                    self.engine.cache_set("wallet_balance", wb)
        rows = []
        for pos in account.get("P", []) or []:
            symbol = str(pos.get("s", "")).upper()
            if symbol not in self.engine.symbols:
                continue
            amount = safe_float(pos.get("pa"))
            ps = str(pos.get("ps", "BOTH")).upper()
            if ps == "LONG":
                side = "BUY"
            elif ps == "SHORT":
                side = "SELL"
            else:
                if abs(amount) < 1e-12:
                    continue
                side = "BUY" if amount > 0 else "SELL"
            if abs(amount) < 1e-12:
                continue
            rows.append({
                "symbol": symbol,
                "side": side,
                "position_side": ps,
                "quantity": abs(amount),
                "entry_price": safe_float(pos.get("ep")),
                "mark_price": safe_float(self.engine._cache.get(f"mark:{symbol}", (0, 0))[1]),
                "unrealized_pnl": safe_float(pos.get("up")),
            })
        self.engine.cache_set("open_positions", rows)

    def _apply_order_update(self, data):
        o = data.get("o", {})
        oid = str(o.get("i", ""))
        if not oid:
            return
        symbol = str(o.get("s", "")).upper()
        status = str(o.get("X", "")).upper()
        client_id = str(o.get("c", ""))
        snapshot = {
            "symbol": symbol, "orderId": oid, "clientOrderId": client_id,
            "status": status, "side": str(o.get("S", "")).upper(),
            "positionSide": str(o.get("ps", "BOTH")).upper(),
            "origQty": safe_float(o.get("q")), "executedQty": safe_float(o.get("z")),
            "avgPrice": safe_float(o.get("ap")), "price": safe_float(o.get("p")),
            "stopPrice": safe_float(o.get("sp")), "updateTime": int(safe_float(o.get("T"), 0)),
        }
        self._order_updates[oid] = snapshot
        self.engine.cache_set(f"order:{symbol}:{oid}", snapshot)
        if client_id:
            self.engine.cache_set(f"order_client:{client_id}", snapshot)

    def _user_loop(self):
        while not self.stop_event.is_set():
            try:
                self.user_healthy = False
                self.listen_key = self._get_listen_key()
                url = self.ws_base + "/ws/" + urllib.parse.quote(self.listen_key, safe="")
                ws = self.websocket_client.WebSocketApp(
                    url,
                    on_open=lambda _ws: logger.info("V12.7.1 user-data WebSocket connected."),
                    on_message=self._user_message,
                    on_error=lambda _ws, exc: self._log_ws_error("user", exc),
                    on_close=lambda _ws, code, msg: logger.warning("V12.7.1 user WebSocket closed: %s %s", code, msg),
                )
                self.user_ws = ws
                ws.run_forever(ping_interval=120, ping_timeout=30)
            except Exception as exc:
                self._log_ws_error("user loop", exc)
            finally:
                self.user_healthy = False
                self.user_ws = None
            if not self.stop_event.is_set():
                time.sleep(5)

    def _keepalive_loop(self):
        # Binance Futures listenKey streams are valid for 60 minutes; renew well
        # before expiry. This adds one very small REST call per ~45 minutes.
        while not self.stop_event.is_set():
            for _ in range(45 * 60):
                if self.stop_event.is_set():
                    return
                time.sleep(1)
            try:
                self._keepalive_once()
            except Exception as exc:
                self._log_ws_error("listenKey keepalive", exc)
                self.user_healthy = False

    def get_order_update(self, symbol, order_id):
        return self.engine._cache.get(f"order:{symbol}:{order_id}", (0, None))[1]

class TradingEngine:
    def __init__(self, config: dict):
        # ----- Core -----
        self.mode = config.get('mode', 'TESTNET').upper()
        self.api_key = config['api_key']
        self.api_secret = config['api_secret']
        self.telegram_token = config.get('telegram_token', '')
        self.telegram_chat_id = config.get('telegram_chat_id', '')
        self.symbols = config.get('symbols', ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'SOLUSDT'])
        self.leverage = config.get('leverage', 15)
        self.fixed_notional = config.get('fixed_notional', 600.0)
        self.min_notional = config.get('min_notional', 50.0)
        self.max_open_positions = config.get('max_open_positions', len(self.symbols)*2)
        self.max_total_notional = config.get('max_total_notional', 15000.0)
        self.max_trades_per_symbol = config.get('max_trades_per_symbol', 150)
        self.max_daily_loss_percent = config.get('max_daily_loss_percent', 10.0)
        self.emergency_max_loss_percent = config.get('emergency_max_loss_percent', 0.0)

        # ----- Indicator settings -----
        self.atr_period = config.get('atr_period', 14)
        self.rsi_period = config.get('rsi_period', 14)
        self.adx_period = config.get('adx_period', 14)
        self.entry_min_confidence = config.get('entry_min_confidence', 75)
        self.entry_adx_min = config.get('entry_adx_min', 18.0)
        self.entry_volume_ratio_min = config.get('entry_volume_ratio_min', 0.90)
        self.exit_score_threshold = config.get('exit_score_threshold', 80)
        self.exit_confirmations_required = config.get('exit_confirmations_required', 3)
        self.exit_min_hold_seconds = config.get('exit_min_hold_seconds', 120)
        self.exit_score_hard_threshold = config.get('exit_score_hard_threshold', 95)

        # ----- Profit protection -----
        self.max_position_loss_percent = config.get('max_position_loss_percent', 3.0)
        self.profit_protection_activation = config.get('profit_protection_activation', 0.8)
        self.profit_giveback_strong = config.get('profit_giveback_strong', 0.20)
        self.profit_giveback_normal = config.get('profit_giveback_normal', 0.15)
        self.profit_giveback_defensive = config.get('profit_giveback_defensive', 0.12)
        self.profit_direction_confidence_floor = config.get('profit_direction_confidence_floor', 15)
        self.profit_hard_reversal_score = config.get('profit_hard_reversal_score', 90)
        self.profit_min_lock_floor = config.get('profit_min_lock_floor', 0.75)
        # V9: profit protection scales with actual position notional. PNL values
        # here are NET of estimated round-trip commission, so activation/floor
        # thresholds remain economically meaningful as notional increases.
        self.v9_reference_notional = config.get('v9_reference_notional', 600.0)
        self.v9_activation_scale_power = config.get('v9_activation_scale_power', 0.50)
        self.v9_activation_max = config.get('v9_activation_max', 2.50)
        self.v9_min_floor_scale_power = config.get('v9_min_floor_scale_power', 0.50)
        self.v9_min_floor_max = config.get('v9_min_floor_max', 2.50)
        self.v9_peak_tiers = config.get('v9_peak_tiers', True)
        self.v9_peak_1_giveback = config.get('v9_peak_1_giveback', 0.20)
        self.v9_peak_2_giveback = config.get('v9_peak_2_giveback', 0.12)
        self.v9_peak_3_giveback = config.get('v9_peak_3_giveback', 0.10)
        self.v9_peak_5_giveback = config.get('v9_peak_5_giveback', 0.08)
        self.v9_peak_5plus_giveback = config.get('v9_peak_5plus_giveback', 0.08)
        self.v9_strong_adjustment = config.get('v9_strong_adjustment', 0.00)
        self.v9_normal_adjustment = config.get('v9_normal_adjustment', 0.02)
        self.v9_defensive_adjustment = config.get('v9_defensive_adjustment', 0.04)
        self.v9_min_retention = config.get('v9_min_retention', 0.75)
        self.v9_min_profit_after_lock = config.get('v9_min_profit_after_lock', 0.50)

        # ----- Loss protection -----
        self.max_trade_loss = config.get('max_trade_loss', 6.00)
        self.loss_warning = config.get('loss_warning', 2.50)
        self.loss_protective = config.get('loss_protective', 3)
        self.loss_emergency = config.get('loss_emergency', 4)
        self.loss_cooldown_minutes = config.get('loss_cooldown_minutes', 1)

        # ----- Precision entry (quality) -----
        self.entry_margin = config.get('entry_margin', 20)
        self.confirmations = config.get('confirmations', 2)
        self.candidate_expiry = config.get('candidate_expiry', 90)

        # ----- Quality score thresholds -----
        self.entry_quality_min = config.get('entry_quality_min', 85)
        self.max_deviation_atr = config.get('max_deviation_atr', 1.35)
        self.hard_max_deviation_atr = config.get('hard_max_deviation_atr', 2.0)
        self.entry_quality_min_trend = config.get('entry_quality_min_trend', 78)
        self.entry_quality_min_range = config.get('entry_quality_min_range', 78)
        self.entry_quality_min_transition = config.get('entry_quality_min_transition', 76)
        self.min_risk_reward = config.get('min_risk_reward', 0.50)

        # ----- V8.8 ADAPTIVE OPPORTUNITY ENTRY -----
        # Strong directional setups are no longer forced to wait for a range
        # boundary/pullback.  R:R remains a risk control, but becomes adaptive.
        self.v85_enabled = config.get('v85_enabled', True)
        # V8.8 deliberately separates strategy logic. Trend/transition entries
        # do NOT depend on Smart Range min/max. Range keeps the boundary logic.
        self.v85_strong_direction = config.get('v85_strong_direction', 85.0)
        self.v85_strong_timing = config.get('v85_strong_timing', 70.0)
        self.v85_strong_agreement = config.get('v85_strong_agreement', 90.0)
        self.v85_strong_final = config.get('v85_strong_final', 80.0)
        self.v85_strong_adverse_max = config.get('v85_strong_adverse_max', 45.0)
        # Directional market entries can use a lower R:R floor when the live
        # opportunity is exceptionally strong AND economically worthwhile.
        self.v85_adaptive_rr_floor = config.get('v85_adaptive_rr_floor', 0.60)
        self.v85_medium_rr_floor = config.get('v85_medium_rr_floor', 1.00)
        self.v85_fast_entry_score = config.get('v85_fast_entry_score', 80.0)
        self.v85_fast_direction = config.get('v85_fast_direction', 85.0)
        self.v85_fast_timing = config.get('v85_fast_timing', 70.0)
        self.v85_fast_agreement = config.get('v85_fast_agreement', 90.0)
        self.v85_fast_final = config.get('v85_fast_final', 80.0)
        self.v85_fast_adverse_max = config.get('v85_fast_adverse_max', 45.0)
        self.v85_fast_max_deviation_atr = config.get('v85_fast_max_deviation_atr', 1.25)
        self.v85_continuation_max_chase_atr = config.get('v85_continuation_max_chase_atr', 1.25)
        self.v85_require_live_trend = config.get('v85_require_live_trend', True)
        self.v85_allow_fast_market_entry = config.get('v85_allow_fast_market_entry', True)
        self.v85_expected_net_floor = config.get('v85_expected_net_floor', 0.50)
        self.v85_expected_net_strong_floor = config.get('v85_expected_net_strong_floor', 0.80)
        # ----- V8.8 ADAPTIVE OPPORTUNITY / CONTINUATION ENTRY -----
        # Strong directional setups can enter at current market price when the
        # live trend, momentum, timing and risk remain aligned.
        self.v86_enabled = config.get('v86_enabled', True)
        self.v86_strong_direction = config.get('v86_strong_direction', 85.0)
        self.v86_strong_timing = config.get('v86_strong_timing', 70.0)
        self.v86_strong_agreement = config.get('v86_strong_agreement', 90.0)
        self.v86_strong_final = config.get('v86_strong_final', 80.0)
        self.v86_strong_adverse_max = config.get('v86_strong_adverse_max', 42.0)
        self.v86_adaptive_rr_floor = config.get('v86_adaptive_rr_floor', 0.60)
        self.v86_expected_net_floor = config.get('v86_expected_net_floor', 0.80)
        self.v86_fast_direction = config.get('v86_fast_direction', 88.0)
        self.v86_fast_timing = config.get('v86_fast_timing', 70.0)
        self.v86_fast_agreement = config.get('v86_fast_agreement', 90.0)
        self.v86_fast_final = config.get('v86_fast_final', 80.0)
        self.v86_fast_adverse_max = config.get('v86_fast_adverse_max', 42.0)
        self.v86_fast_max_deviation_atr = config.get('v86_fast_max_deviation_atr', 1.20)
        self.v86_cont_direction = config.get('v86_cont_direction', 82.0)
        self.v86_cont_timing = config.get('v86_cont_timing', 68.0)
        self.v86_cont_agreement = config.get('v86_cont_agreement', 88.0)
        self.v86_cont_final = config.get('v86_cont_final', 78.0)
        self.v86_cont_adverse_max = config.get('v86_cont_adverse_max', 44.0)
        self.v86_cont_max_chase_atr = config.get('v86_cont_max_chase_atr', 1.35)
        self.v86_require_live_momentum = config.get('v86_require_live_momentum', True)
        self.v86_require_live_trend = config.get('v86_require_live_trend', True)
        self.v86_allow_market_entry = config.get('v86_allow_market_entry', True)

        # ----- V8.8 DIRECT MARKET EXECUTION -----
        # Automated entries use MARKET orders only. Smart Boundary LIMIT execution is disabled.
        self.v88_direct_market_only = config.get('v88_direct_market_only', True)
        self.v88_disable_limit_entries = True

        # ----- V8.8 OPPORTUNITY-FIRST ENTRY INTELLIGENCE -----
        # Directional opportunities are evaluated at the CURRENT market price.
        # No min/max pullback, limit price, boundary trigger, or second-candle wait
        # is required once the V8.8 market-entry gate is satisfied.
        self.v88_enabled = config.get('v88_enabled', True)
        self.v88_min_direction = config.get('v88_min_direction', 72.0)
        self.v88_min_timing = config.get('v88_min_timing', 60.0)
        self.v88_min_agreement = config.get('v88_min_agreement', 72.0)
        self.v88_min_final = config.get('v88_min_final', 70.0)
        self.v88_max_adverse = config.get('v88_max_adverse', 52.0)
        self.v88_min_rr = config.get('v88_min_rr', 0.45)
        self.v88_expected_net_floor = config.get('v88_expected_net_floor', 0.25)
        self.v88_max_chase_atr = config.get('v88_max_chase_atr', 1.55)
        self.v88_max_hard_chase_atr = config.get('v88_max_hard_chase_atr', 1.85)
        self.v88_min_5m_adx = config.get('v88_min_5m_adx', 14.0)
        self.v88_min_momentum = config.get('v88_min_momentum', 0.00015)
        self.v88_live_fallback_enabled = config.get('v88_live_fallback_enabled', True)
        self.v88_one_pass_entry = config.get('v88_one_pass_entry', True)
        self.v88_allow_trend_entry_without_closed_trigger = config.get('v88_allow_trend_entry_without_closed_trigger', True)
        self.v88_direct_market_only = True
        self.v88_disable_limit_entries = True

        # V8.9 Opportunity Lifecycle Engine
        self.v89_enabled = config.get('v89_enabled', True)
        self.v89_min_direction = config.get('v89_min_direction', 74.0)
        self.v89_min_timing = config.get('v89_min_timing', 58.0)
        self.v89_min_agreement = config.get('v89_min_agreement', 70.0)
        self.v89_min_final = config.get('v89_min_final', 70.0)
        self.v89_max_adverse = config.get('v89_max_adverse', 55.0)
        self.v89_max_chase_atr = config.get('v89_max_chase_atr', 1.80)
        self.v89_hard_chase_atr = config.get('v89_hard_chase_atr', 2.20)
        self.v89_min_momentum = config.get('v89_min_momentum', 0.00012)
        self.v89_acceleration_bonus = config.get('v89_acceleration_bonus', 8.0)
        self.v89_continuation_min_score = config.get('v89_continuation_min_score', 76.0)
        self.v89_strong_direction = config.get('v89_strong_direction', 86.0)
        self.v89_strong_final = config.get('v89_strong_final', 82.0)
        self.v89_extreme_volume_ratio = config.get('v89_extreme_volume_ratio', 3.50)
        self.v89_extreme_candle_atr = config.get('v89_extreme_candle_atr', 2.00)
        self.v89_fast_reconcile_seconds = config.get('v89_fast_reconcile_seconds', 8.0)
        self.v89_kline_cache_seconds = config.get('v89_kline_cache_seconds', 3.0)
        self.v89_mark_cache_seconds = config.get('v89_mark_cache_seconds', 0.75)
        self.v89_entry_report = config.get('v89_entry_report', True)

        # ----- V9.2 FAST LIVE ENTRY ENGINE -----
        # V9.2 keeps V9 profit/risk management, but changes only the entry decision:
        # no timed confirmation and no global consecutive-loss pause. A brief market
        # breath is treated as a live price-state change, not as a cooldown timer.
        self.v92_enabled = config.get('v92_enabled', True)
        self.v92_min_direction = config.get('v92_min_direction', 78.0)
        self.v92_min_final = config.get('v92_min_final', 76.0)
        self.v92_min_execution_score = config.get('v92_min_execution_score', 72.0)
        self.v92_max_adverse = config.get('v92_max_adverse', 58.0)
        self.v92_max_chase_atr = config.get('v92_max_chase_atr', 2.00)
        self.v92_breath_atr = config.get('v92_breath_atr', 0.10)
        self.v92_recovery_atr = config.get('v92_recovery_atr', 0.035)
        self.v92_stall_atr = config.get('v92_stall_atr', 0.06)
        self.v92_history_size = config.get('v92_history_size', 8)
        self.v92_allow_early_continuation = config.get('v92_allow_early_continuation', True)
        self.v92_relax_1m5m_alignment = config.get('v92_relax_1m5m_alignment', True)
        self.v92_disable_global_loss_pause = config.get('v92_disable_global_loss_pause', True)

        self.pullback_min_atr = config.get('pullback_min_atr', 0.20)
        self.pullback_max_atr = config.get('pullback_max_atr', 1.20)
        self.chase_max_atr = config.get('chase_max_atr', 0.90)
        self.entry_volume_ratio_max = config.get('entry_volume_ratio_max', 3.5)
        self.candle_max_atr = config.get('candle_max_atr', 1.60)
        self.range_boundary_pct = config.get('range_boundary_pct', 0.20)
        self.range_rejection_required = config.get('range_rejection_required', True)
        self.breakout_volume_ratio_min = config.get('breakout_volume_ratio_min', 1.25)
        self.breakout_close_buffer_atr = config.get('breakout_close_buffer_atr', 0.12)
        self.entry_confirmation_seconds = config.get('entry_confirmation_seconds', 5)
        self.early_validation_seconds = config.get('early_validation_seconds', 20)
        self.early_validation_max_adverse = config.get('early_validation_max_adverse', 0.005)
        self.regime_adx_trend = config.get('regime_adx_trend', 25)
        self.regime_adx_ranging = config.get('regime_adx_ranging', 20)

        # ----- RANGING MODE -----
        self.ranging_enabled = config.get('ranging_enabled', True)
        self.ranging_confidence_min = config.get('ranging_confidence_min', 10)
        self.ranging_notional_multiplier = config.get('ranging_notional_multiplier', 0.6)
        self.ranging_loss_budget = config.get('ranging_loss_budget', 2.50)
        self.rsi_oversold = config.get('rsi_oversold', 35)
        self.rsi_overbought = config.get('rsi_overbought', 65)
        self.bollinger_period = config.get('bollinger_period', 20)
        self.bollinger_std = config.get('bollinger_std', 2.0)

        # ----- TRANSITION MODE -----
        self.transition_enabled = config.get('transition_enabled', True)
        self.transition_confidence_min = config.get('transition_confidence_min', 15)
        self.transition_notional_multiplier = config.get('transition_notional_multiplier', 0.8)
        self.transition_loss_budget = config.get('transition_loss_budget', 1.60)
        self.transition_range_period = config.get('transition_range_period', 20)
        self.transition_breakout_threshold = config.get('transition_breakout_threshold', 0.01)

        # ----- V6 MULTI-REGIME / STRATEGY ROUTER -----
        self.max_daily_trades = config.get('max_daily_trades', 20)
        self.global_trade_cooldown_seconds = config.get('global_trade_cooldown_seconds', 45)
        self.router_min_score = config.get('router_min_score', 74)
        self.router_min_margin = config.get('router_min_margin', 6)
        self.confirmations_required = config.get('confirmations_required', 2)
        self.confirmation_max_age_seconds = config.get('confirmation_max_age_seconds', 900)
        self.micro_enabled = config.get('micro_enabled', True)
        self.micro_score_min = config.get('micro_score_min', 82)
        self.micro_notional_multiplier = config.get('micro_notional_multiplier', 0.45)
        self.micro_loss_budget = config.get('micro_loss_budget', 1.25)
        self.micro_min_rr = config.get('micro_min_rr', 0.55)
        self.min_expected_net_profit = config.get('min_expected_net_profit', 0.75)
        self.range_quality_min = config.get('range_quality_min', 75)
        self.range_min_atr_width = config.get('range_min_atr_width', 2.0)
        self.range_max_atr_width = config.get('range_max_atr_width', 9.0)
        self.chop_efficiency_max = config.get('chop_efficiency_max', 0.16)
        self.chop_adx_max = config.get('chop_adx_max', 17.0)
        self.vol_expansion_ratio = config.get('vol_expansion_ratio', 1.25)
        self.extreme_btc_guard = config.get('extreme_btc_guard', True)
        self.extreme_btc_momentum = config.get('extreme_btc_momentum', 0.0025)
        self.cross_symbol_penalty = config.get('cross_symbol_penalty', 10)
        self.fee_slippage_buffer = config.get('fee_slippage_buffer', 1.25)
        self.micro_target_atr = config.get('micro_target_atr', 0.55)
        self.micro_stop_atr = config.get('micro_stop_atr', 0.40)
        self.range_target_atr = config.get('range_target_atr', 0.55)
        self.range_stop_atr = config.get('range_stop_atr', 0.45)
        self.transition_target_atr = config.get('transition_target_atr', 1.00)
        self.transition_stop_atr = config.get('transition_stop_atr', 0.70)
        self.trend_target_atr = config.get('trend_target_atr', 1.00)
        self.trend_stop_atr = config.get('trend_stop_atr', 0.70)
        self.strategy_priority = {'trend': 4, 'range': 3, 'transition': 2, 'micro': 1}
        # ----- V7 SMART DYNAMIC BOUNDARY ENTRY (SDBE) — STABILITY / GRACE / REVALIDATION -----
        self.smart_boundary_enabled = config.get('smart_boundary_enabled', False) and not getattr(self, 'v88_disable_limit_entries', True)
        self.smart_boundary_quality_min = config.get('smart_boundary_quality_min', 75)
        self.smart_boundary_range_quality_min = config.get('smart_boundary_range_quality_min', 80)
        self.smart_boundary_activation_atr = config.get('smart_boundary_activation_atr', 1.00)
        self.smart_boundary_zone_atr = config.get('smart_boundary_zone_atr', 0.28)
        self.smart_boundary_inner_offset_atr = config.get('smart_boundary_inner_offset_atr', 0.18)
        self.smart_boundary_breakout_buffer_atr = config.get('smart_boundary_breakout_buffer_atr', 0.15)
        self.smart_boundary_min_rr = config.get('smart_boundary_min_rr', 0.55)
        self.smart_boundary_reprice_atr = config.get('smart_boundary_reprice_atr', 0.20)
        self.smart_boundary_reprice_seconds = config.get('smart_boundary_reprice_seconds', 45)
        self.smart_boundary_max_age_seconds = config.get('smart_boundary_max_age_seconds', 900)
        self.smart_boundary_max_tests = config.get('smart_boundary_max_tests', 8)
        self.smart_boundary_max_pending_per_symbol = config.get('smart_boundary_max_pending_per_symbol', 1)
        self.smart_boundary_notional_multiplier = config.get('smart_boundary_notional_multiplier', 0.55)
        self.smart_boundary_loss_budget = config.get('smart_boundary_loss_budget', 2.25)
        self.smart_boundary_cancel_on_volume_expansion = config.get('smart_boundary_cancel_on_volume_expansion', True)
        self.smart_boundary_min_net_profit = config.get('smart_boundary_min_net_profit', 0.80)
        self.smart_boundary_cost_multiple = config.get('smart_boundary_cost_multiple', 1.50)
        self.smart_boundary_require_revalidation = config.get('smart_boundary_require_revalidation', True)
        self.smart_exchange_recovery_seconds = config.get('smart_exchange_recovery_seconds', 30)
        # ----- V7 SMART LIMIT STABILITY -----
        # A resting limit is not cancelled merely because a small amount of market
        # noise changes one indicator. Hard invalidation still cancels immediately.
        self.smart_boundary_min_residence_seconds = config.get('smart_boundary_min_residence_seconds', 30)
        self.smart_boundary_revalidation_interval_seconds = config.get('smart_boundary_revalidation_interval_seconds', 8)
        self.smart_boundary_soft_grace_seconds = config.get('smart_boundary_soft_grace_seconds', 24)
        self.smart_boundary_quality_soft_drop = config.get('smart_boundary_quality_soft_drop', 18)
        self.smart_boundary_quality_hard_floor = config.get('smart_boundary_quality_hard_floor', 60)
        self.smart_boundary_rr_soft_drop_pct = config.get('smart_boundary_rr_soft_drop_pct', 0.30)
        self.smart_boundary_rr_hard_floor = config.get('smart_boundary_rr_hard_floor', 1.15)
        self.smart_boundary_net_soft_shortfall = config.get('smart_boundary_net_soft_shortfall', 0.25)
        self.smart_boundary_net_hard_floor = config.get('smart_boundary_net_hard_floor', 0.60)
        self.smart_boundary_hard_volume_ratio = config.get('smart_boundary_hard_volume_ratio', 1.80)
        self.smart_boundary_reprice_min_age_seconds = config.get('smart_boundary_reprice_min_age_seconds', 45)
        self.smart_boundary_zone_shift_atr = config.get('smart_boundary_zone_shift_atr', 0.35)
        self.smart_boundary_revalidation_max_failures = config.get('smart_boundary_revalidation_max_failures', 3)
        self.smart_boundary_order_type = 'LIMIT'
        self.smart_boundary_time_in_force = 'GTC'

        # ----- V7 PREDICTIVE ENTRY INTELLIGENCE -----
        # Direction, timing and market-agreement are scored independently.
        # A strong directional bias is NOT sufficient for immediate entry.
        self.v7_enabled = config.get('v7_enabled', True)
        self.v7_min_direction_probability = config.get('v7_min_direction_probability', 72.0)
        self.v7_min_entry_timing = config.get('v7_min_entry_timing', 72.0)
        self.v7_min_market_agreement = config.get('v7_min_market_agreement', 70.0)
        self.v7_min_final_score = config.get('v7_min_final_score', 82.0)
        self.v7_max_adverse_risk = config.get('v7_max_adverse_risk', 45.0)
        self.v7_min_direction_margin = config.get('v7_min_direction_margin', 12.0)
        self.v7_max_resistance_distance_atr = config.get('v7_max_resistance_distance_atr', 0.55)
        self.v7_max_support_distance_atr = config.get('v7_max_support_distance_atr', 0.55)
        self.v7_confirmation_window_seconds = config.get('v7_confirmation_window_seconds', 120)
        self.v7_require_fresh_candle = config.get('v7_require_fresh_candle', True)
        self.v7_block_chop = config.get('v7_block_chop', True)
        self.v7_block_extreme_volume = config.get('v7_block_extreme_volume', True)
        self.v7_extreme_volume_ratio = config.get('v7_extreme_volume_ratio', 3.0)
        self.v7_location_weight = config.get('v7_location_weight', 0.30)
        self.v7_momentum_weight = config.get('v7_momentum_weight', 0.20)
        self.v7_candle_weight = config.get('v7_candle_weight', 0.20)
        self.v7_structure_weight = config.get('v7_structure_weight', 0.20)
        self.v7_volatility_weight = config.get('v7_volatility_weight', 0.10)
        self.v7_final_direction_weight = config.get('v7_final_direction_weight', 0.35)
        self.v7_final_timing_weight = config.get('v7_final_timing_weight', 0.35)
        self.v7_final_agreement_weight = config.get('v7_final_agreement_weight', 0.20)
        self.v7_final_risk_weight = config.get('v7_final_risk_weight', 0.10)

        # ----- V8 PREDICTIVE ENTRY COMMITMENT ENGINE -----
        # Analyze early, wait near the zone, then commit only when price confirms
        # the expected reaction. This prevents the repeated LIMIT -> cancel -> LIMIT
        # cycle seen when a range moves between scans.
        self.v8_enabled = config.get('v8_enabled', True)
        self.v8_arm_proximity_atr = config.get('v8_arm_proximity_atr', 0.60)
        self.v8_commit_zone_atr = config.get('v8_commit_zone_atr', 0.18)
        self.v8_trigger_offset_atr = config.get('v8_trigger_offset_atr', 0.12)
        self.v8_confirmation_seconds = config.get('v8_confirmation_seconds', 6)
        self.v8_revalidation_seconds = config.get('v8_revalidation_seconds', 12)
        self.v8_max_thesis_age_seconds = config.get('v8_max_thesis_age_seconds', 600)
        self.v8_invalidation_cooldown_seconds = config.get('v8_invalidation_cooldown_seconds', 120)
        self.v8_min_commit_score = config.get('v8_min_commit_score', 82)
        self.v8_min_direction_probability = config.get('v8_min_direction_probability', 70.0)
        self.v8_max_adverse_risk = config.get('v8_max_adverse_risk', 48.0)
        self.v8_require_reclaim = config.get('v8_require_reclaim', True)
        self.v8_break_tolerance_atr = config.get('v8_break_tolerance_atr', 0.10)
        self.v8_max_commit_wait_seconds = config.get('v8_max_commit_wait_seconds', 45)
        self.v8_post_commit_grace_seconds = config.get('v8_post_commit_grace_seconds', 30)
        self.v8_one_entry_per_thesis = config.get('v8_one_entry_per_thesis', True)
        self.v8_fill_dedupe_enabled = config.get('v8_fill_dedupe_enabled', True)
        self.v8_fill_event_memory = config.get('v8_fill_event_memory', 500)

        # ----- Timing -----
        self.loop_seconds = config.get('loop_seconds', 2)
        self.dashboard_seconds = config.get('dashboard_seconds', 60)
        self.rest_reconcile_seconds = config.get('rest_reconcile_seconds', 60)
        self.mark_price_cache_seconds = config.get('mark_price_cache_seconds', 1.0)
        self.kline_cache_seconds = config.get('kline_cache_seconds', 8.0)
        self.trade_throttle_seconds = config.get('trade_throttle_seconds', 15)
        self.api_call_delay = config.get('api_call_delay', 0.11)
        self.rate_limit_cooldown = config.get('rate_limit_cooldown', 60)
        self.db_path = config.get('db_path', os.path.join(get_writable_data_dir(), 'SealabsX-Trade.db'))
        self.estimated_commission_rate = config.get('estimated_commission_rate', 0.0006)
        self.telegram_alert_cooldown = config.get('telegram_alert_cooldown', 300)
        # V12.7.1 WebSocket-first live state. REST is recovery/verification only.
        self.websocket_enabled = config.get('websocket_enabled', True)
        self.websocket_market_stale_seconds = config.get('websocket_market_stale_seconds', 20.0)
        self.websocket_user_stale_seconds = config.get('websocket_user_stale_seconds', 180.0)
        self.websocket_order_poll_fallback_seconds = config.get('websocket_order_poll_fallback_seconds', 120.0)
        self.websocket_rest_reconcile_flat_seconds = config.get('websocket_rest_reconcile_flat_seconds', 900.0)
        self.websocket_rest_reconcile_position_seconds = config.get('websocket_rest_reconcile_position_seconds', 300.0)

        # ----- Internal state -----
        self.running = True
        self.paused = False
        self.positions: Dict[str, Dict[str, Position]] = {}
        self.lock = threading.RLock()
        self.last_trade_time: Dict[Tuple[str, str], float] = {}
        self.latest_signals: Dict[str, tuple] = {}  # (signal, confidence, regime, status, quality, reason)
        self.latest_exit: Dict[Tuple[str, str], Tuple[int, str]] = {}
        self.dashboard_last = 0.0
        self.paper_daily_pnl = 0.0
        self.last_rest_reconcile = 0.0
        self.state_known = (self.mode == "PAPER")
        self.last_state_error = ""
        self.dashboard_message_id = None
        self._unknown_alert_sent = False
        self._last_dashboard_text = ""
        self._stop_event = None
        self.cooldowns: Dict[Tuple[str, str], float] = {}
        self.candidates: Dict[Tuple[str, str, str], Dict] = {}
        self.last_global_trade_time = 0.0
        self.latest_strategy_candidates: Dict[str, list] = {}
        self.latest_market_context: Dict[str, dict] = {}
        # V7 smart boundary state: plans may be ARMED locally before an exchange order is placed.
        self.smart_plans: Dict[Tuple[str, str, str], dict] = {}
        self.smart_orders: Dict[Tuple[str, str, str], dict] = {}
        self.last_smart_order_action: Dict[Tuple[str, str, str], float] = {}
        self.last_smart_exchange_recovery = 0.0
        self.v8_previous_prices: Dict[str, float] = {}
        self.v92_price_history: Dict[str, list] = {s: [] for s in self.symbols}
        self.v92_last_entry_state: Dict[str, dict] = {}
        # ----- V10 EARLY-ENTRY / SNAP-REVERSAL ENGINE -----
        # V10 deliberately separates strategic thesis creation from execution.
        # A thesis is created from the best directional intelligence available,
        # while execution uses only fast live conditions and never calls V9.2 gates.
        self.v10_enabled = config.get("v10_enabled", True)
        # V10 movement-birth execution model. Direction is a thesis, not an entry.
        # A thesis starts only when a fresh movement is detected near its origin.
        self.v10_thesis_min_direction = config.get("v10_thesis_min_direction", 62.0)
        self.v10_movement_start_direction = config.get("v10_movement_start_direction", 62.0)
        self.v10_movement_break_atr = config.get("v10_movement_break_atr", 0.06)
        self.v10_movement_accel_threshold = config.get("v10_movement_accel_threshold", 0.00010)
        self.v10_movement_window_seconds = config.get("v10_movement_window_seconds", 12.0)
        self.v10_origin_max_distance_atr = config.get("v10_origin_max_distance_atr", 0.25)
        self.v10_thesis_refresh_direction_gap = config.get("v10_thesis_refresh_direction_gap", 12.0)
        self.v10_early_max_consumed = config.get("v10_early_max_consumed", 20.0)
        self.v10_prime_max_consumed = config.get("v10_prime_max_consumed", 30.0)
        self.v10_accept_max_consumed = config.get("v10_accept_max_consumed", 50.0)
        self.v10_last_chance_max_consumed = config.get("v10_last_chance_max_consumed", 60.0)
        self.v10_snap_arm_consumed = config.get("v10_snap_arm_consumed", 65.0)
        self.v10_thesis_max_age = config.get("v10_thesis_max_age", 600.0)
        self.v10_fast_min_confirmations = config.get("v10_fast_min_confirmations", 2)
        self.v10_fast_min_score = config.get("v10_fast_min_score", 58.0)
        self.v10_snap_min_score = config.get("v10_snap_min_score", 55.0)
        self.v10_snap_notional_mult = config.get("v10_snap_notional_mult", 0.65)
        self.v10_snap_loss_budget = config.get("v10_snap_loss_budget", 2.0)
        self.v10_theses: Dict[str, dict] = {}
        self.v10_last_fast_state: Dict[str, dict] = {}
        self.v10_live_tape: Dict[str, list] = {sym: [] for sym in self.symbols}
        self.v10_last_movement: Dict[str, dict] = {}
        self.v10_entry_cooldown: Dict[str, float] = {}
        self.v10_last_entry_attempt: Dict[str, float] = {}

        # ----- V11 ZERO-LAG / REVERSE EXPERIMENT ENGINE -----
        # V11 deliberately preserves the existing intelligence/analysis pipeline.
        # It removes the extra movement-birth/confirmation wait from the ENTRY path.
        # The latest strategic direction is captured and acted on immediately.
        self.v11_enabled = config.get("v11_enabled", True)
        self.v11_direction_threshold = config.get("v11_direction_threshold", 65.0)
        self.v11_direction_margin = config.get("v11_direction_margin", 5.0)
        self.v11_max_extension_atr = config.get("v11_max_extension_atr", 2.50)
        self.v11_pair_compare = config.get("v11_pair_compare", True)
        self.v11_leg_notional_multiplier = config.get("v11_leg_notional_multiplier", 0.50)
        self.v11_reverse_only = config.get("v11_reverse_only", False)
        self.v11_max_pairs_per_day = config.get("v11_max_pairs_per_day", 10)
        # Pair mode is an experiment, not a hedged profit strategy. Equal long/short
        # legs largely cancel gross PNL, so the bot must observe first and only
        # release the losing leg after the winner has demonstrated real movement.
        self.v11_pair_observation_seconds = config.get("v11_pair_observation_seconds", 300.0)
        self.v11_pair_max_observation_seconds = config.get("v11_pair_max_observation_seconds", 900.0)
        self.v11_pair_min_winner_net = config.get("v11_pair_min_winner_net", 1.00)
        self.v11_pair_min_winner_direction = config.get("v11_pair_min_winner_direction", 60)
        self.v11_pair_emergency_leg_loss = config.get("v11_pair_emergency_leg_loss", 8.00)
        self.v11_pair_release_buffer = config.get("v11_pair_release_buffer", 0.35)
        self.v11_pair_post_release_min_hold = config.get("v11_pair_post_release_min_hold", 180.0)
        self.v11_pair_count = 0
        self.v11_pair_day = datetime.utcnow().date()
        self.v11_last_entry: Dict[str, dict] = {}
        self.v11_pair_seq = 0

        # ----- V12 PREDICTIVE SINGLE-ENTRY ENGINE -----
        # V12 is not a pair experiment. It keeps the existing market intelligence
        # but tries to identify the *pre-move state* before entering. One symbol gets
        # one position per movement; there are no staged adds and no automatic
        # long+short hedge at entry. A reversal is allowed only after a strong thesis
        # failure and a newly-confirmed opposite prediction.
        self.v12_enabled = config.get("v12_enabled", True)
        self.v12_min_prediction = config.get("v12_min_prediction", 72.0)
        self.v12_min_direction_margin = config.get("v12_min_direction_margin", 15.0)
        self.v12_min_structure = config.get("v12_min_structure", 65.0)
        self.v12_min_pre_move_score = config.get("v12_min_pre_move_score", 72.0)
        self.v12_max_extension_atr = config.get("v12_max_extension_atr", 0.55)
        self.v12_max_pressure_atr = config.get("v12_max_pressure_atr", 0.50)
        self.v12_min_compression = config.get("v12_min_compression", 0.40)
        self.v12_max_volume_ratio = config.get("v12_max_volume_ratio", 2.20)
        self.v12_min_volume_ratio = config.get("v12_min_volume_ratio", 0.45)
        self.v12_min_breakout_room_atr = config.get("v12_min_breakout_room_atr", 0.60)
        self.v126_early_extension_atr = config.get('v126_early_extension_atr', 0.90)
        self.v126_developing_extension_atr = config.get('v126_developing_extension_atr', 1.20)
        self.v126_hard_extension_atr = config.get('v126_hard_extension_atr', 1.40)
        self.v126_early_pressure_atr = config.get('v126_early_pressure_atr', 0.80)
        self.v126_developing_pressure_atr = config.get('v126_developing_pressure_atr', 0.90)
        self.v126_early_compression = config.get('v126_early_compression', 0.25)
        self.v126_developing_room_atr = config.get('v126_developing_room_atr', 0.35)
        # V12.6 EXPERT GUARD: protect the strategy from entering after the move has
        # already consumed too much of its available displacement.
        self.v126_max_move_consumed_pct = config.get('v126_max_move_consumed_pct', 35.0)
        self.v126_max_entry_extension_atr = config.get('v126_max_entry_extension_atr', 0.75)
        self.v126_max_entry_pressure_atr = config.get('v126_max_entry_pressure_atr', 0.55)
        self.v126_min_entry_rr = config.get('v126_min_entry_rr', 1.80)
        self.v126_assumed_stop_atr = config.get('v126_assumed_stop_atr', 0.45)
        self.v126_min_expected_net = config.get('v126_min_expected_net', 1.50)
        self.v126_dashboard_min_interval = config.get('v126_dashboard_min_interval', 60.0)

        # ----- V12.6 EXPERT ADAPTIVE DECISION LAYER -----
        # This layer is the final gate above the existing prediction model. It does
        # not replace the underlying signal engines; it decides whether the current
        # opportunity is early enough, economically worthwhile, and appropriate for
        # the current market regime.
        self.v126_expert_enabled = config.get('v126_expert_enabled', True)
        self.v126_expert_min_prediction = config.get('v126_expert_min_prediction', 74.0)
        self.v126_expert_min_premove = config.get('v126_expert_min_premove', 65.0)
        self.v126_expert_min_structure = config.get('v126_expert_min_structure', 65.0)
        self.v126_expert_max_consumed = config.get('v126_expert_max_consumed', 35.0)
        self.v126_expert_max_extension = config.get('v126_expert_max_extension', 0.75)
        self.v126_expert_max_pressure = config.get('v126_expert_max_pressure', 0.55)
        self.v126_expert_min_rr = config.get('v126_expert_min_rr', 1.80)
        self.v126_expert_min_expected_net = config.get('v126_expert_min_expected_net', 1.50)
        self.v126_expert_max_risk_dollars = config.get('v126_expert_max_risk_dollars', 4.50)

        # ----- V12.7 ADAPTIVE OPPORTUNITY TIERS -----
        # V12.6 required every quality gate to pass simultaneously. V12.7 keeps
        # the anti-chase and risk protections, but allows good setups to qualify
        # at reduced size instead of waiting indefinitely for a perfect setup.
        self.v127_tier_a_prediction = config.get('v127_tier_a_prediction', 78.0)
        self.v127_tier_a_premove = config.get('v127_tier_a_premove', 70.0)
        self.v127_tier_a_consumed = config.get('v127_tier_a_consumed', 30.0)
        self.v127_tier_a_extension = config.get('v127_tier_a_extension', 0.55)
        self.v127_tier_a_pressure = config.get('v127_tier_a_pressure', 0.50)
        self.v127_tier_a_rr = config.get('v127_tier_a_rr', 1.80)
        self.v127_tier_a_structure = config.get('v127_tier_a_structure', 65.0)
        self.v127_tier_a_expected_net = config.get('v127_tier_a_expected_net', 1.50)

        self.v127_tier_b_prediction = config.get('v127_tier_b_prediction', 74.0)
        self.v127_tier_b_premove = config.get('v127_tier_b_premove', 60.0)
        self.v127_tier_b_consumed = config.get('v127_tier_b_consumed', 40.0)
        self.v127_tier_b_extension = config.get('v127_tier_b_extension', 0.75)
        self.v127_tier_b_pressure = config.get('v127_tier_b_pressure', 0.65)
        self.v127_tier_b_rr = config.get('v127_tier_b_rr', 1.50)
        self.v127_tier_b_structure = config.get('v127_tier_b_structure', 62.0)
        self.v127_tier_b_expected_net = config.get('v127_tier_b_expected_net', 1.05)

        self.v127_tier_c_prediction = config.get('v127_tier_c_prediction', 70.0)
        self.v127_tier_c_premove = config.get('v127_tier_c_premove', 55.0)
        self.v127_tier_c_consumed = config.get('v127_tier_c_consumed', 45.0)
        self.v127_tier_c_extension = config.get('v127_tier_c_extension', 0.90)
        self.v127_tier_c_pressure = config.get('v127_tier_c_pressure', 0.75)
        self.v127_tier_c_rr = config.get('v127_tier_c_rr', 1.30)
        self.v127_tier_c_structure = config.get('v127_tier_c_structure', 60.0)
        self.v127_tier_c_expected_net = config.get('v127_tier_c_expected_net', 0.75)
        self.v127_tier_c_max_notional_fraction = config.get('v127_tier_c_max_notional_fraction', 0.60)
        self.v127_tier_b_max_notional_fraction = config.get('v127_tier_b_max_notional_fraction', 0.80)
        # ----- V12.8 ADAPTIVE MARKET PARTICIPATION / CONTINUATION BRAIN -----
        # V12.7 was too dependent on the idea that a good trade must still be in
        # PRE_MOVE/EARLY_MOVE. V12.8 separates two valid opportunities:
        #   1) PREDICTIVE: enter before expansion (prefer LIMIT).
        #   2) CONTINUATION: join a healthy trend after expansion has started, but
        #      only while live momentum/structure still show room for continuation.
        # Continuation is smaller-risk and is NEVER allowed after an exhausted move.
        self.v128_enabled = config.get('v128_enabled', True)
        self.v128_cont_min_prediction = config.get('v128_cont_min_prediction', 74.0)
        self.v128_cont_min_margin = config.get('v128_cont_min_margin', 10.0)
        self.v128_cont_min_structure = config.get('v128_cont_min_structure', 58.0)
        self.v128_cont_min_remaining = config.get('v128_cont_min_remaining', 15.0)
        self.v128_cont_max_extension = config.get('v128_cont_max_extension', 1.65)
        self.v128_cont_hard_extension = config.get('v128_cont_hard_extension', 2.00)
        self.v128_cont_max_pressure = config.get('v128_cont_max_pressure', 0.95)
        self.v128_cont_min_adx = config.get('v128_cont_min_adx', 18.0)
        self.v128_cont_min_momentum = config.get('v128_cont_min_momentum', 0.00010)
        self.v128_cont_min_score = config.get('v128_cont_min_score', 68.0)
        self.v128_cont_target_atr = config.get('v128_cont_target_atr', 0.90)
        self.v128_cont_stop_atr = config.get('v128_cont_stop_atr', 0.55)
        self.v128_cont_min_rr = config.get('v128_cont_min_rr', 1.10)
        self.v128_cont_notional_fraction = config.get('v128_cont_notional_fraction', 0.55)
        self.v128_cont_strong_fraction = config.get('v128_cont_strong_fraction', 0.70)
        self.v128_cont_volume_max = config.get('v128_cont_volume_max', 3.50)
        # V12.8.1 controlled micro-trend opportunity mode
        self.v1281_micro_enabled = config.get('v1281_micro_enabled', True)
        self.v1281_micro_min_prediction = config.get('v1281_micro_min_prediction', 62.0)
        self.v1281_micro_min_margin = config.get('v1281_micro_min_margin', 7.0)
        self.v1281_micro_min_structure = config.get('v1281_micro_min_structure', 52.0)
        self.v1281_micro_min_score = config.get('v1281_micro_min_score', 58.0)
        self.v1281_micro_min_adx = config.get('v1281_micro_min_adx', 14.0)
        self.v1281_micro_max_extension = config.get('v1281_micro_max_extension', 1.25)
        self.v1281_micro_max_pressure = config.get('v1281_micro_max_pressure', 0.80)
        self.v1281_micro_min_remaining = config.get('v1281_micro_min_remaining', 10.0)
        self.v1281_micro_notional_fraction = config.get('v1281_micro_notional_fraction', 0.30)
        self.v1281_micro_min_expected_net = config.get('v1281_micro_min_expected_net', 0.35)
        self.v128_allow_market_entry = config.get('v128_allow_market_entry', True)
        self.v128_allow_developing_continuation = config.get('v128_allow_developing_continuation', True)

        # ----- V13 DIRECTION-FIRST ENTRY BRAIN -----
        # V13 deliberately trades less often. It will not use a numerical prediction
        # alone to justify an entry. A market entry requires multi-timeframe directional
        # agreement, live momentum, a fresh trigger and acceptable entry location.
        self.v13_enabled = config.get('v13_enabled', True)
        self.v13_min_direction = config.get('v13_min_direction', 82.0)
        self.v13_min_margin = config.get('v13_min_margin', 20.0)
        self.v13_min_adx_5m = config.get('v13_min_adx_5m', 20.0)
        self.v13_min_adx_15m = config.get('v13_min_adx_15m', 18.0)
        self.v13_min_momentum_1m = config.get('v13_min_momentum_1m', 0.00012)
        self.v13_min_momentum_5m = config.get('v13_min_momentum_5m', 0.00008)
        self.v13_max_extension_atr = config.get('v13_max_extension_atr', 0.65)
        self.v13_max_pressure_atr = config.get('v13_max_pressure_atr', 0.70)
        self.v13_min_room_atr = config.get('v13_min_room_atr', 0.55)
        self.v13_min_trigger_score = config.get('v13_min_trigger_score', 80.0)
        self.v13_min_prediction = config.get('v13_min_prediction', 80.0)
        self.v13_min_volume_ratio = config.get('v13_min_volume_ratio', 0.55)
        self.v13_max_volume_ratio = config.get('v13_max_volume_ratio', 2.60)
        self.v13_reentry_cooldown = config.get('v13_reentry_cooldown', 900.0)
        self.v13_reentry_reset_atr = config.get('v13_reentry_reset_atr', 0.75)
        self.v13_reentry_pullback_atr = config.get('v13_reentry_pullback_atr', 0.25)
        self.v13_disable_micro = config.get('v13_disable_micro', True)
        self.v13_require_all_htf = config.get('v13_require_all_htf', True)
        # Integrated Expert Brain: direction is established independently of the legacy
        # prediction score.  1H/15M/5M form the directional backbone; 1M confirms
        # timing and may be neutral during a controlled pullback, but may never oppose.
        self.v13_min_htf_agreement = config.get('v13_min_htf_agreement', 3)
        self.v13_require_1h_15m_same = config.get('v13_require_1h_15m_same', True)
        self.v13_min_direction_margin = config.get('v13_min_direction_margin', 18.0)
        self.v13_min_live_trigger = config.get('v13_min_live_trigger', 72.0)
        self.v13_max_chase_atr = config.get('v13_max_chase_atr', 0.55)
        self.v13_max_adverse_entry_atr = config.get('v13_max_adverse_entry_atr', 0.12)
        self.v13_require_fresh_candle = config.get('v13_require_fresh_candle', True)
        self.v13_setup_expiry_seconds = config.get('v13_setup_expiry_seconds', 90.0)
        self.v13_last_setup = {}
        self.v13_entry_accuracy = {}
        self.v13_last_exit: Dict[str, dict] = {}
        self.v13_entry_count_today = 0
        # V15 HISTORICAL LEARNING BRAIN — calibration only. It never creates a
        # hard rejection gate, so added intelligence cannot freeze the engine.
        self.learning_enabled = config.get('learning_enabled', True)
        self.learning_min_samples = int(config.get('learning_min_samples', 8))
        self.learning_max_score_adjustment = float(config.get('learning_max_score_adjustment', 6.0))
        self.learning_min_size_factor = float(config.get('learning_min_size_factor', 0.72))
        self.learning_max_size_factor = float(config.get('learning_max_size_factor', 1.10))
        self.learning_update_seconds = int(config.get('learning_update_seconds', 30))
        self.learning_last_update = 0.0
        self.learning_open = {}
        self.learning_lock = threading.RLock()
        self._v15_init_learning_db()
        self.v126_expert_min_notional = config.get('v126_expert_min_notional', 50.0)
        self.v126_expert_max_notional = config.get('v126_expert_max_notional', 600.0)
        self.v126_expert_low_vol_min_compression = config.get('v126_expert_low_vol_min_compression', 0.55)
        self.v126_expert_low_vol_min_premove = config.get('v126_expert_low_vol_min_premove', 80.0)
        self.v126_max_pending_entries = config.get('v126_max_pending_entries', 1)
        self.v126_trail_peak_1 = config.get('v126_trail_peak_1', 0.75)
        self.v126_trail_peak_2 = config.get('v126_trail_peak_2', 0.80)
        self.v126_trail_peak_3 = config.get('v126_trail_peak_3', 0.86)
        self.v126_trail_peak_5 = config.get('v126_trail_peak_5', 0.90)
        self.v126_trail_peak_10 = config.get('v126_trail_peak_10', 0.92)
        self.v126_expert_cross_penalty = config.get('v126_expert_cross_penalty', 8.0)
        self.v126_expert_rank_weight_prediction = config.get('v126_expert_rank_weight_prediction', 0.24)
        self.v126_expert_rank_weight_premove = config.get('v126_expert_rank_weight_premove', 0.18)
        self.v126_expert_rank_weight_structure = config.get('v126_expert_rank_weight_structure', 0.14)
        self.v126_expert_rank_weight_rr = config.get('v126_expert_rank_weight_rr', 0.14)
        self.v126_expert_rank_weight_net = config.get('v126_expert_rank_weight_net', 0.12)
        self.v126_expert_rank_weight_regime = config.get('v126_expert_rank_weight_regime', 0.10)
        self.v126_expert_rank_weight_location = config.get('v126_expert_rank_weight_location', 0.08)
        self.v126_thesis_soft_adverse_atr = config.get('v126_thesis_soft_adverse_atr', 0.12)
        self.v126_thesis_failure_atr = config.get('v126_thesis_failure_atr', 0.40)
        self.v126_thesis_failure_min_age = config.get('v126_thesis_failure_min_age', 45.0)
        self.v126_thesis_recovery_atr = config.get('v126_thesis_recovery_atr', 0.10)
        self.v126_thesis_recheck_seconds = config.get('v126_thesis_recheck_seconds', 30.0)
        self.v126_thesis_opposite_min_prediction = config.get('v126_thesis_opposite_min_prediction', 88.0)
        self.v126_thesis_opposite_min_margin = config.get('v126_thesis_opposite_min_margin', 25.0)
        self.v126_thesis_opposite_min_score = config.get('v126_thesis_opposite_min_score', 84.0)
        self.v126_thesis_states: Dict[Tuple[str, str], dict] = {}
        self.v12_reversal_prediction = config.get("v12_reversal_prediction", 88.0)
        self.v12_reversal_margin = config.get("v12_reversal_margin", 25.0)
        self.v12_reversal_score = config.get("v12_reversal_score", 84.0)
        self.v12_reversal_min_age = config.get("v12_reversal_min_age", 180.0)
        self.v12_reversal_max_loss = config.get("v12_reversal_max_loss", 4.0)
        self.v12_reentry_cooldown = config.get("v12_reentry_cooldown", 240.0)
        self.v12_reentry_reset_atr = config.get("v12_reentry_reset_atr", 0.50)
        self.v12_max_daily_entries = config.get("v12_max_daily_entries", 12)
        # V12.3 thesis-hold protection. Predictive positions are NOT closed merely
        # because a few fast indicators temporarily disagree. The normal V9
        # protective-loss governor ($3 in the old build) is intentionally bypassed
        # for V12. V12 closes on its own monetary loss budget or a genuine,
        # high-confidence controlled reversal.
        self.v12_max_loss = config.get("v12_max_loss", 6.00)
        self.v12_emergency_loss = config.get("v12_emergency_loss", 7.00)
        self.v12_adaptive_loss_min_adverse_atr = config.get("v12_adaptive_loss_min_adverse_atr", 0.30)
        self.v12_adaptive_loss_min_age = config.get("v12_adaptive_loss_min_age", 45.0)
        self.v12_adaptive_loss_confirmations = config.get("v12_adaptive_loss_confirmations", 5)
        self.v12_hold_direction_floor = config.get("v12_hold_direction_floor", 45.0)
        self.v12_profit_giveback = config.get("v12_profit_giveback", 0.10)
        self.v12_profit_giveback_mid = config.get("v12_profit_giveback_mid", 0.10)
        self.v12_profit_giveback_high = config.get("v12_profit_giveback_high", 0.08)
        self.v12_profit_confirmations = config.get("v12_profit_confirmations", 1)
        # V12.2 profit protection: V12 positions need more breathing room than the
        # older V9 lock. A normal pullback must not turn a profitable position into
        # a forced loss simply because the old floor was too close to the peak.
        self.v12_profit_exit_buffer = config.get("v12_profit_exit_buffer", 0.15)
        self.v12_profit_min_positive = config.get("v12_profit_min_positive", 0.75)
        self.v12_profit_breach_count = {}
        # V12.6: once a predictive trade has demonstrated meaningful profit, that
        # profit becomes a protected state. The ordinary V12 loss budget must not
        # be allowed to turn a proven winner into a large loser.
        self.v126_profit_lock_min_activation = config.get('v126_profit_lock_min_activation', 1.50)
        self.v126_profit_floor_retention = config.get('v126_profit_floor_retention', 0.90)
        self.v126_profit_floor_min = config.get('v126_profit_floor_min', 0.75)
        self.v126_profit_floor_grace_seconds = config.get('v126_profit_floor_grace_seconds', 2.0)
        # V13.7 EARLY WINNER PROTECTION: the Smart Expert Brain can protect a
        # correctly timed trade much earlier than the old $1.50 V12 threshold.
        # Thresholds are adaptive to entry quality so weak trades do not get a
        # falsely tight profit lock. These are NET PNL estimates.
        self.v127_early_profit_protection = config.get('v127_early_profit_protection', True)
        self.v127_activation_strong = config.get('v127_activation_strong', 0.30)
        self.v127_activation_good = config.get('v127_activation_good', 0.40)
        self.v127_activation_standard = config.get('v127_activation_standard', 0.50)
        self.v127_strong_entry_score = config.get('v127_strong_entry_score', 92)
        self.v127_strong_direction_confidence = config.get('v127_strong_direction_confidence', 88)
        self.v127_good_entry_score = config.get('v127_good_entry_score', 85)
        self.v127_good_direction_confidence = config.get('v127_good_direction_confidence', 82)
        self.v127_initial_floor_min = config.get('v127_initial_floor_min', 0.20)
        self.v127_initial_retention = config.get('v127_initial_retention', 0.70)
        self.v127_early_execution_buffer = config.get('v127_early_execution_buffer', 0.06)
        self.v127_max_execution_buffer = config.get('v127_max_execution_buffer', 0.15)
        self.v127_profit_confirmations = config.get('v127_profit_confirmations', 2)
        self.v127_exchange_stop_min_peak = config.get('v127_exchange_stop_min_peak', 0.75)
        self.v126_profit_floor_breach_started: Dict[Tuple[str, str], float] = {}
        # V12.5 exchange-side protection: local polling is not allowed to be the first line of defense.
        self.v125_hard_loss_trigger = config.get("v125_hard_loss_trigger", 5.00)
        self.v125_profit_retention = config.get("v125_profit_retention", 0.90)
        self.v125_profit_retention_mid = config.get("v125_profit_retention_mid", 0.90)
        self.v125_profit_retention_high = config.get("v125_profit_retention_high", 0.92)
        self.v125_profit_stop_buffer = config.get("v125_profit_stop_buffer", 0.25)
        self.v125_protection_refresh_seconds = config.get("v125_protection_refresh_seconds", 4.0)
        self.v125_last_protection_refresh: Dict[Tuple[str, str], float] = {}
        self.v125_protection_orders: Dict[Tuple[str, str], Dict[str, str]] = {}
        self.v12_entries_today = 0
        self.v12_last_entry_state: Dict[str, dict] = {}
        # V12.6 adaptive predictive LIMIT-entry lifecycle.
        self.v126_entry_enabled = config.get('v126_entry_enabled', True)
        self.v126_entry_max_age_seconds = config.get('v126_entry_max_age_seconds', 120.0)
        self.v126_entry_recheck_seconds = config.get('v126_entry_recheck_seconds', 30.0)
        self.v126_entry_max_reprices = config.get('v126_entry_max_reprices', 1)
        self.v126_entry_min_offset_atr = config.get('v126_entry_min_offset_atr', 0.10)
        self.v126_entry_offset_atr = config.get('v126_entry_offset_atr', 0.18)
        self.v126_entry_max_offset_atr = config.get('v126_entry_max_offset_atr', 0.30)
        self.v126_pending_entries: Dict[str, dict] = {}
        self.v126_entries_today = 0
        self.v126_recovery_done = False
        self.v12_last_close: Dict[str, dict] = {}
        self.v12_last_entry_price: Dict[str, float] = {}
        self.v8_thesis_cooldowns: Dict[Tuple[str, str], float] = {}
        self.v8_processed_fill_ids = set()
        self.v8_last_commit_attempt: Dict[Tuple[str, str], float] = {}

        # ----- Cache -----
        self.symbol_info_cache: Dict[str, dict] = {}
        self._cache: Dict[str, Tuple[float, Any]] = {}
        self._last_api_call = 0.0
        self._rate_limit_pause_until = 0.0
        self._last_telegram_alert: Dict[str, float] = {}
        self.ws_bridge = None

        # ----- Init -----
        if self.mode != "PAPER":
            if not BINANCE_LIBRARY_AVAILABLE:
                raise RuntimeError("python-binance not installed.")
            self.client = Client(self.api_key, self.api_secret, testnet=(self.mode=="TESTNET"))
        else:
            self.client = None

        init_database(self.db_path)
        self.load_symbol_info()
        self.load_persisted_smart_orders()
        self.risk = RiskEngine(self)
        self.startup_checks()
        self.reconcile(force=True, reason="STARTUP")
        if self.websocket_enabled and self.mode != "PAPER":
            self.ws_bridge = BinanceWebSocketBridge(self)
            self.ws_bridge.start()
        self.recover_exchange_smart_orders()
        self.cancel_legacy_smart_boundary_orders()
        self.send_startup()

    @staticmethod
    def _parse_iso_timestamp(value, default=0.0) -> float:
        try:
            if not value:
                return default
            return datetime.fromisoformat(str(value)).timestamp()
        except Exception:
            return default

    def load_persisted_smart_orders(self):
        """Recover locally persisted SDBE plans after restart. Disabled in V8.8 direct-market mode."""
        if getattr(self, 'v88_disable_limit_entries', True):
            return
        try:
            with db(self.db_path) as conn:
                rows=conn.execute("""SELECT symbol,side,strategy,order_id,status,limit_price,zone_low,zone_high,
                    range_high,range_low,target_price,stop_price,notional,expected_net,required_net,quality,
                    created_at,updated_at,candle_time,reason,residence_started_at,last_revalidated_at,soft_fail_started_at,
                    revalidation_failures,original_quality,original_rr,original_expected_net,last_cancel_reason,
                    v8_state,trigger_price,armed_at,commit_at,invalidation_cooldown_until,thesis_id
                    FROM smart_orders WHERE status IN ('NEW','ARMED','PARTIALLY_FILLED')""").fetchall()
            for row in rows:
                (symbol,side,strategy,order_id,status,limit_price,zone_low,zone_high,range_high,range_low,
                 target,stop,notional,expected_net,required_net,quality,created_at,updated_at,candle_time,reason,
                 residence_started_at,last_revalidated_at,soft_fail_started_at,revalidation_failures,original_quality,original_rr,original_expected_net,last_cancel_reason,
                 v8_state,trigger_price,armed_at,commit_at,invalidation_cooldown_until,thesis_id)=row
                try: created_ts=datetime.fromisoformat(created_at).timestamp()
                except Exception: created_ts=time.time()
                plan={'symbol':symbol,'side':side,'strategy':strategy,'order_id':order_id,'status':status,
                      'limit_price':safe_float(limit_price),'zone_low':safe_float(zone_low),'zone_high':safe_float(zone_high),
                      'range_high':safe_float(range_high),'range_low':safe_float(range_low),'target':safe_float(target),
                      'stop':safe_float(stop),'notional':safe_float(notional),'expected_net':safe_float(expected_net),
                      'required_net':safe_float(required_net),'quality':safe_float(quality),'created_at':created_ts,
                      'updated_at':time.time(),'candle_time':int(candle_time or 0),'reason':reason or '',
                      'residence_started_at': self._parse_iso_timestamp(residence_started_at, created_ts),
                      'last_revalidated_at': self._parse_iso_timestamp(last_revalidated_at, 0),
                      'soft_fail_started_at': self._parse_iso_timestamp(soft_fail_started_at, 0),
                      'revalidation_failures': int(revalidation_failures or 0),
                      'original_quality': safe_float(original_quality, safe_float(quality)),
                      'original_rr': safe_float(original_rr, 0),
                      'original_expected_net': safe_float(original_expected_net, safe_float(expected_net)),
                      'last_cancel_reason': last_cancel_reason or '',
                      'v8_state': v8_state or ('EXECUTED_PENDING_FILL' if status in ('NEW','PARTIALLY_FILLED') else 'WATCH'),
                      'trigger_price': safe_float(trigger_price, safe_float(limit_price)),
                      'armed_at': self._parse_iso_timestamp(armed_at, created_ts) if armed_at else 0.0,
                      'commit_at': self._parse_iso_timestamp(commit_at, 0) if commit_at else 0.0,
                      'invalidation_cooldown_until': self._parse_iso_timestamp(invalidation_cooldown_until, 0) if invalidation_cooldown_until else 0.0,
                      'thesis_id': thesis_id or ''}
                key=self._smart_plan_key(symbol,side)
                if status in ('NEW','PARTIALLY_FILLED') and order_id:
                    self.smart_orders[key]=plan
                else:
                    self.smart_plans[key]=plan
            if rows: logger.info('Recovered %s persisted V8 smart boundary plans/orders.',len(rows))
        except Exception as exc:
            logger.warning('Smart order recovery failed: %s',exc)

    def total_daily_trade_count(self) -> int:
        cutoff = start_of_day_utc().isoformat()
        with db(self.db_path) as conn:
            row = conn.execute(
                "SELECT COUNT(*) FROM trades WHERE entry_time>=?",
                (cutoff,)
            ).fetchone()
        return int(row[0] or 0)

    # -------------------------------------------------------------------------
    # TELEGRAM HELPERS
    # -------------------------------------------------------------------------
    def send_telegram(self, message: str, reply_markup=None) -> Optional[int]:
        if not self.telegram_token or not self.telegram_chat_id:
            logger.info("TELEGRAM | %s", message.replace("\n", " | "))
            return None
        try:
            import requests
            data = {
                "chat_id": self.telegram_chat_id,
                "text": message,
                "parse_mode": "HTML",
                "disable_web_page_preview": True,
            }
            if reply_markup:
                data["reply_markup"] = json.dumps(reply_markup)
            resp = requests.post(
                f"https://api.telegram.org/bot{self.telegram_token}/sendMessage",
                data=data,
                timeout=(5, 8),
            )
            if resp.ok:
                result = resp.json().get("result")
                if result:
                    return result.get("message_id")
            else:
                logger.warning("Telegram send failed: %s", resp.text[:500])
        except Exception as exc:
            logger.warning("Telegram error: %s", exc)
        return None

    def edit_telegram(self, message_id: int, message: str, reply_markup=None) -> bool:
        if not self.telegram_token or not self.telegram_chat_id or not message_id:
            return False
        try:
            import requests
            data = {
                "chat_id": self.telegram_chat_id,
                "message_id": message_id,
                "text": message,
                "parse_mode": "HTML",
                "disable_web_page_preview": True,
            }
            if reply_markup:
                data["reply_markup"] = json.dumps(reply_markup)
            resp = requests.post(
                f"https://api.telegram.org/bot{self.telegram_token}/editMessageText",
                data=data,
                timeout=(5, 8),
            )
            if resp.ok:
                logger.debug("Edit successful.")
                return True
            else:
                if "message is not modified" in resp.text:
                    logger.debug("Edit skipped – content unchanged.")
                    return True
                logger.warning("Edit failed: %s", resp.text[:200])
                return False
        except Exception as exc:
            logger.warning("Edit error: %s", exc)
            return False

    def delete_telegram(self, message_id: int) -> bool:
        if not self.telegram_token or not self.telegram_chat_id or not message_id:
            return False
        try:
            import requests
            resp = requests.post(
                f"https://api.telegram.org/bot{self.telegram_token}/deleteMessage",
                data={"chat_id": self.telegram_chat_id, "message_id": message_id},
                timeout=10,
            )
            return resp.ok
        except Exception:
            return False

    def answer_callback(self, callback_id: str):
        if not self.telegram_token or not callback_id:
            return
        try:
            import requests
            requests.post(
                f"https://api.telegram.org/bot{self.telegram_token}/answerCallbackQuery",
                data={"callback_query_id": callback_id},
                timeout=5
            )
        except Exception:
            pass

    def send_telegram_alert(self, key: str, message: str, force=False) -> None:
        now = time.time()
        if not force and key in self._last_telegram_alert:
            if now - self._last_telegram_alert[key] < self.telegram_alert_cooldown:
                return
        self._last_telegram_alert[key] = now
        self.send_telegram(f"⚠️ {message}")

    # -------------------------------------------------------------------------
    # CACHE / RATE LIMIT
    # -------------------------------------------------------------------------
    def cache_fresh(self, key: str, ttl: float) -> bool:
        if key not in self._cache:
            return False
        return (time.time() - self._cache[key][0]) < ttl

    def cache_set(self, key: str, value: Any) -> None:
        self._cache[key] = (time.time(), value)

    def throttle_api_call(self) -> None:
        now = time.time()
        if self._rate_limit_pause_until > now:
            time.sleep(self._rate_limit_pause_until - now)
        elapsed = time.time() - self._last_api_call
        if elapsed < self.api_call_delay:
            time.sleep(self.api_call_delay - elapsed)
        self._last_api_call = time.time()

    def handle_rate_limit_error(self, exc: Exception) -> None:
        text = str(exc)
        if "1003" in text or "too many" in text.lower() or "rate limit" in text.lower():
            self._rate_limit_pause_until = time.time() + self.rate_limit_cooldown
            logger.error("Rate limit detected. New activity paused for %ss.", self.rate_limit_cooldown)
            self.send_telegram_alert(
                "rate_limit",
                f"🚨 Binance rate limit hit! Bot paused for {self.rate_limit_cooldown}s.\nError: {text[:200]}"
            )

    # -------------------------------------------------------------------------
    # SYMBOL PRECISION
    # -------------------------------------------------------------------------
    def load_symbol_info(self) -> None:
        if self.mode == "PAPER":
            for s in self.symbols:
                self.symbol_info_cache[s] = {"step_size": 0.001, "tick_size": 0.01, "min_qty": 0.001}
            return
        self.throttle_api_call()
        try:
            info = self.client.futures_exchange_info()
        except Exception as e:
            logger.error("Failed to fetch exchange info: %s", e)
            self.send_telegram(f"🚨 Failed to fetch symbol info from Binance: {e}")
            raise RuntimeError(f"Symbol info fetch failed: {e}")
        wanted = set(self.symbols)
        for s in info.get("symbols", []):
            if s.get("symbol") not in wanted:
                continue
            lot = next((f for f in s.get("filters", []) if f.get("filterType") == "LOT_SIZE"), None)
            price = next((f for f in s.get("filters", []) if f.get("filterType") == "PRICE_FILTER"), None)
            self.symbol_info_cache[s["symbol"]] = {
                "step_size": safe_float(lot.get("stepSize")) if lot else 0.001,
                "min_qty": safe_float(lot.get("minQty")) if lot else 0.001,
                "tick_size": safe_float(price.get("tickSize")) if price else 0.01,
            }
        missing = [s for s in self.symbols if s not in self.symbol_info_cache]
        if missing:
            raise RuntimeError(f"Symbols not available on this Binance Futures environment: {missing}")

    def round_quantity(self, symbol: str, quantity: float) -> float:
        info = self.symbol_info_cache[symbol]
        step = info["step_size"]
        if quantity <= 0 or step <= 0:
            return 0.0
        units = math.floor(quantity / step + 1e-12)
        qty = units * step
        if qty < info["min_qty"]:
            return 0.0
        decimals = max(0, int(round(-math.log10(step))) + 2) if step < 1 else 8
        return round(qty, decimals)

    def round_price(self, symbol: str, price: float) -> float:
        tick = self.symbol_info_cache[symbol]["tick_size"]
        if tick <= 0:
            return price
        units = round(price / tick)
        decimals = max(0, int(round(-math.log10(tick))) + 2) if tick < 1 else 8
        return round(units * tick, decimals)

    # -------------------------------------------------------------------------
    # BINANCE API
    # -------------------------------------------------------------------------
    def get_wallet_balance(self, force=False) -> float:
        key = "wallet_balance"
        if not force and self.ws_bridge and self.ws_bridge.user_healthy:
            cached = safe_float(self._cache.get(key, (0, 0))[1])
            if cached > 0:
                return cached
        if not force and self.cache_fresh(key, 30):
            return safe_float(self._cache[key][1])
        if self.mode == "PAPER":
            value = float(os.getenv("PAPER_BALANCE", "10000"))
            self.cache_set(key, value)
            return value
        try:
            self.throttle_api_call()
            account = self.client.futures_account()
            for asset in account.get("assets", []):
                if asset.get("asset") == "USDT":
                    value = safe_float(asset.get("walletBalance"))
                    self.cache_set(key, value)
                    return value
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.warning("Wallet balance unavailable: %s", exc)
        return 0.0

    def get_mark_price(self, symbol: str, force=False) -> float:
        key = f"mark:{symbol}"
        if not force and self.ws_bridge and self.ws_bridge.market_healthy:
            cached = safe_float(self._cache.get(key, (0, 0))[1])
            if cached > 0 and time.time() - self.ws_bridge.last_market_event <= self.websocket_market_stale_seconds:
                return cached
        ttl = min(0.35 if force else self.mark_price_cache_seconds, self.v89_mark_cache_seconds)
        if self.cache_fresh(key, ttl):
            return safe_float(self._cache[key][1])
        if self.mode == "PAPER":
            df = self.get_klines(symbol, "1m", 3)
            value = float(df["close"].iloc[-1]) if df is not None else 0.0
            self.cache_set(key, value)
            return value
        try:
            self.throttle_api_call()
            value = safe_float(self.client.futures_mark_price(symbol=symbol).get("markPrice"))
            if value > 0:
                self.cache_set(key, value)
            return value
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.warning("Mark price unavailable %s: %s", symbol, exc)
            return safe_float(self._cache.get(key, (0, 0))[1])

    def get_klines(self, symbol: str, interval: str, limit=100, force=False):
        key = f"klines:{symbol}:{interval}:{limit}"
        if (not force and interval == "5m" and self.ws_bridge and self.ws_bridge.market_healthy
                and self.cache_fresh(key, 120.0)):
            return self._cache[key][1]
        if not force and self.cache_fresh(key, min(self.kline_cache_seconds, self.v89_kline_cache_seconds)):
            return self._cache[key][1]
        if force and self.cache_fresh(key, 1.0):
            return self._cache[key][1]
        if self.mode == "PAPER":
            return None
        try:
            self.throttle_api_call()
            rows = self.client.futures_klines(symbol=symbol, interval=interval, limit=limit)
            df = pd.DataFrame(rows, columns=[
                "time", "open", "high", "low", "close", "volume", "close_time",
                "qav", "trades", "tb_base", "tb_quote", "ignore"
            ])
            for c in ["open", "high", "low", "close", "volume", "qav", "tb_base", "tb_quote"]:
                df[c] = pd.to_numeric(df[c], errors="coerce").astype("float64")
            for c in ["time", "close_time", "trades", "ignore"]:
                df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0).astype("int64")
            self.cache_set(key, df)
            return df
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.warning("Klines unavailable %s %s: %s", symbol, interval, exc)
            return self._cache.get(key, (0, None))[1]

    def get_open_positions(self, force=False) -> Optional[List[dict]]:
        key = "open_positions"
        if not force and self.ws_bridge and self.ws_bridge.user_healthy:
            return self._cache.get(key, (0, None))[1]
        if not force and self.cache_fresh(key, 10):
            return self._cache[key][1]
        if self.mode == "PAPER":
            self.cache_set(key, [])
            return []
        try:
            self.throttle_api_call()
            raw = self.client.futures_position_information()
            result = []
            for p in raw:
                symbol = str(p.get("symbol", ""))
                if symbol not in self.symbols:
                    continue
                amount = safe_float(p.get("positionAmt"))
                position_side = str(p.get("positionSide", "BOTH")).upper()
                if position_side == "LONG":
                    side = "BUY"
                elif position_side == "SHORT":
                    side = "SELL"
                else:
                    if abs(amount) < 1e-12:
                        continue
                    side = "BUY" if amount > 0 else "SELL"
                if abs(amount) < 1e-12:
                    continue
                result.append({
                    "symbol": symbol,
                    "side": side,
                    "position_side": position_side,
                    "quantity": abs(amount),
                    "entry_price": safe_float(p.get("entryPrice")),
                    "mark_price": safe_float(p.get("markPrice")),
                    "unrealized_pnl": safe_float(p.get("unRealizedProfit")),
                })
            self.cache_set(key, result)
            return result
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.error("POSITION STATE UNKNOWN: %s", exc)
            self.send_telegram_alert(
                "position_unknown",
                f"🚨 Binance position snapshot FAILED.\nState is UNKNOWN – new entries blocked.\nError: {exc}"
            )
            return None

    # -------------------------------------------------------------------------
    # INDICATORS
    # -------------------------------------------------------------------------
    def ema(self, series: pd.Series, period: int) -> float:
        if len(series) < period:
            return 0.0
        return safe_float(series.ewm(span=period, adjust=False).mean().iloc[-1])

    def atr(self, df: pd.DataFrame, period=14) -> float:
        if df is None or len(df) < period + 1:
            return 0.0
        prev = df["close"].shift(1)
        tr = pd.concat([
            df["high"] - df["low"],
            (df["high"] - prev).abs(),
            (df["low"] - prev).abs(),
        ], axis=1).max(axis=1)
        val = tr.rolling(period).mean().iloc[-1]
        return 0.0 if pd.isna(val) else float(val)

    def rsi(self, df: pd.DataFrame, period=14) -> float:
        if df is None or len(df) < period + 2:
            return 50.0
        delta = df["close"].diff()
        gain = delta.clip(lower=0).rolling(period).mean()
        loss = (-delta.clip(upper=0)).rolling(period).mean()
        rs = gain / loss.replace(0, float("nan"))
        out = 100 - (100 / (1 + rs))
        val = out.iloc[-1]
        if pd.isna(val):
            return 100.0 if loss.iloc[-1] == 0 and gain.iloc[-1] > 0 else 50.0
        return float(val)

    def adx(self, df: pd.DataFrame, period=14) -> float:
        if df is None or len(df) < period * 2 + 2:
            return 0.0
        high, low, close = df["high"], df["low"], df["close"]
        up = high.diff()
        down = -low.diff()
        plus_dm = up.where((up > down) & (up > 0), 0.0)
        minus_dm = down.where((down > up) & (down > 0), 0.0)
        prev_close = close.shift(1)
        tr = pd.concat([
            high - low,
            (high - prev_close).abs(),
            (low - prev_close).abs(),
        ], axis=1).max(axis=1)
        atrv = tr.rolling(period).mean()
        plus_di = 100 * plus_dm.rolling(period).mean() / atrv.replace(0, float("nan"))
        minus_di = 100 * minus_dm.rolling(period).mean() / atrv.replace(0, float("nan"))
        dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, float("nan"))
        val = dx.rolling(period).mean().iloc[-1]
        return 0.0 if pd.isna(val) else float(val)

    def market_features(self, symbol: str, interval="5m") -> Optional[dict]:
        df = self.get_klines(symbol, interval, 100)
        if df is None or len(df) < 60:
            return None
        # Use closed candles for indicators
        closed = df.iloc[:-1].copy() if len(df) > 1 else df.copy()
        if len(closed) < 55:
            return None
        price = float(closed["close"].iloc[-1])
        e20 = self.ema(closed["close"], 20)
        e50 = self.ema(closed["close"], 50)
        a = self.atr(closed, self.atr_period)
        rv = self.rsi(closed, self.rsi_period)
        ax = self.adx(closed, self.adx_period)
        avg_vol = float(closed["volume"].rolling(20).mean().iloc[-1])
        vr = float(closed["volume"].iloc[-1]) / avg_vol if avg_vol > 0 else 0.0
        prev = float(closed["close"].iloc[-2])
        momentum = (price - prev) / prev if prev else 0.0
        slope = (e20 - float(closed["close"].ewm(span=20, adjust=False).mean().iloc[-6])) / e20 if e20 else 0.0
        trend = 1 if e20 > e50 and price >= e20 else -1 if e20 < e50 and price <= e20 else 0
        return {
            "price": price,
            "live_price": safe_float(self._cache.get(f"mark:{symbol}", (0, price))[1]),
            "ema20": e20,
            "ema50": e50,
            "atr": a,
            "rsi": rv,
            "adx": ax,
            "volume_ratio": vr,
            "momentum": momentum,
            "ema_slope": slope,
            "trend": trend,
            "candle_time": int(closed["time"].iloc[-1]) if "time" in closed.columns else 0,
            "range_high_20": float(closed["high"].iloc[-20:].max()),
            "range_low_20": float(closed["low"].iloc[-20:].min()),
            "bb_mid": float(closed["close"].rolling(20).mean().iloc[-1]),
            "bb_std": float(closed["close"].rolling(20).std().iloc[-1]),
            "df": closed,
        }

    # -------------------------------------------------------------------------
    # REGIME DETECTION
    # -------------------------------------------------------------------------
    def detect_regime(self, df: pd.DataFrame) -> str:
        """Classify the market into trend, stable range, transition, chop or mixed."""
        if df is None or len(df) < 40:
            return "unknown"
        d = df.iloc[:-1].copy() if len(df) > 1 else df.copy()
        adx_val = self.adx(d, self.adx_period)
        atr_val = self.atr(d, self.atr_period)
        price = safe_float(d["close"].iloc[-1])
        if price <= 0 or atr_val <= 0:
            return "unknown"
        vol_ratio = atr_val / price
        closes = d["close"].astype(float)
        path = float(closes.diff().abs().iloc[-20:].sum())
        displacement = abs(float(closes.iloc[-1]) - float(closes.iloc[-21]))
        efficiency = displacement / max(path, 1e-12)
        high20 = float(d["high"].iloc[-20:].max()); low20 = float(d["low"].iloc[-20:].min())
        width_atr = (high20-low20) / max(atr_val,1e-12)
        band=max(0.18*atr_val,(high20-low20)*0.08)
        high_touches=int((d["high"].iloc[-30:]>=high20-band).sum())
        low_touches=int((d["low"].iloc[-30:]<=low20+band).sum())
        tr=pd.concat([d["high"]-d["low"],(d["high"]-d["close"].shift(1)).abs(),(d["low"]-d["close"].shift(1)).abs()],axis=1).max(axis=1)
        recent=float(tr.rolling(14).mean().iloc[-1]); old=float(tr.rolling(14).mean().iloc[-15]) if len(tr)>=30 else recent
        expansion=recent/max(old,1e-12)
        if adx_val >= self.regime_adx_trend and efficiency >= 0.22:
            base="trending"
        elif efficiency <= self.chop_efficiency_max and adx_val <= self.chop_adx_max and (width_atr < self.range_min_atr_width or (high_touches<2 and low_touches<2)):
            base="chop"
        elif expansion >= self.vol_expansion_ratio or (width_atr > 7.0 and adx_val >= 20):
            base="transition"
        elif adx_val <= self.regime_adx_ranging and self.range_min_atr_width <= width_atr <= self.range_max_atr_width and high_touches>=2 and low_touches>=2:
            base="ranging"
        else:
            base="mixed"
        vol="high_vol" if vol_ratio>0.005 else "low_vol" if vol_ratio<0.002 else "normal_vol"
        return f"{base}_{vol}"

    # -------------------------------------------------------------------------
    # SIGNAL GENERATION (enhanced multi-timeframe)
    # -------------------------------------------------------------------------
    def generate_signal(self, symbol: str) -> Tuple[str, int, dict]:
        """Generate directional bias from closed candles across 1m/5m/15m/1h."""
        f1 = self.market_features(symbol, "1m")
        f5 = self.market_features(symbol, "5m")
        f15 = self.market_features(symbol, "15m")
        f1h = self.market_features(symbol, "1h")
        if not all([f1, f5, f15, f1h]):
            return "HOLD", 0, {"margin": 0, "trigger": False, "reason": "insufficient data"}

        long_score = 0
        short_score = 0
        reasons_long, reasons_short = [], []

        for tf, weight, label in ((f1h, 20, "1h trend"), (f15, 15, "15m trend"), (f5, 10, "5m trend")):
            if tf["trend"] == 1:
                long_score += weight; reasons_long.append(f"{label}=BULL")
            elif tf["trend"] == -1:
                short_score += weight; reasons_short.append(f"{label}=BEAR")

        for tf, weight in ((f15, 8), (f5, 7)):
            if tf["adx"] >= self.entry_adx_min:
                if tf["trend"] == 1: long_score += weight
                elif tf["trend"] == -1: short_score += weight

        for tf, weight in ((f1, 5), (f5, 5)):
            if tf["momentum"] > 0.00025: long_score += weight
            elif tf["momentum"] < -0.00025: short_score += weight

        if 50 <= f5["rsi"] <= 67: long_score += 5
        elif 33 <= f5["rsi"] <= 50: short_score += 5

        if f5["volume_ratio"] >= self.entry_volume_ratio_min:
            if f5["momentum"] > 0: long_score += 8
            elif f5["momentum"] < 0: short_score += 8

        if f5["ema_slope"] > 0.0001: long_score += 5
        elif f5["ema_slope"] < -0.0001: short_score += 5

        atr = f5["atr"]
        dist_atr = abs(f5["price"] - f5["ema20"]) / atr if atr > 0 else 999
        if dist_atr <= self.max_deviation_atr:
            if f5["trend"] == 1: long_score += 5
            elif f5["trend"] == -1: short_score += 5
        else:
            reasons_long.append(f"EMA distance={dist_atr:.2f}ATR")
            reasons_short.append(f"EMA distance={dist_atr:.2f}ATR")

        def candle_strength(tf):
            df = tf["df"]
            row = df.iloc[-1]
            rng = float(row["high"] - row["low"])
            if rng <= 0: return {"bull": False, "bear": False, "range_atr": 0.0}
            bull = row["close"] > row["open"] and (row["close"]-row["low"])/rng >= 0.62
            bear = row["close"] < row["open"] and (row["high"]-row["close"])/rng >= 0.62
            return {"bull": bool(bull), "bear": bool(bear), "range_atr": rng/max(tf["atr"],1e-12)}

        c1, c5 = candle_strength(f1), candle_strength(f5)
        trigger_buy = c1["bull"] and c5["bull"]
        trigger_sell = c1["bear"] and c5["bear"]
        confidence = int(max(long_score, short_score))
        margin = int(abs(long_score-short_score))
        signal = "HOLD"
        if confidence >= self.entry_min_confidence and margin >= self.entry_margin:
            if long_score > short_score: signal = "BUY"
            elif short_score > long_score: signal = "SELL"

        features = {
            "1m": f1, "5m": f5, "15m": f15, "1h": f1h,
            "long_score": int(min(100, long_score)),
            "short_score": int(min(100, short_score)),
            "margin": margin,
            "trigger": trigger_buy if signal == "BUY" else trigger_sell if signal == "SELL" else False,
            "trigger_buy": trigger_buy,
            "trigger_sell": trigger_sell,
            "dist_atr": dist_atr,
            "candle_1m": c1,
            "candle_5m": c5,
            "reason": "; ".join(reasons_long if signal == "BUY" else reasons_short if signal == "SELL" else []) or "No qualified directional setup"
        }
        save_feature(symbol, "5m", f5, self.db_path, signal, confidence)
        return signal, confidence, features

    # -------------------------------------------------------------------------
    # RANGING SIGNAL
    # -------------------------------------------------------------------------
    def generate_ranging_signal(self, symbol: str) -> Tuple[str, int, dict]:
        df = self.get_klines(symbol, "5m", 80)
        if df is None or len(df) < 40:
            return "HOLD", 0, {}
        d = df.iloc[:-1].copy()
        price = float(d["close"].iloc[-1])
        atr = self.atr(d, self.atr_period)
        rsi_now = self.rsi(d, self.rsi_period)
        rsi_prev = self.rsi(d.iloc[:-1], self.rsi_period)
        ma = d["close"].rolling(self.bollinger_period).mean().iloc[-1]
        std = d["close"].rolling(self.bollinger_period).std().iloc[-1]
        upper, lower = ma + self.bollinger_std*std, ma - self.bollinger_std*std
        high20, low20 = d["high"].iloc[-20:].max(), d["low"].iloc[-20:].min()
        span = max(high20-low20, 1e-12)
        pos = (price-low20)/span
        row, prev = d.iloc[-1], d.iloc[-2]
        rng = max(float(row["high"]-row["low"]), 1e-12)
        bull_reject = row["close"] > row["open"] and (row["close"]-row["low"])/rng >= 0.65
        bear_reject = row["close"] < row["open"] and (row["high"]-row["close"])/rng >= 0.65
        volume_ratio = float(row["volume"])/max(float(d["volume"].iloc[-21:-1].mean()),1e-12)
        long_score = short_score = 0
        lr, sr = [], []
        if pos <= self.range_boundary_pct: long_score += 30; lr.append("range-low")
        if pos >= 1-self.range_boundary_pct: short_score += 30; sr.append("range-high")
        if price <= lower: long_score += 20; lr.append("lower-Bollinger")
        if price >= upper: short_score += 20; sr.append("upper-Bollinger")
        if rsi_now <= self.rsi_oversold and rsi_now > rsi_prev: long_score += 25; lr.append("RSI recovering")
        if rsi_now >= self.rsi_overbought and rsi_now < rsi_prev: short_score += 25; sr.append("RSI cooling")
        if bull_reject: long_score += 20; lr.append("bullish rejection")
        if bear_reject: short_score += 20; sr.append("bearish rejection")
        if volume_ratio >= 0.75: long_score += 5; short_score += 5
        # Require room to midpoint
        mid = (high20+low20)/2
        long_room = (mid-price)/max(atr,1e-12)
        short_room = (price-mid)/max(atr,1e-12)
        if long_room < 0.5: long_score -= 20
        if short_room < 0.5: short_score -= 20
        signal="HOLD"; confidence=max(long_score,short_score)
        if long_score >= 70 and long_score > short_score: signal="BUY"
        elif short_score >= 70 and short_score > long_score: signal="SELL"
        features={
            "price":price, "rsi":rsi_now, "rsi_prev":rsi_prev, "ma":ma,
            "upper":upper, "lower":lower, "range_high":high20, "range_low":low20,
            "range_pos":pos, "atr":atr, "volume_ratio":volume_ratio,
            "bull_rejection":bull_reject, "bear_rejection":bear_reject,
            "long_score":long_score, "short_score":short_score,
            "reason":"; ".join(lr if signal=="BUY" else sr if signal=="SELL" else lr+sr) or "range not at actionable boundary"
        }
        return signal, int(max(0,min(100,confidence))), features

    # -------------------------------------------------------------------------
    # TRANSITION SIGNAL
    # -------------------------------------------------------------------------
    def generate_transition_signal(self, symbol: str) -> Tuple[str, int, dict]:
        df = self.get_klines(symbol, "5m", 80)
        if df is None or len(df) < 50:
            return "HOLD", 0, {}
        d = df.iloc[:-1].copy()
        row = d.iloc[-1]
        look = d.iloc[-21:-1]
        high20, low20 = float(look["high"].max()), float(look["low"].min())
        atr = self.atr(d, self.atr_period)
        vol_avg = float(look["volume"].mean())
        vr = float(row["volume"])/max(vol_avg,1e-12)
        close = float(row["close"])
        mom = (close-float(d["close"].iloc[-6]))/max(float(d["close"].iloc[-6]),1e-12)
        close_buffer = self.breakout_close_buffer_atr*atr
        buy_break = close > high20 + close_buffer
        sell_break = close < low20 - close_buffer
        rng = max(float(row["high"]-row["low"]),1e-12)
        bull_close = row["close"] > row["open"] and (row["close"]-row["low"])/rng >= 0.65
        bear_close = row["close"] < row["open"] and (row["high"]-row["close"])/rng >= 0.65
        long_score = (45 if buy_break else 0) + (25 if vr>=self.breakout_volume_ratio_min else 0) + (15 if mom>0.001 else 0) + (15 if bull_close else 0)
        short_score = (45 if sell_break else 0) + (25 if vr>=self.breakout_volume_ratio_min else 0) + (15 if mom<-0.001 else 0) + (15 if bear_close else 0)
        signal="BUY" if long_score>=80 and long_score>short_score else "SELL" if short_score>=80 and short_score>long_score else "HOLD"
        conf=max(long_score,short_score)
        features={
            "breakout_high":high20, "breakout_low":low20, "momentum":mom,
            "volume_ratio":vr, "atr":atr, "long_score":long_score,
            "short_score":short_score, "buy_break":buy_break, "sell_break":sell_break,
            "trigger": bull_close if signal=="BUY" else bear_close if signal=="SELL" else False,
            "reason":"confirmed breakout" if signal!="HOLD" else "breakout not confirmed"
        }
        return signal, int(min(100,conf)), features

    # -------------------------------------------------------------------------
    # ENTRY QUALITY SCORING
    # -------------------------------------------------------------------------
    # -------------------------------------------------------------------------
    # V6 STRATEGY INTELLIGENCE
    # -------------------------------------------------------------------------
    def _candle_rejection(self, df: pd.DataFrame) -> Tuple[bool, bool, float]:
        if df is None or len(df)<2: return False,False,0.0
        row=df.iloc[-1]; rng=max(float(row["high"]-row["low"]),1e-12)
        return bool(row["close"]>row["open"] and (row["close"]-row["low"])/rng>=0.62), bool(row["close"]<row["open"] and (row["high"]-row["close"])/rng>=0.62), rng

    def generate_micro_signal(self, symbol: str) -> Tuple[str,int,dict]:
        """Short-horizon mean-reversion. Fades only an overshoot with exhaustion and rejection."""
        if not self.micro_enabled: return "HOLD",0,{"reason":"micro engine disabled"}
        f1=self.market_features(symbol,"1m"); f5=self.market_features(symbol,"5m")
        if not f1 or not f5 or f1["atr"]<=0: return "HOLD",0,{"reason":"micro data unavailable"}
        d=f1["df"]; price=f1["price"]; atr=f1["atr"]; dist=(price-f1["ema20"])/max(atr,1e-12)
        rsi=f1["rsi"]; prev_rsi=self.rsi(d.iloc[:-1],self.rsi_period); vr=f1["volume_ratio"]
        bull,bear,_=self._candle_rejection(d); hi=float(d["high"].iloc[-16:-1].max()); lo=float(d["low"].iloc[-16:-1].min())
        ls=ss=0; lr=[]; sr=[]
        if price < lo-0.15*atr: ls+=28; lr.append("1m downside overshoot")
        if price > hi+0.15*atr: ss+=28; sr.append("1m upside overshoot")
        if dist<=-0.75: ls+=20; lr.append("below EMA by >=0.75 ATR")
        if dist>=0.75: ss+=20; sr.append("above EMA by >=0.75 ATR")
        if rsi<=30 and rsi>prev_rsi: ls+=20; lr.append("RSI recovery from oversold")
        if rsi>=70 and rsi<prev_rsi: ss+=20; sr.append("RSI cooling from overbought")
        if bull: ls+=18; lr.append("bullish rejection")
        if bear: ss+=18; sr.append("bearish rejection")
        if vr>=0.8: ls+=5; ss+=5
        if f5["trend"]==-1 and ls<85: ls-=8
        if f5["trend"]==1 and ss<85: ss-=8
        candle_atr=float(d["high"].iloc[-1]-d["low"].iloc[-1])/max(atr,1e-12)
        if candle_atr>1.8: ls-=12; ss-=12
        ls=max(0,ls); ss=max(0,ss)
        signal="BUY" if ls>=self.micro_score_min and ls>ss+8 else "SELL" if ss>=self.micro_score_min and ss>ls+8 else "HOLD"
        if signal=="BUY":
            target=min(f1["ema20"],price+self.micro_target_atr*atr); stop=min(lo-0.1*atr,price-self.micro_stop_atr*atr); reward=max(target-price,0); risk=max(price-stop,0.5*atr)
        elif signal=="SELL":
            target=max(f1["ema20"],price-self.micro_target_atr*atr); stop=max(hi+0.1*atr,price+self.micro_stop_atr*atr); reward=max(price-target,0); risk=max(stop-price,0.5*atr)
        else: target=stop=reward=risk=0.0
        rr=reward/max(risk,1e-12); notional=self.fixed_notional*self.micro_notional_multiplier
        expected=(reward/max(price,1e-12))*notional-(2*self.estimated_commission_rate*notional*self.fee_slippage_buffer)
        reason="; ".join(lr if signal=="BUY" else sr if signal=="SELL" else lr+sr) or "no short-horizon reversal edge"
        return signal,int(min(100,max(ls,ss))),{"long_score":ls,"short_score":ss,"rr":rr,"expected_net":expected,"target":target,"stop":stop,"candle_time":f1.get("candle_time",0),"reason":reason,"overshoot":abs(dist),"volatility_candle_atr":candle_atr}

    def _range_quality(self,symbol: str)->dict:
        df=self.get_klines(symbol,"5m",100)
        if df is None or len(df)<50: return {"quality":0,"reason":"range data unavailable"}
        d=df.iloc[:-1].copy(); atr=self.atr(d,self.atr_period)
        if atr<=0: return {"quality":0,"reason":"ATR unavailable"}
        hi=float(d["high"].iloc[-20:].max()); lo=float(d["low"].iloc[-20:].min()); width=hi-lo; wa=width/max(atr,1e-12)
        closes=d["close"].astype(float); path=float(closes.diff().abs().iloc[-20:].sum()); eff=abs(float(closes.iloc[-1])-float(closes.iloc[-21]))/max(path,1e-12)
        band=max(0.18*atr,width*0.08); ht=int((d["high"].iloc[-30:]>=hi-band).sum()); lt=int((d["low"].iloc[-30:]<=lo+band).sum())
        width_score=100 if self.range_min_atr_width<=wa<=self.range_max_atr_width else 45; touch_score=min(100,(ht+lt)*12); eff_score=100 if eff<=0.18 else 75 if eff<=0.28 else 35
        q=int(round(.4*width_score+.35*touch_score+.25*eff_score))
        return {"quality":q,"high":hi,"low":lo,"width_atr":wa,"efficiency":eff,"high_touches":ht,"low_touches":lt,"atr":atr}

    def cross_symbol_context(self,symbol: str)->dict:
        if symbol=="BTCUSDT" or not self.extreme_btc_guard: return {"penalty":0,"reason":""}
        f=self.market_features("BTCUSDT","5m")
        if not f: return {"penalty":0,"reason":"BTC context unavailable"}
        if f["trend"]==-1 and f["momentum"]<=-self.extreme_btc_momentum: return {"penalty":self.cross_symbol_penalty,"reason":"BTC bearish impulse"}
        if f["trend"]==1 and f["momentum"]>=self.extreme_btc_momentum: return {"penalty":self.cross_symbol_penalty,"reason":"BTC bullish impulse"}
        return {"penalty":0,"reason":""}

    # -------------------------------------------------------------------------
    # V7 SMART DYNAMIC BOUNDARY ENTRY (SDBE) — STABILITY / GRACE / REVALIDATION
    # -------------------------------------------------------------------------
    def required_net_profit(self, notional: float) -> float:
        """Return the transparent minimum expected net profit for a new setup.
        The configured floor is absolute; the cost multiple prevents tiny gross edges
        from being accepted merely because they are above the floor.
        """
        round_trip_cost = 2.0 * self.estimated_commission_rate * max(notional, 0.0) * self.fee_slippage_buffer
        return max(self.smart_boundary_min_net_profit, round_trip_cost * self.smart_boundary_cost_multiple)

    def _distinct_boundary_touches(self, symbol: str, side: str, level: float, band: float) -> int:
        """Count separated boundary tests instead of counting every candle inside the zone."""
        df=self.get_klines(symbol,'5m',60)
        if df is None or len(df)<25: return 0
        d=df.iloc[:-1].copy(); hits=[]; last_idx=-99
        for i in range(max(0,len(d)-30),len(d)):
            value=float(d['high'].iloc[i] if side=='SELL' else d['low'].iloc[i])
            hit=(value>=level-band) if side=='SELL' else (value<=level+band)
            if hit and i-last_idx>=2:
                hits.append(i); last_idx=i
        return len(hits)

    def _range_boundary_plan(self, symbol: str, side: str) -> Optional[dict]:
        """Build a conditional limit-entry plan from the current validated range.
        No order is placed here. The plan is revalidated again immediately before
        an exchange order is created.
        """
        if not self.smart_boundary_enabled or not self.ranging_enabled:
            return None
        rq = self._range_quality(symbol)
        if rq.get('quality', 0) < self.smart_boundary_range_quality_min:
            return None
        rs, rc, rf = self.generate_ranging_signal(symbol)
        if side == 'BUY' and rs not in ('BUY', 'HOLD'):
            return None
        if side == 'SELL' and rs not in ('SELL', 'HOLD'):
            return None
        hi, lo, atr = safe_float(rq.get('high')), safe_float(rq.get('low')), safe_float(rq.get('atr'))
        if hi <= lo or atr <= 0:
            return None
        price = safe_float(self.get_mark_price(symbol))
        if price <= 0:
            price = safe_float(rf.get('price'))
        if price <= 0:
            return None
        width = hi - lo
        zone_depth = max(self.smart_boundary_zone_atr * atr, width * 0.035)
        touch_band=max(zone_depth,0.20*atr)
        smart_high_touches=self._distinct_boundary_touches(symbol,'SELL',hi,touch_band)
        smart_low_touches=self._distinct_boundary_touches(symbol,'BUY',lo,touch_band)
        if side == 'SELL':
            zone_low = hi - zone_depth
            zone_high = hi + max(0.04 * atr, self.smart_boundary_breakout_buffer_atr * 0.35 * atr)
            limit_price = hi - self.smart_boundary_inner_offset_atr * atr
            activation_distance = self.smart_boundary_activation_atr * atr
            activation = price >= zone_low - activation_distance
            target = (hi + lo) / 2.0
            stop = hi + self.smart_boundary_breakout_buffer_atr * atr
            boundary_reason = 'upper resistance reaction zone'
        else:
            zone_low = lo - max(0.04 * atr, self.smart_boundary_breakout_buffer_atr * 0.35 * atr)
            zone_high = lo + zone_depth
            limit_price = lo + self.smart_boundary_inner_offset_atr * atr
            activation_distance = self.smart_boundary_activation_atr * atr
            activation = price <= zone_high + activation_distance
            target = (hi + lo) / 2.0
            stop = lo - self.smart_boundary_breakout_buffer_atr * atr
            boundary_reason = 'lower support reaction zone'
        limit_price = self.round_price(symbol, limit_price)
        target = self.round_price(symbol, target)
        stop = self.round_price(symbol, stop)
        reward = (limit_price - target) if side == 'SELL' else (target - limit_price)
        risk = (stop - limit_price) if side == 'SELL' else (limit_price - stop)
        rr = reward / max(risk, 1e-12)
        notional = self.fixed_notional * self.smart_boundary_notional_multiplier
        expected_net = (max(reward, 0.0) / max(limit_price, 1e-12)) * notional - (2.0 * self.estimated_commission_rate * notional * self.fee_slippage_buffer)
        required_net = self.required_net_profit(notional)
        volume_ratio = safe_float(rf.get('volume_ratio'), 0.0)
        range_pos = safe_float(rf.get('range_pos'), 0.5)
        boundary_score = 0
        reasons = [boundary_reason]
        if side == 'SELL' and range_pos >= 0.70:
            boundary_score += 18; reasons.append('price approaching range high')
        elif side == 'BUY' and range_pos <= 0.30:
            boundary_score += 18; reasons.append('price approaching range low')
        touches=smart_high_touches if side=='SELL' else smart_low_touches
        if touches >= 2:
            boundary_score += min(25, 8 + touches*4); reasons.append(f"{touches} separated boundary tests")
        boundary_score += min(25, int(rq.get('quality', 0) * 0.25))
        if side == 'SELL' and rf.get('bear_rejection'): boundary_score += 15; reasons.append('bearish rejection present')
        if side == 'BUY' and rf.get('bull_rejection'): boundary_score += 15; reasons.append('bullish rejection present')
        if volume_ratio >= 0.70: boundary_score += 6
        if rq.get('efficiency', 1.0) <= 0.28: boundary_score += 8; reasons.append('low directional efficiency')
        boundary_score = max(0, min(100, int(boundary_score)))
        blockers=[]
        if not activation: blockers.append('price not yet near activation zone')
        if touches > self.smart_boundary_max_tests: blockers.append(f'boundary tested {touches} times — breakout risk elevated')
        if rr < self.smart_boundary_min_rr: blockers.append(f'R:R {rr:.2f} below minimum {self.smart_boundary_min_rr:.2f}')
        if expected_net < required_net: blockers.append(f'expected net ${expected_net:.2f} below required ${required_net:.2f}')
        if self.smart_boundary_cancel_on_volume_expansion and volume_ratio >= self.breakout_volume_ratio_min:
            blockers.append(f'volume expansion {volume_ratio:.2f}x warns of breakout')
        if side == 'SELL' and price > stop: blockers.append('range high already broken')
        if side == 'BUY' and price < stop: blockers.append('range low already broken')
        # V8.2 boundary trigger: activate outside the analysed range instead of
        # waiting for a reversal/reclaim back inside the reaction zone.
        trigger_offset = self.v8_trigger_offset_atr * atr if getattr(self, 'v8_enabled', True) else 0.0
        if side == 'BUY':
            trigger_price = lo - trigger_offset
        else:
            trigger_price = hi + trigger_offset
        trigger_price = self.round_price(symbol, trigger_price)

        return {
            'symbol':symbol,'side':side,'strategy':'smart_range','regime':'range',
            'quality':boundary_score,'range_quality':int(rq.get('quality',0)),
            'range_high':hi,'range_low':lo,'zone_low':zone_low,'zone_high':zone_high,'atr':atr,
            'boundary_tests':touches,
            'limit_price':limit_price,'trigger_price':trigger_price,'target':target,'stop':stop,'rr':rr,
            'expected_net':expected_net,'required_net':required_net,'notional':notional,
            'activation':activation,'candle_time':int(rf.get('candle_time',0) or 0),
            'reason':'; '.join(reasons),'blockers':blockers,
            'created_at':time.time(),'updated_at':time.time()
        }

    def _smart_plan_key(self, symbol: str, side: str) -> Tuple[str,str,str]:
        return (symbol, side, 'smart_range')

    def _save_smart_order_db(self, plan: dict, status: str, order_id=None):
        try:
            with db(self.db_path) as conn:
                # The INSERT is intentionally explicit so V6/V7 databases can be migrated
                # without losing the original smart-order state.
                conn.execute("""
                    INSERT OR REPLACE INTO smart_orders
                    (symbol,side,strategy,order_id,status,limit_price,zone_low,zone_high,range_high,range_low,
                     target_price,stop_price,notional,expected_net,required_net,quality,created_at,updated_at,candle_time,reason,
                     residence_started_at,last_revalidated_at,soft_fail_started_at,revalidation_failures,original_quality,original_rr,original_expected_net,last_cancel_reason,
                     v8_state,trigger_price,armed_at,commit_at,invalidation_cooldown_until,thesis_id)
                    VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
                """, (
                    plan['symbol'], plan['side'], plan.get('strategy','smart_range'), order_id, status,
                    plan['limit_price'], plan.get('zone_low'), plan.get('zone_high'), plan.get('range_high'), plan.get('range_low'),
                    plan.get('target'), plan.get('stop'), plan['notional'], plan.get('expected_net',0), plan.get('required_net',0),
                    plan.get('quality',0), datetime.fromtimestamp(plan.get('created_at',time.time()),tz=timezone.utc).isoformat(),
                    iso_now(), plan.get('candle_time',0), plan.get('reason',''),
                    datetime.fromtimestamp(plan.get('residence_started_at',plan.get('created_at',time.time())),tz=timezone.utc).isoformat()
                        if plan.get('residence_started_at') else None,
                    datetime.fromtimestamp(plan['last_revalidated_at'],tz=timezone.utc).isoformat()
                        if plan.get('last_revalidated_at') else None,
                    datetime.fromtimestamp(plan['soft_fail_started_at'],tz=timezone.utc).isoformat()
                        if plan.get('soft_fail_started_at') else None,
                    int(plan.get('revalidation_failures',0)),
                    plan.get('original_quality',plan.get('quality',0)),
                    plan.get('original_rr',plan.get('rr',0)),
                    plan.get('original_expected_net',plan.get('expected_net',0)),
                    plan.get('last_cancel_reason',''),
                    plan.get('v8_state','WATCH'),
                    plan.get('trigger_price',0),
                    datetime.fromtimestamp(plan['armed_at'],tz=timezone.utc).isoformat() if plan.get('armed_at') else None,
                    datetime.fromtimestamp(plan['commit_at'],tz=timezone.utc).isoformat() if plan.get('commit_at') else None,
                    datetime.fromtimestamp(plan['invalidation_cooldown_until'],tz=timezone.utc).isoformat() if plan.get('invalidation_cooldown_until') else None,
                    plan.get('thesis_id','')
                ))
        except Exception as exc:
            logger.warning('Smart order DB save failed: %s', exc)

    def _delete_smart_order_db(self, key):
        with db(self.db_path) as conn:
            conn.execute('DELETE FROM smart_orders WHERE symbol=? AND side=? AND strategy=?', key)

    def get_open_orders(self, symbol: Optional[str]=None) -> Optional[List[dict]]:
        if self.mode == 'PAPER': return []
        key=f'open_orders:{symbol or "ALL"}'
        if self.cache_fresh(key, 10.0): return self._cache[key][1]
        try:
            self.throttle_api_call()
            raw = self.client.futures_get_open_orders(symbol=symbol) if symbol else self.client.futures_get_open_orders()
            self.cache_set(key, raw or [])
            return raw or []
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.warning('Open orders unavailable: %s', exc)
            return None

    def get_order_status(self, symbol: str, order_id: str) -> Optional[dict]:
        if self.mode == 'PAPER': return {'status':'NEW','orderId':order_id}
        if self.ws_bridge and self.ws_bridge.user_healthy:
            cached = self._cache.get(f"order:{symbol}:{order_id}", (0, None))[1]
            if cached is not None:
                return cached
        try:
            self.throttle_api_call()
            return self.client.futures_get_order(symbol=symbol, orderId=int(order_id))
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.warning('Smart order status unavailable %s/%s: %s',symbol,order_id,exc)
            return None

    def cancel_order(self, symbol: str, order_id: str, reason='SMART ORDER INVALIDATED') -> bool:
        if self.mode == 'PAPER': return True
        try:
            self.throttle_api_call()
            self.client.futures_cancel_order(symbol=symbol, orderId=int(order_id))
            logger.info('SMART ORDER CANCELLED %s %s: %s',symbol,order_id,reason)
            return True
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.warning('Smart order cancellation failed %s/%s: %s',symbol,order_id,exc)
            return False

    def place_smart_limit_order(self, plan: dict) -> bool:
        """Deprecated in V8.8. Automated entry is MARKET-only.

        Smart Boundary calculations remain available for analysis/history, but this
        execution method is intentionally disabled so no automated LIMIT order can
        be created by this engine.
        """
        logger.warning("V8.8: LIMIT execution disabled; MARKET entry path only (%s %s).",
                       plan.get("symbol"), plan.get("side"))
        return False

    def _cancel_smart_plan(self, key, reason: str, notify=True):
        plan=self.smart_orders.get(key) or self.smart_plans.get(key)
        if not plan: return
        order_id=plan.get('order_id')
        if order_id and plan.get('status','NEW') in ('NEW','PARTIALLY_FILLED'):
            self.cancel_order(plan['symbol'],order_id,reason)
        plan['status']='CANCELLED'; plan['reason']=reason; plan['last_cancel_reason']=reason; plan['updated_at']=time.time()
        self._save_smart_order_db(plan,'CANCELLED',order_id)
        self.smart_orders.pop(key,None); self.smart_plans.pop(key,None)
        self.last_smart_order_action[key]=time.time()
        if notify: self.send_telegram(f'🧹 <b>SMART LIMIT CANCELLED</b>\n{plan["symbol"]} {"LONG" if plan["side"]=="BUY" else "SHORT"}\nReason: {reason}')

    def _simulate_smart_fill(self, plan: dict):
        """Paper-mode fill simulator for a resting boundary limit order."""
        key=self._smart_plan_key(plan['symbol'],plan['side'])
        if self.mode!='PAPER': return
        if plan.get('status')=='FILLED': return
        price=plan['limit_price']; qty=self.round_quantity(plan['symbol'], plan['notional']/max(price,1e-12))
        if qty<=0: return
        p=Position(symbol=plan['symbol'],side=plan['side'],entry_time=iso_now(),entry_price=price,
                   quantity=qty,notional=qty*price,entry_order_id=plan.get('order_id'),highest_price=price,
                   lowest_price=price,current_price=price,recovered=False,regime='smart_range')
        p.entry_quality_score=int(plan.get('quality',0)); p.last_direction_confidence=int(plan.get('quality',0))
        p._strategy='smart_range'; p._temp_loss_budget=self.smart_boundary_loss_budget
        self.positions.setdefault(plan['symbol'],{})[plan['side']]=p
        save_open_position(p,self.db_path)
        self._adopt_filled_smart_order(key,plan)

    def _smart_fill_already_processed(self, order_id: Optional[str]) -> bool:
        if not order_id or not self.v8_fill_dedupe_enabled:
            return False
        oid=str(order_id)
        if oid in self.v8_processed_fill_ids:
            return True
        try:
            with db(self.db_path) as conn:
                row=conn.execute("SELECT 1 FROM smart_fill_events WHERE order_id=?", (oid,)).fetchone()
            if row:
                self.v8_processed_fill_ids.add(oid)
                return True
        except Exception as exc:
            logger.warning('Smart fill dedupe lookup failed: %s', exc)
        return False

    def _mark_smart_fill_processed(self, plan: dict) -> bool:
        order_id=str(plan.get('order_id') or '')
        if not order_id or not self.v8_fill_dedupe_enabled:
            return True
        try:
            with db(self.db_path) as conn:
                cur=conn.execute(
                    "INSERT OR IGNORE INTO smart_fill_events(order_id,symbol,side,processed_at) VALUES (?,?,?,?)",
                    (order_id, plan['symbol'], plan['side'], iso_now())
                )
                inserted = cur.rowcount == 1
            if inserted:
                self.v8_processed_fill_ids.add(order_id)
                if len(self.v8_processed_fill_ids) > self.v8_fill_event_memory:
                    self.v8_processed_fill_ids = set(list(self.v8_processed_fill_ids)[-self.v8_fill_event_memory:])
            return inserted
        except Exception as exc:
            logger.warning('Smart fill dedupe write failed: %s', exc)
            return True

    def _adopt_filled_smart_order(self, key, plan):
        symbol,side,_=key
        order_id=str(plan.get('order_id') or '')
        # Critical V8 idempotency rule: one Binance order ID can create only one
        # local fill/adoption event. Recovery/reconciliation may see the same fill
        # more than once.
        if self._smart_fill_already_processed(order_id):
            self.smart_orders.pop(key,None); self.smart_plans.pop(key,None)
            return True
        p=self.positions.get(symbol,{}).get(side)
        if p:
            p.entry_order_id=plan.get('order_id') or p.entry_order_id
            p.regime='smart_range'
            p.entry_quality_score=int(plan.get('quality',0))
            p.last_direction_confidence=int(plan.get('direction_probability',plan.get('quality',0)))
            p._strategy='smart_range'
            p._temp_loss_budget=self.smart_boundary_loss_budget
            save_open_position(p,self.db_path)
            inserted=self._mark_smart_fill_processed(plan)
            self.smart_orders.pop(key,None); self.smart_plans.pop(key,None)
            self.last_global_trade_time=time.time()
            self._save_smart_order_db(plan,'FILLED',plan.get('order_id'))
            if inserted:
                self.send_telegram(f'🎯 <b>SMART ENTRY CONFIRMED</b>\n{symbol} {p.position_side}\nEntry: ${p.entry_price:,.6f}\nQuanT entry commitment confirmed. QuanT entry commitment confirmed; V7 position management is now active.')
            return True
        return False

    def recover_exchange_smart_orders(self):
        """Recover open SDBE LIMIT orders from Binance after restart. Binance is authoritative."""
        if self.mode == 'PAPER' or not self.state_known or getattr(self, 'v88_disable_limit_entries', True): return
        if time.time()-self.last_smart_exchange_recovery < self.smart_exchange_recovery_seconds: return
        self.last_smart_exchange_recovery=time.time()
        for symbol in self.symbols:
            orders=self.get_open_orders(symbol)
            if orders is None: continue
            for o in orders:
                cid=str(o.get('clientOrderId',''))
                if not cid.startswith('SDBE_'): continue
                side=str(o.get('side','')).upper()
                if side not in ('BUY','SELL'): continue
                key=self._smart_plan_key(symbol,side)
                if key in self.smart_orders: continue
                price=safe_float(o.get('price'))
                qty=safe_float(o.get('origQty'))
                if price<=0 or qty<=0: continue
                # Rebuild a conservative local record. The next management cycle performs
                # the full range revalidation before leaving the order active.
                plan=self._range_boundary_plan(symbol,side)
                if not plan: continue
                plan.update({'order_id':str(o.get('orderId')),'status':'NEW','quantity':qty,'limit_price':price})
                self.smart_orders[key]=plan; self.smart_plans[key]=plan
                self._save_smart_order_db(plan,'NEW',str(o.get('orderId')))
                logger.info('Recovered exchange SDBE order %s %s order=%s',symbol,side,o.get('orderId'))

    def _smart_order_age(self, plan: dict) -> float:
        started = safe_float(plan.get('residence_started_at'), safe_float(plan.get('created_at'), time.time()))
        return max(0.0, time.time() - started)

    def _smart_revalidation_health(self, plan: dict, fresh: Optional[dict]) -> dict:
        """Classify revalidation as HEALTHY, SOFT_DEGRADED or HARD_INVALID.

        V7 deliberately separates normal market noise from a broken trade thesis.
        A small quality/expected-net change therefore does not immediately cancel a
        resting limit. Structural invalidation still does.
        """
        if not fresh:
            return {'state':'HARD_INVALID','reason':'market structure unavailable','soft':False}
        side=plan['side']; age=self._smart_order_age(plan)
        original_q=safe_float(plan.get('original_quality'),plan.get('quality',0))
        original_rr=safe_float(plan.get('original_rr'),plan.get('rr',0))
        original_net=safe_float(plan.get('original_expected_net'),plan.get('expected_net',0))
        q=safe_float(fresh.get('quality')); rr=safe_float(fresh.get('rr')); net=safe_float(fresh.get('expected_net'))
        required=safe_float(fresh.get('required_net'),self.smart_boundary_min_net_profit)
        blockers=list(fresh.get('blockers') or [])
        volume_ratio=safe_float(fresh.get('volume_ratio'),0)
        atr=max(safe_float(fresh.get('atr')),1e-12)
        # Hard structural invalidation. These conditions mean the original range thesis
        # is no longer trustworthy and should override the residence/grace period.
        if side=='SELL' and safe_float(fresh.get('price')) > safe_float(fresh.get('stop')):
            return {'state':'HARD_INVALID','reason':'range high broken','soft':False}
        if side=='BUY' and safe_float(fresh.get('price')) < safe_float(fresh.get('stop')):
            return {'state':'HARD_INVALID','reason':'range low broken','soft':False}
        if volume_ratio >= self.smart_boundary_hard_volume_ratio:
            return {'state':'HARD_INVALID','reason':f'breakout volume expansion {volume_ratio:.2f}x','soft':False}
        if q < self.smart_boundary_quality_hard_floor:
            return {'state':'HARD_INVALID','reason':f'boundary quality collapsed to {q:.0f}/100','soft':False}
        if rr > 0 and rr < self.smart_boundary_rr_hard_floor:
            return {'state':'HARD_INVALID','reason':f'R:R collapsed to {rr:.2f}','soft':False}
        # A material zone displacement is a reprice event, not ordinary noise.
        old_zone=(safe_float(plan.get('zone_low')),safe_float(plan.get('zone_high')))
        new_zone=(safe_float(fresh.get('zone_low')),safe_float(fresh.get('zone_high')))
        if old_zone[0]>0 and new_zone[0]>0:
            shift=max(abs(new_zone[0]-old_zone[0]),abs(new_zone[1]-old_zone[1]))/atr
            if shift >= self.smart_boundary_zone_shift_atr and age >= self.smart_boundary_reprice_min_age_seconds:
                return {'state':'REPRICE','reason':f'boundary zone moved {shift:.2f} ATR','soft':False}
        # Soft deterioration. Give it a grace period before cancellation.
        q_drop=max(0.0,original_q-q)
        rr_drop=(original_rr-rr)/max(original_rr,1e-12) if original_rr>0 else 0.0
        net_shortfall=max(0.0,required-net)
        soft_reasons=[]
        if q_drop >= self.smart_boundary_quality_soft_drop: soft_reasons.append(f'quality -{q_drop:.0f}')
        if rr_drop >= self.smart_boundary_rr_soft_drop_pct: soft_reasons.append(f'R:R -{rr_drop:.0%}')
        if net_shortfall >= self.smart_boundary_net_soft_shortfall: soft_reasons.append(f'net shortfall ${net_shortfall:.2f}')
        # Non-structural blockers from the fresh plan are treated as soft unless they
        # indicate an explicit breakout/invalidated range.
        for b in blockers:
            text=str(b).lower()
            if any(x in text for x in ('range high already broken','range low already broken','breakout volume','boundary tested')):
                soft_reasons.append(str(b))
        if soft_reasons:
            return {'state':'SOFT_DEGRADED','reason':'market changed: '+ '; '.join(soft_reasons[:3]),'soft':True}
        return {'state':'HEALTHY','reason':'minor market changes only','soft':False}

    def _refresh_smart_plan_from_fresh(self, plan: dict, fresh: dict):
        # Preserve the original economics for stability comparisons while updating the
        # live zone/target/stop information used by the dashboard.
        for k in ('zone_low','zone_high','range_high','range_low','target','stop','quality','expected_net','required_net','rr','reason','candle_time'):
            if k in fresh: plan[k]=fresh[k]
        plan['updated_at']=time.time()
        plan['last_revalidated_at']=time.time()
        return plan

    def _v8_thesis_key(self, symbol: str, side: str) -> Tuple[str,str]:
        return (symbol, side)

    def _v8_in_cooldown(self, symbol: str, side: str) -> bool:
        until=self.v8_thesis_cooldowns.get((symbol,side),0.0)
        return time.time() < until

    def _v8_set_cooldown(self, symbol: str, side: str, seconds: Optional[float]=None) -> None:
        self.v8_thesis_cooldowns[(symbol,side)] = time.time() + (self.v8_invalidation_cooldown_seconds if seconds is None else seconds)

    def _v8_entry_commit_signal(self, plan: dict) -> dict:
        """Final range-entry gate. V8.3 is deliberately less aggressive.

        A boundary penetration is only an *entry window*, not proof of direction.
        The final decision also checks live 1m/5m/15m structure, momentum,
        range-direction margin, adverse risk, and whether the market has started
        trending against the range thesis.
        """
        symbol,side=plan['symbol'],plan['side']
        buy=side=='BUY'
        try:
            _, _, features=self.generate_signal(symbol)
            _, range_conf, range_features=self.generate_ranging_signal(symbol)
            live_df=self.get_klines(symbol,'5m',80)
            live_regime=self.detect_regime(live_df.iloc[:-1]) if live_df is not None and len(live_df)>=50 else 'unknown'
            v7=self.predictive_entry_intelligence(symbol,side,features,live_regime)
        except Exception as exc:
            return {'confirmed':False,'score':0,'reason':f'commit analysis unavailable: {exc}','v7':{}}

        f1=features.get('1m',{})
        f5=features.get('5m',{})
        f15=features.get('15m',{})
        mom=safe_float(f1.get('momentum'))
        mom5=safe_float(f5.get('momentum'))
        trend1=f1.get('trend')
        trend5=f5.get('trend')
        trend15=f15.get('trend')
        wanted=1 if buy else -1
        trigger_ok=bool(features.get('trigger_buy' if buy else 'trigger_sell'))
        rejection=bool((features.get('candle_1m',{}) or {}).get('bull' if buy else 'bear'))
        direction=v7.get('direction_probability',0)
        timing=v7.get('entry_timing',0)
        agreement=v7.get('market_agreement',0)
        adverse=v7.get('adverse_risk',100)
        range_long=safe_float(range_features.get('long_score',0))
        range_short=safe_float(range_features.get('short_score',0))
        range_direction=range_long if buy else range_short
        range_opposite=range_short if buy else range_long
        range_margin=range_direction-range_opposite
        live_price=self.get_mark_price(symbol, force=True)
        trigger_price=safe_float(plan.get('trigger_price'),0.0)
        atr=max(safe_float(plan.get('atr')),1e-12)
        penetration_atr=((trigger_price-live_price)/atr if buy else (live_price-trigger_price)/atr) if live_price>0 and trigger_price>0 else 999.0
        boundary_triggered=((buy and live_price>0 and live_price<=trigger_price) or
                            ((not buy) and live_price>0 and live_price>=trigger_price))

        # A range setup must not be allowed to buy a falling market or short a rising
        # market simply because price crossed the boundary. If both 5m and 15m oppose
        # the trade, the range thesis is considered stale and the entry is vetoed.
        opposing_tf_count=sum(1 for v in (trend5,trend15) if v == -wanted)
        trend_veto=(getattr(self,'v8_require_trend_sanity',True) and
                    opposing_tf_count>=2)
        regime_text=str(live_regime).lower()
        opposite_trend_regime=(('trending' in regime_text) and
                               ((wanted==1 and 'trending' in regime_text and trend5==-1 and trend15==-1) or
                                (wanted==-1 and 'trending' in regime_text and trend5==1 and trend15==1)))

        # Do not enter after the market has already run materially through the trigger.
        # This is the key V8.3 anti-chasing safeguard for cases such as 79,432 -> 79,273.
        max_pen=getattr(self,'v8_max_trigger_penetration_atr',0.30)
        penetration_ok=boundary_triggered and penetration_atr <= max_pen

        direction_blended=max(direction, min(100.0, 55.0 + 0.45*range_direction))
        score=(0.25*direction_blended+0.25*timing+0.20*agreement+0.15*(100-adverse)+0.15*(100 if trigger_ok or rejection else 35))
        if opposing_tf_count>=1: score -= 7.0
        if trend_veto or opposite_trend_regime: score -= 15.0
        if penetration_atr > max_pen: score -= 20.0
        score=max(0,min(100,score))

        reasons=[]
        if trend1 == wanted: reasons.append('1m direction aligned')
        if trend5 == wanted: reasons.append('5m trend aligned')
        if trend15 == wanted: reasons.append('15m trend aligned')
        if (mom>0 if buy else mom<0): reasons.append('1m momentum aligned')
        if (mom5>0 if buy else mom5<0): reasons.append('5m momentum aligned')
        if range_direction >= getattr(self,'v8_range_direction_min',58.0): reasons.append(f'range {"LONG" if buy else "SHORT"} evidence {range_direction:.0f}')
        if range_margin >= getattr(self,'v8_range_direction_margin_min',10.0): reasons.append('range direction margin confirmed')
        if boundary_triggered: reasons.append(f'boundary penetration {penetration_atr:.2f} ATR')
        if trigger_ok: reasons.append('closed-candle trigger confirmed')
        elif rejection: reasons.append('1m rejection confirmed')
        if opposing_tf_count: reasons.append(f'{opposing_tf_count} higher-timeframe direction conflict')
        if trend_veto or opposite_trend_regime: reasons.append('trend veto: range thesis conflicts with live trend')
        if penetration_atr > max_pen: reasons.append(f'penetration {penetration_atr:.2f} ATR exceeds {max_pen:.2f} ATR safety')
        if direction_blended < self.v8_min_direction_probability: reasons.append(f'blended direction {direction_blended:.0f}% weak')
        if adverse > self.v8_max_adverse_risk: reasons.append(f'adverse risk {adverse:.0f}% high')

        confirmed=(score>=self.v8_min_commit_score and
                   direction_blended>=self.v8_min_direction_probability and
                   range_direction>=getattr(self,'v8_range_direction_min',58.0) and
                   range_margin>=getattr(self,'v8_range_direction_margin_min',10.0) and
                   adverse<=self.v8_max_adverse_risk and
                   boundary_triggered and penetration_ok and
                   not trend_veto and not opposite_trend_regime and
                   opposing_tf_count<2)
        return {'confirmed':confirmed,'score':score,'direction_probability':direction_blended,'timing':timing,'agreement':agreement,'adverse_risk':adverse,
                'range_direction':range_direction,'range_margin':range_margin,'penetration_atr':penetration_atr,
                'live_regime':live_regime,'reason':'; '.join(reasons) if reasons else 'range entry confirmation passed','v7':v7}

    def _v8_stage(self, plan: dict, price: float, previous_price: float) -> str:
        """Return WATCH, ARMED, COMMIT or INVALID using controlled boundary penetration.

        Crossing the trigger creates an entry window. It does not mean "buy regardless
        of how far price keeps falling" (or sell regardless of how far it keeps rising).
        """
        atr=max(safe_float(plan.get('atr')),1e-12)
        zl,zh=safe_float(plan.get('zone_low')),safe_float(plan.get('zone_high'))
        trigger=safe_float(plan.get('trigger_price'))
        side=plan['side']
        max_pen=getattr(self,'v8_max_trigger_penetration_atr',0.30)
        if side=='BUY':
            penetration=(trigger-price)/atr if trigger>0 and price>0 else 999.0
            if price < safe_float(plan.get('stop')) - self.v8_break_tolerance_atr*atr:
                return 'INVALID'
            if penetration > max_pen:
                return 'INVALID'
            near = price <= zh + self.v8_arm_proximity_atr*atr
            boundary_hit = price <= trigger
            in_commit = boundary_hit and penetration <= max_pen
        else:
            penetration=(price-trigger)/atr if trigger>0 and price>0 else 999.0
            if price > safe_float(plan.get('stop')) + self.v8_break_tolerance_atr*atr:
                return 'INVALID'
            if penetration > max_pen:
                return 'INVALID'
            near = price >= zl - self.v8_arm_proximity_atr*atr
            boundary_hit = price >= trigger
            in_commit = boundary_hit and penetration <= max_pen
        if not near:
            return 'WATCH'
        if in_commit:
            return 'COMMIT'
        return 'ARMED'

    def _v8_cancel_thesis(self, key, plan, reason: str):
        symbol,side,_=key
        self._v8_set_cooldown(symbol,side)
        plan['v8_state']='INVALIDATED'; plan['last_cancel_reason']=reason; plan['reason']=reason; plan['updated_at']=time.time()
        self._cancel_smart_plan(key, f'thesis invalidated: {reason}')

    def manage_smart_boundary_orders(self):
        """V8 event-driven SDBE lifecycle.

        Key design: do not place an exchange LIMIT merely because a range analysis is
        attractive. Build a local thesis first, wait until price is actually near the
        zone, then require a short reclaim/rejection confirmation before committing.
        Existing exchange orders are still Binance-authoritative and are protected from
        soft noise, but V8 normally creates them only at the final entry window.
        """
        if getattr(self, 'v88_disable_limit_entries', True) or not self.smart_boundary_enabled or not self.state_known:
            return
        now=time.time()
        new_entries_ok=False
        if not self.paused:
            try:
                allowed,_=self.risk.new_entries_allowed()
                new_entries_ok=bool(allowed) and self.total_daily_trade_count() < self.max_daily_trades
            except Exception:
                new_entries_ok=False

        # ---------------- EXISTING EXCHANGE SMART ORDERS ----------------
        # Once committed to Binance, do not rebuild the thesis every 2 seconds.
        for key,plan in list(self.smart_orders.items()):
            symbol,side,_=key
            if symbol in self.positions and side in self.positions[symbol]:
                self._adopt_filled_smart_order(key,plan); continue
            age=self._smart_order_age(plan)
            if age > self.smart_boundary_max_age_seconds:
                self._cancel_smart_plan(key,'smart limit exceeded maximum age'); continue
            order_id=plan.get('order_id')
            if order_id and self.mode!='PAPER':
                status=self.get_order_status(symbol,order_id)
                if status is None: continue
                st=str(status.get('status','')).upper()
                if st=='FILLED':
                    self.reconcile(force=True,reason='SMART LIMIT FILLED')
                    self._adopt_filled_smart_order(key,plan); continue
                if st in ('CANCELED','CANCELLED','EXPIRED','REJECTED'):
                    plan['status']=st; self._save_smart_order_db(plan,st,order_id); self.smart_orders.pop(key,None); self.smart_plans.pop(key,None); continue
            elif order_id and self.mode=='PAPER':
                px=self.get_mark_price(symbol,force=True)
                crossed=(side=='BUY' and px<=plan['limit_price']) or (side=='SELL' and px>=plan['limit_price'])
                if crossed:
                    self._simulate_smart_fill(plan); continue

            # A committed exchange order receives only measured revalidation. During
            # the short post-commit grace window, price noise cannot cancel the order.
            # Binance order status is still checked every cycle, so a real fill is never
            # hidden. Structural protection resumes after the grace window.
            commit_ts=safe_float(plan.get('commit_at'),0)
            if commit_ts and now-commit_ts < self.v8_post_commit_grace_seconds:
                continue
            if plan.get('last_revalidated_at') and now-safe_float(plan.get('last_revalidated_at')) < self.v8_revalidation_seconds:
                continue
            fresh=self._range_boundary_plan(symbol,side)
            health=self._smart_revalidation_health(plan,fresh)
            plan['last_revalidated_at']=now
            if health['state']=='HARD_INVALID':
                self._cancel_smart_plan(key,health['reason']); self._v8_set_cooldown(symbol,side); continue
            if health['state']=='REPRICE':
                # After commitment, do not chase a moving range. Let the existing order
                # stand unless the structure is genuinely broken.
                if age < self.smart_boundary_reprice_min_age_seconds:
                    plan['revalidation_failures']=int(plan.get('revalidation_failures',0))+1
                else:
                    self._cancel_smart_plan(key,health['reason']); self._v8_set_cooldown(symbol,side); continue
            elif health['state']=='SOFT_DEGRADED':
                if not plan.get('soft_fail_started_at'): plan['soft_fail_started_at']=now
                plan['revalidation_failures']=int(plan.get('revalidation_failures',0))+1
                if (age>=self.smart_boundary_min_residence_seconds and
                    now-plan['soft_fail_started_at']>=self.smart_boundary_soft_grace_seconds and
                    plan['revalidation_failures']>=self.smart_boundary_revalidation_max_failures):
                    self._cancel_smart_plan(key,health['reason']); self._v8_set_cooldown(symbol,side); continue
            else:
                plan['soft_fail_started_at']=0.0; plan['revalidation_failures']=0
            if fresh:
                self._refresh_smart_plan_from_fresh(plan,fresh)
                self.smart_orders[key]=plan; self.smart_plans[key]=plan; self._save_smart_order_db(plan,'NEW',order_id)

        # ---------------- LOCAL WATCH / ARMED THESIS ----------------
        # Local plans are intentionally not exchange orders. They are analysis state.
        for key,plan in list(self.smart_plans.items()):
            if key in self.smart_orders: continue
            symbol,side,_=key
            age=now-safe_float(plan.get('created_at'),now)
            if age > self.v8_max_thesis_age_seconds:
                self.smart_plans.pop(key,None); self._delete_smart_order_db(key); self._v8_set_cooldown(symbol,side,45); continue
            if self._v8_in_cooldown(symbol,side):
                continue
            px=self.get_mark_price(symbol)
            if px<=0: continue
            previous=self.v8_previous_prices.get(symbol,px)
            stage=self._v8_stage(plan,px,previous)
            plan['v8_state']=stage
            plan['updated_at']=now
            plan['last_price']=px

            if stage=='INVALID':
                self._v8_cancel_thesis(key,plan,'range boundary invalidated')
                continue

            # Do not continuously rebuild the range while price is merely WATCHING.
            # Recheck only after a meaningful interval.
            last_check=safe_float(plan.get('last_revalidated_at'),0)
            if now-last_check >= self.v8_revalidation_seconds:
                fresh=self._range_boundary_plan(symbol,side)
                if fresh:
                    # Preserve thesis identity; update economics without moving the
                    # trigger every scan. A material structural shift creates a new thesis.
                    old_range=(safe_float(plan.get('range_low')),safe_float(plan.get('range_high')))
                    new_range=(safe_float(fresh.get('range_low')),safe_float(fresh.get('range_high')))
                    atr=max(safe_float(plan.get('atr')),1e-12)
                    shift=max(abs(new_range[0]-old_range[0]),abs(new_range[1]-old_range[1]))/atr if old_range[0]>0 else 0
                    if shift >= self.smart_boundary_zone_shift_atr and stage in ('WATCH','ARMED'):
                        # Replace the analysis thesis only after a genuine structural move.
                        plan.update(fresh); plan['v8_state']='WATCH'; plan['created_at']=now; plan['armed_at']=None; plan['commit_at']=None
                    else:
                        for k in ('quality','rr','expected_net','required_net','reason','zone_low','zone_high','target','stop','range_low','range_high','atr','boundary_tests'):
                            if k in fresh: plan[k]=fresh[k]
                plan['last_revalidated_at']=now

            if stage=='ARMED' and not plan.get('armed_at'):
                plan['armed_at']=now
                self.send_telegram(
                    f'🟢 <b>QuanT ENTRY ARMED</b>\n{symbol} {"LONG" if side=="BUY" else "SHORT"}\n'
                    f'Trigger: ${safe_float(plan.get("trigger_price")):,.6f}\n'
                    f'Zone: ${safe_float(plan.get("zone_low")):,.6f} – ${safe_float(plan.get("zone_high")):,.6f}\n'
                    f'Quality: {safe_float(plan.get("quality")):.0f}/100\n'
                    f'Waiting for boundary penetration trigger.'
                )

            if stage=='COMMIT':
                if now-safe_float(plan.get('armed_at'),now) > self.v8_max_commit_wait_seconds:
                    self._v8_cancel_thesis(key,plan,'commit window expired'); continue
                if now-safe_float(self.v8_last_commit_attempt.get((symbol,side),0)) < self.v8_confirmation_seconds:
                    continue
                self.v8_last_commit_attempt[(symbol,side)]=now
                commit=self._v8_entry_commit_signal(plan)
                plan['v8_commit_score']=commit.get('score',0)
                plan['direction_probability']=commit.get('direction_probability',0)
                plan['entry_timing']=commit.get('timing',0)
                plan['market_agreement']=commit.get('agreement',0)
                plan['adverse_risk']=commit.get('adverse_risk',100)
                plan['penetration_atr']=commit.get('penetration_atr',0)
                plan['live_regime']=commit.get('live_regime','unknown')
                if not commit.get('confirmed'):
                    plan['v8_state']='ARMED'
                    plan['reason']='V8 waiting: '+str(commit.get('reason','confirmation not ready'))
                    self._save_smart_order_db(plan,'ARMED',None)
                    continue

                # Final exchange-side exposure/risk check occurs immediately before
                # commitment. No distant LIMIT is left waiting for minutes.
                if not new_entries_ok: continue
                if now-self.last_global_trade_time < self.global_trade_cooldown_seconds: continue
                if symbol in self.positions and side in self.positions[symbol]: continue
                try:
                    if self.daily_trade_count(symbol)>=self.max_trades_per_symbol: continue
                except Exception:
                    continue
                ok,_=self.risk.exposure_allowed(plan['notional'])
                if not ok: continue

                plan['v8_state']='CONFIRMED'; plan['commit_at']=now
                # Place a LIMIT at the current confirmed reaction area, not at a stale
                # price discovered several scans earlier. This keeps the existing
                # LIMIT-only Smart Boundary safety rule intact.
                px_now=self.get_mark_price(symbol,force=True)
                if px_now<=0: continue
                if side=='BUY':
                    entry_price=min(px_now, safe_float(plan.get('trigger_price'),px_now))
                    # A confirmed upward reclaim should not be chased far above trigger.
                    if px_now > safe_float(plan.get('trigger_price')) + 0.10*safe_float(plan.get('atr')):
                        plan['v8_state']='ARMED'; continue
                else:
                    entry_price=max(px_now, safe_float(plan.get('trigger_price'),px_now))
                    if px_now < safe_float(plan.get('trigger_price')) - 0.10*safe_float(plan.get('atr')):
                        plan['v8_state']='ARMED'; continue
                plan['limit_price']=self.round_price(symbol,entry_price)
                plan['reason']=(plan.get('reason','')+'; V8 controlled penetration confirmed; entry committed near live price').strip('; ')
                if self.place_smart_limit_order(plan):
                    self.v8_previous_prices[symbol]=px_now
                    continue
                plan['v8_state']='ARMED'

            self.smart_plans[key]=plan
            self._save_smart_order_db(plan,'ARMED',None)

        # ---------------- CREATE NEW WATCH THESIS ----------------
        if new_entries_ok and now-self.last_global_trade_time >= self.global_trade_cooldown_seconds:
            for symbol in self.symbols:
                if any(k[0]==symbol for k in self.smart_orders): continue
                if sum(1 for k in self.smart_orders if k[0]==symbol)>=self.smart_boundary_max_pending_per_symbol: continue
                if symbol in self.positions and len(self.positions.get(symbol,{}))>=2: continue
                try:
                    if self.daily_trade_count(symbol)>=self.max_trades_per_symbol: continue
                except Exception:
                    continue
                for side in ('BUY','SELL'):
                    key=self._smart_plan_key(symbol,side)
                    if key in self.smart_plans or key in self.smart_orders: continue
                    if self._v8_in_cooldown(symbol,side): continue
                    plan=self._range_boundary_plan(symbol,side)
                    if not plan: continue
                    # We allow a WATCH thesis to exist even when price is not yet near
                    # the activation zone. This is the major V8 change: analysis first,
                    # execution only when price actually arrives.
                    nonstruct=[b for b in plan.get('blockers',[]) if 'activation zone' not in str(b)]
                    if nonstruct: continue
                    plan['v8_state']='WATCH'
                    plan['created_at']=now
                    plan['last_revalidated_at']=now
                    plan['original_quality']=plan['quality']; plan['original_rr']=plan.get('rr',0); plan['original_expected_net']=plan.get('expected_net',0)
                    plan['thesis_id']=f"{symbol}_{side}_{int(now)}"
                    self.smart_plans[key]=plan
                    self._save_smart_order_db(plan,'ARMED',None)

        for sym in self.symbols:
            px=self.get_mark_price(sym)
            if px>0:
                self.v8_previous_prices[sym]=px

    def smart_boundary_dashboard(self, symbol: str) -> List[str]:
        lines=[]
        for key,plan in self.smart_plans.items():
            if key[0]!=symbol: continue
            side=plan['side']; label='LONG' if side=='BUY' else 'SHORT'; status=plan.get('v8_state',plan.get('status','WATCH'))
            age=self._smart_order_age(plan)
            lines.append(f'🧠 V8 {label} {status} | Trigger ${safe_float(plan.get("trigger_price")):,.6f} | Zone ${safe_float(plan.get("zone_low")):,.6f}-${safe_float(plan.get("zone_high")):,.6f}')
            lines.append(f'Quality: {int(safe_float(plan.get("quality")))} | R:R {safe_float(plan.get("rr")):.2f} | Exp.Net ${safe_float(plan.get("expected_net")):+.2f} / Req ${safe_float(plan.get("required_net")):.2f} | Age {age:.0f}s')
            if plan.get('v8_commit_score'):
                lines.append(f'Commit: {safe_float(plan.get("v8_commit_score")):.0f}/100 | Dir {safe_float(plan.get("direction_probability")):.0f}% | Timing {safe_float(plan.get("entry_timing")):.0f}% | Risk {safe_float(plan.get("adverse_risk")):.0f}%')
        return lines

    # -------------------------------------------------------------------------
    # V7 PREDICTIVE ENTRY INTELLIGENCE
    # -------------------------------------------------------------------------
    def _v7_softmax_direction(self, long_score: float, short_score: float) -> Tuple[float, float]:
        """Convert directional scores into a bounded probability-like estimate.
        This is a confidence estimate, not a guarantee of future price movement.
        """
        import math as _math
        temp = 35.0
        try:
            a = _math.exp(max(-20.0, min(20.0, float(long_score) / temp)))
            b = _math.exp(max(-20.0, min(20.0, float(short_score) / temp)))
            z = max(a + b, 1e-12)
            return 100.0 * a / z, 100.0 * b / z
        except Exception:
            return 50.0, 50.0

    def predictive_entry_intelligence(self, symbol: str, side: str, features: dict, regime: str = "unknown") -> dict:
        """V7 entry gate: direction != timing.

        The function deliberately prefers WAIT over a weak immediate entry. It uses
        closed-candle data and structural location, then returns a transparent score
        that can be logged and learned from later. No external LLM is required for
        the core decision.
        """
        buy = side == 'BUY'
        f1, f5, f15, f1h = (features.get(k, {}) for k in ('1m','5m','15m','1h'))
        long_score = safe_float(features.get('long_score', 0))
        short_score = safe_float(features.get('short_score', 0))
        p_long, p_short = self._v7_softmax_direction(long_score, short_score)
        direction_probability = p_long if buy else p_short
        opposite_probability = p_short if buy else p_long
        direction_margin = abs(p_long - p_short)

        atr = max(safe_float(f5.get('atr')), 1e-12)
        price = safe_float(f5.get('price'))
        ema20 = safe_float(f5.get('ema20'), price)
        dist_ema = abs(price - ema20) / atr if price > 0 else 99.0

        # Location: entering near a logical pullback zone is better than chasing.
        if dist_ema < 0.10:
            location = 68.0
        elif 0.10 <= dist_ema <= 0.75:
            location = 92.0
        elif dist_ema <= 1.20:
            location = 72.0
        elif dist_ema <= 1.60:
            location = 42.0
        else:
            location = 10.0

        # Avoid buying directly under resistance / selling directly above support.
        hi = safe_float(f5.get('range_high_20'))
        lo = safe_float(f5.get('range_low_20'))
        resistance_gap = (hi - price) / atr if buy and hi > price else 99.0
        support_gap = (price - lo) / atr if (not buy) and price > lo else 99.0
        boundary_penalty = 0.0
        if buy and resistance_gap <= self.v7_max_resistance_distance_atr:
            boundary_penalty = max(0.0, 35.0 - resistance_gap * 35.0)
            location -= boundary_penalty
        if not buy and support_gap <= self.v7_max_support_distance_atr:
            boundary_penalty = max(0.0, 35.0 - support_gap * 35.0)
            location -= boundary_penalty
        location = max(0.0, min(100.0, location))

        # Momentum: direction should be supported, but an extreme impulse is a chase risk.
        mom = safe_float(f5.get('momentum'))
        slope = safe_float(f5.get('ema_slope'))
        aligned_mom = (mom > 0 and buy) or (mom < 0 and not buy)
        aligned_slope = (slope > 0 and buy) or (slope < 0 and not buy)
        momentum = 50.0
        if aligned_mom: momentum += 25.0
        elif abs(mom) > 0.00025: momentum -= 20.0
        if aligned_slope: momentum += 20.0
        if abs(mom) > 0.0035: momentum -= 20.0
        momentum = max(0.0, min(100.0, momentum))

        # Candle timing: closed 1m and 5m candles should agree with the proposed side.
        c1, c5 = features.get('candle_1m', {}), features.get('candle_5m', {})
        c1_ok = bool(c1.get('bull' if buy else 'bear'))
        c5_ok = bool(c5.get('bull' if buy else 'bear'))
        candle = 45.0 + (30.0 if c5_ok else 0.0) + (20.0 if c1_ok else 0.0)
        if safe_float(c5.get('range_atr')) > self.candle_max_atr:
            candle -= 25.0
        candle = max(0.0, min(100.0, candle))

        # Structure agreement across higher/lower timeframes.
        wanted = 1 if buy else -1
        tf_values = [f1h.get('trend'), f15.get('trend'), f5.get('trend'), f1.get('trend')]
        tf_weights = [0.30, 0.30, 0.25, 0.15]
        structure = sum(w * 100.0 for v, w in zip(tf_values, tf_weights) if v == wanted)
        structure += 8.0 if aligned_slope else 0.0
        structure -= 12.0 if ((f15.get('trend') == -wanted) and (f5.get('trend') == -wanted)) else 0.0
        structure = max(0.0, min(100.0, structure))

        # Volatility: normal volatility is preferred; extreme expansion is a timing risk.
        vr = safe_float(f5.get('volume_ratio'))
        adx = safe_float(f5.get('adx'))
        volatility = 90.0
        if vr < self.entry_volume_ratio_min:
            volatility -= 15.0
        if vr >= self.vol_expansion_ratio:
            volatility -= 20.0
        if vr >= self.v7_extreme_volume_ratio:
            volatility -= 35.0
        if adx < 16:
            volatility -= 12.0
        volatility = max(0.0, min(100.0, volatility))

        timing = (self.v7_location_weight * location +
                  self.v7_momentum_weight * momentum +
                  self.v7_candle_weight * candle +
                  self.v7_structure_weight * structure +
                  self.v7_volatility_weight * volatility)
        timing = max(0.0, min(100.0, timing))

        # Explicit contradiction score. High contradiction means WAIT even when
        # directional probability looks attractive.
        contradictions = []
        if direction_margin < self.v7_min_direction_margin:
            contradictions.append(f'direction margin only {direction_margin:.1f}%')
        if f15.get('trend') == -wanted:
            contradictions.append('15m direction disagrees')
        if f5.get('trend') == -wanted:
            contradictions.append('5m direction disagrees')
        if not c5_ok:
            contradictions.append('5m entry candle not confirmed')
        if buy and resistance_gap <= self.v7_max_resistance_distance_atr:
            contradictions.append(f'near resistance ({resistance_gap:.2f} ATR)')
        if not buy and support_gap <= self.v7_max_support_distance_atr:
            contradictions.append(f'near support ({support_gap:.2f} ATR)')
        if vr >= self.v7_extreme_volume_ratio:
            contradictions.append(f'extreme volume {vr:.2f}x')
        if 'chop' in str(regime).lower():
            contradictions.append('choppy regime')
        contradiction_penalty = min(60.0, len(contradictions) * 12.0)
        agreement = max(0.0, min(100.0, 0.55 * structure + 0.25 * (100.0 - contradiction_penalty) + 0.20 * direction_probability))

        # Approximate adverse-move risk before the trade has a chance to work.
        adverse_risk = 100.0 - (0.45 * location + 0.25 * momentum + 0.20 * structure + 0.10 * volatility)
        if contradictions:
            adverse_risk += min(20.0, len(contradictions) * 5.0)
        adverse_risk = max(0.0, min(100.0, adverse_risk))

        risk_score = 100.0 - adverse_risk
        final_score = (self.v7_final_direction_weight * direction_probability +
                       self.v7_final_timing_weight * timing +
                       self.v7_final_agreement_weight * agreement +
                       self.v7_final_risk_weight * risk_score)
        final_score = max(0.0, min(100.0, final_score))

        blockers = []
        if direction_probability < self.v7_min_direction_probability:
            blockers.append(f'direction {direction_probability:.0f}% below V7 minimum {self.v7_min_direction_probability:.0f}%')
        if direction_margin < self.v7_min_direction_margin:
            blockers.append(f'direction margin {direction_margin:.0f}% too small')
        if timing < self.v7_min_entry_timing:
            blockers.append(f'entry timing {timing:.0f}% below V7 minimum {self.v7_min_entry_timing:.0f}%')
        if agreement < self.v7_min_market_agreement:
            blockers.append(f'market agreement {agreement:.0f}% below V7 minimum {self.v7_min_market_agreement:.0f}%')
        if final_score < self.v7_min_final_score:
            blockers.append(f'final entry score {final_score:.0f} below V7 minimum {self.v7_min_final_score:.0f}')
        if adverse_risk > self.v7_max_adverse_risk:
            blockers.append(f'adverse-entry risk {adverse_risk:.0f}% too high')
        if self.v7_block_chop and 'chop' in str(regime).lower():
            blockers.append('V7 blocks immediate market entry in chop')
        if self.v7_block_extreme_volume and vr >= self.v7_extreme_volume_ratio:
            blockers.append(f'extreme volume {vr:.2f}x; wait for normalization')

        return {
            'direction_probability': direction_probability,
            'opposite_probability': opposite_probability,
            'long_probability': p_long,
            'short_probability': p_short,
            'direction_margin': direction_margin,
            'entry_timing': timing,
            'market_agreement': agreement,
            'adverse_risk': adverse_risk,
            'risk_score': risk_score,
            'final_score': final_score,
            'location_score': location,
            'momentum_score': momentum,
            'candle_score': candle,
            'structure_score': structure,
            'volatility_score': volatility,
            'resistance_gap_atr': resistance_gap,
            'support_gap_atr': support_gap,
            'contradictions': contradictions,
            'blockers': blockers,
            'action': 'ENTER' if not blockers else 'WAIT',
            'reason': ('V7 confirmed direction + timing + agreement' if not blockers else '; '.join(blockers[:4]))
        }

    def v7_gate_candidate(self, candidate: dict, route: dict) -> dict:
        """Attach V7 predictive intelligence to a normal market-entry candidate."""
        if not self.v7_enabled:
            candidate['v7'] = {'action':'ENTER','final_score':candidate.get('score',0), 'direction_probability':candidate.get('direction',0), 'entry_timing':candidate.get('score',0), 'market_agreement':candidate.get('score',0), 'adverse_risk':0, 'blockers':[]}
            return candidate
        side = candidate['side']
        features = route.get('trend_features') or {}
        if not features:
            _, _, features = self.generate_signal(candidate.get('symbol',''))
        v7 = self.predictive_entry_intelligence(candidate.get('symbol',''), side, features, route.get('regime','unknown'))
        candidate['v7'] = v7
        candidate['direction_probability'] = v7['direction_probability']
        candidate['entry_timing'] = v7['entry_timing']
        candidate['market_agreement'] = v7['market_agreement']
        candidate['adverse_risk'] = v7['adverse_risk']
        candidate['v7_score'] = v7['final_score']
        candidate['blockers'] = list(candidate.get('blockers', [])) + list(v7.get('blockers', []))
        candidate['reason'] = (candidate.get('reason') or '') + '; V7: ' + v7['reason']
        candidate['score'] = int(round(min(candidate.get('score',0), v7['final_score'])))
        return candidate

    def _v85_market_entry_policy(self, candidate: dict, route: dict) -> dict:
        """V8.8 opportunity-first market-entry policy.

        Important separation:
        * TREND / TRANSITION = enter at the current market price when the live
          directional thesis is strong enough. They do not wait for min/max.
        * RANGE = remains the dedicated zig-zag/range strategy. Its boundary
          analysis is optional and never creates a LIMIT order.
        * MICRO = remains a small tactical strategy with its own risk controls.
        """
        strategy = str(candidate.get('strategy','')).lower()
        if not getattr(self, 'v88_enabled', True):
            return candidate
        if strategy not in ('trend', 'transition'):
            return candidate

        v7 = candidate.get('v7') or {}
        direction = safe_float(v7.get('direction_probability', candidate.get('direction', 0)))
        timing = safe_float(v7.get('entry_timing', 0))
        agreement = safe_float(v7.get('market_agreement', 0))
        final = safe_float(v7.get('final_score', candidate.get('score', 0)))
        adverse = safe_float(v7.get('adverse_risk', 100))
        rr = safe_float(candidate.get('rr', 0))
        expected_net = safe_float(candidate.get('expected_net', 0))
        side = candidate.get('side')

        tf = route.get('trend_features') or {}
        f1, f5, f15 = tf.get('1m',{}), tf.get('5m',{}), tf.get('15m',{})
        wanted = 1 if side == 'BUY' else -1
        live_trend_ok = (f5.get('trend') == wanted and f1.get('trend') == wanted)
        higher_trend_ok = f15.get('trend') == wanted
        momentum = safe_float(f5.get('momentum'))
        live_momentum_ok = (momentum >= self.v88_min_momentum if side == 'BUY'
                            else momentum <= -self.v88_min_momentum)
        price = safe_float(f5.get('live_price', f5.get('price', route.get('price', 0))))
        atr = max(safe_float(f5.get('atr')), 1e-12)
        ema20 = safe_float(f5.get('ema20'), price)
        dist_atr = abs(price - ema20) / atr if price > 0 else 99.0
        adx5 = safe_float(f5.get('adx'))
        volume = safe_float(f5.get('volume_ratio'))
        slope = safe_float(f5.get('ema_slope'))
        slope_ok = slope > 0 if side == 'BUY' else slope < 0

        # V8.8 uses a tiered gate. This is intentionally easier than V8.7, but
        # it still requires live directional evidence and a hard anti-chase guard.
        direction_ok = direction >= self.v88_min_direction
        timing_ok = timing >= self.v88_min_timing
        agreement_ok = agreement >= self.v88_min_agreement
        final_ok = final >= self.v88_min_final
        adverse_ok = adverse <= self.v88_max_adverse
        trend_ok = live_trend_ok and higher_trend_ok
        momentum_ok = live_momentum_ok
        not_overextended = dist_atr <= self.v88_max_chase_atr
        hard_chase_ok = dist_atr <= self.v88_max_hard_chase_atr
        strength_ok = adx5 >= self.v88_min_5m_adx or (live_trend_ok and slope_ok)

        # V8.8 soft economics: R:R is no longer allowed to kill a very strong
        # directional move by itself. It remains a preference/risk filter.
        rr_ok = rr >= self.v88_min_rr if rr > 0 else False
        economic_ok = expected_net >= self.v88_expected_net_floor

        # Strong continuation: all live directional evidence agrees.
        strong = (direction_ok and timing_ok and agreement_ok and final_ok and adverse_ok
                  and trend_ok and momentum_ok and strength_ok and hard_chase_ok)

        # Fast market entry: the preferred V8.8 route. No pullback and no candle
        # confirmation wait. Current-price analysis is the entry trigger.
        fast = (strong and not_overextended and
                (rr_ok or strategy == 'transition' or expected_net >= self.v88_expected_net_floor))

        # Early opportunity: permits one small relaxation when the market is already
        # moving in the predicted direction, while retaining trend + momentum + risk.
        early = (direction >= self.v88_min_direction and
                 timing >= self.v88_min_timing - 4 and
                 agreement >= self.v88_min_agreement - 3 and
                 final >= self.v88_min_final and
                 adverse <= self.v88_max_adverse and
                 live_trend_ok and momentum_ok and strength_ok and hard_chase_ok and
                 (rr_ok or economic_ok))

        blockers = []
        # Preserve meaningful structural blockers from the strategy, but remove
        # obsolete pullback/limit/closed-candle requirements for directional market entry.
        for b in candidate.get('blockers', []):
            low = str(b).lower()
            obsolete = (
                'closed-candle trigger missing' in low or
                'activation zone' in low or
                'pullback' in low or
                'range boundary' in low or
                'limit' in low or
                'r:r' in low and 'below minimum' in low or
                'expected net' in low and 'below minimum' in low
            )
            if obsolete:
                continue
            blockers.append(b)

        if not direction_ok: blockers.append(f'V8.8 direction {direction:.0f}<minimum {self.v88_min_direction:.0f}')
        if not timing_ok: blockers.append(f'V8.8 timing {timing:.0f}<minimum {self.v88_min_timing:.0f}')
        if not agreement_ok: blockers.append(f'V8.8 agreement {agreement:.0f}<minimum {self.v88_min_agreement:.0f}')
        if not final_ok: blockers.append(f'V8.8 final {final:.0f}<minimum {self.v88_min_final:.0f}')
        if not adverse_ok: blockers.append(f'V8.8 adverse risk {adverse:.0f}>{self.v88_max_adverse:.0f}')
        if not live_trend_ok: blockers.append('V8.8 live 1m/5m trend not aligned')
        if not higher_trend_ok: blockers.append('V8.8 15m trend not aligned')
        if not momentum_ok: blockers.append('V8.8 live momentum not aligned')
        if not strength_ok: blockers.append(f'V8.8 5m trend strength weak (ADX {adx5:.1f})')
        if not hard_chase_ok: blockers.append(f'V8.8 price extension {dist_atr:.2f}ATR too high')
        if not_overextended is False and hard_chase_ok: blockers.append(f'V8.8 caution: price {dist_atr:.2f}ATR from EMA20')

        # If the thesis is strong, don't let a stale structural R:R calculation
        # veto the live directional opportunity. The anti-chase and live trend gates
        # are the important protections here.
        if strong and (rr_ok or strategy == 'transition' or economic_ok):
            action = 'ENTER_NOW'
        elif early and not blockers:
            action = 'EARLY_ENTRY'
        elif strong:
            action = 'ACTIVE_WATCH'
        else:
            action = 'WAIT'

        # For ENTER_NOW/EARLY_ENTRY, only meaningful blockers remain. The score gate
        # is handled by the router after this method.
        candidate['blockers'] = list(dict.fromkeys(blockers))
        candidate['v85'] = {
            'strong': strong,
            'fast_entry': action in ('ENTER_NOW','EARLY_ENTRY'),
            'continuation_entry': action in ('ENTER_NOW','EARLY_ENTRY'),
            'rr_ok': rr_ok,
            'economic_ok': economic_ok,
            'live_trend_ok': live_trend_ok,
            'higher_trend_ok': higher_trend_ok,
            'live_momentum_ok': live_momentum_ok,
            'dist_atr': dist_atr,
            'action': action,
            'market_entry': True,
            'range_dependency': False,
            'version': '8.8',
            'volume_ratio': volume,
            'adx5': adx5,
            'slope_ok': slope_ok,
        }
        candidate['market_entry_action'] = action
        if action == 'ENTER_NOW':
            candidate['reason'] = ((candidate.get('reason','') +
                '; V8.8 DIRECT MARKET ENTRY: live direction + momentum + trend aligned').strip('; '))
            candidate['fast_entry'] = True
        elif action == 'EARLY_ENTRY':
            candidate['reason'] = ((candidate.get('reason','') +
                '; V8.8 EARLY MARKET ENTRY: current-price opportunity confirmed').strip('; '))
            candidate['fast_entry'] = True
        elif action == 'ACTIVE_WATCH':
            candidate['reason'] = ((candidate.get('reason','') +
                '; V8.8 strong thesis but protection gate not yet complete').strip('; '))
        return candidate

    def _v89_market_entry_policy(self, candidate: dict, route: dict) -> dict:
        """V8.9 final opportunity/execution gate.

        The key change from V8.8 is that timing is no longer synonymous with
        "wait for a better price". The engine evaluates the market that exists
        NOW. A live continuation can therefore be executable even when the
        closed-candle trigger is absent or the old range has already moved.

        Safety remains explicit: Binance state, direction, live momentum,
        structural alignment, extension, volume exhaustion and economics can
        still veto an entry. There is no LIMIT entry path here.
        """
        if not getattr(self, 'v89_enabled', True):
            return candidate
        strategy = str(candidate.get('strategy', '')).lower()
        if strategy not in ('trend', 'transition', 'micro', 'range'):
            candidate['market_entry_action'] = 'WAIT'
            return candidate

        # Range/micro retain their own strategy-specific risk model. They also use
        # direct MARKET execution when their own candidate is already qualified.
        if strategy in ('range', 'micro'):
            blockers = []
            for b in candidate.get('blockers', []):
                low = str(b).lower()
                if 'limit' in low or 'pullback' in low or 'closed-candle' in low:
                    continue
                blockers.append(b)
            candidate['blockers'] = list(dict.fromkeys(blockers))
            if not candidate['blockers'] and safe_float(candidate.get('score', 0)) >= self.router_min_score:
                candidate['market_entry_action'] = 'ENTER_NOW'
                candidate['v89'] = {'version':'8.9','action':'ENTER_NOW','market_entry':True,'no_limit':True}
            else:
                candidate['market_entry_action'] = 'WAIT'
            return candidate

        v7 = candidate.get('v7') or {}
        tf = route.get('trend_features') or {}
        f1, f5, f15, f1h = (tf.get(k, {}) for k in ('1m', '5m', '15m', '1h'))
        side = candidate.get('side')
        if side not in ('BUY', 'SELL'):
            candidate['market_entry_action'] = 'WAIT'
            return candidate
        wanted = 1 if side == 'BUY' else -1

        direction = safe_float(v7.get('direction_probability', candidate.get('direction', 0)))
        timing = safe_float(v7.get('entry_timing', 0))
        agreement = safe_float(v7.get('market_agreement', 0))
        adverse = safe_float(v7.get('adverse_risk', 100))
        final = safe_float(v7.get('final_score', candidate.get('score', 0)))

        # Live structure is authoritative for the immediate execution decision.
        trend1 = f1.get('trend')
        trend5 = f5.get('trend')
        trend15 = f15.get('trend')
        trend1h = f1h.get('trend')
        mom1 = safe_float(f1.get('momentum'))
        mom5 = safe_float(f5.get('momentum'))
        slope5 = safe_float(f5.get('ema_slope'))
        adx5 = safe_float(f5.get('adx'))
        vr5 = safe_float(f5.get('volume_ratio'))
        price = safe_float(f5.get('live_price', f5.get('price', 0)))
        atr = max(safe_float(f5.get('atr')), 1e-12)
        ema20 = safe_float(f5.get('ema20'), price)
        dist_atr = abs(price - ema20) / atr if price > 0 else 99.0

        live_trend = trend5 == wanted and trend1 == wanted
        higher_trend = trend15 == wanted
        one_higher = trend1h == wanted
        momentum_ok = (mom5 >= self.v89_min_momentum and mom1 >= self.v89_min_momentum * 0.50) if side == 'BUY' else (mom5 <= -self.v89_min_momentum and mom1 <= -self.v89_min_momentum * 0.50)
        slope_ok = slope5 > 0 if side == 'BUY' else slope5 < 0
        strength_ok = adx5 >= self.v88_min_5m_adx or (live_trend and slope_ok)

        # Momentum acceleration: compare current mark with the previous scan.
        # It is evidence of continuation, not a reason to blindly chase.
        previous_price = safe_float(self.v8_previous_prices.get(candidate.get('symbol'), price), price)
        live_move = ((price - previous_price) / previous_price) if previous_price > 0 else 0.0
        acceleration_ok = (live_move > 0 if side == 'BUY' else live_move < 0) and abs(live_move) >= self.v89_min_momentum * 0.50

        # Replace stale closed-candle timing with a live timing score.
        live_timing = 50.0
        if live_trend: live_timing += 18.0
        if higher_trend: live_timing += 10.0
        if momentum_ok: live_timing += 12.0
        if slope_ok: live_timing += 6.0
        if acceleration_ok: live_timing += self.v89_acceleration_bonus
        if dist_atr <= 0.40: live_timing -= 3.0
        elif dist_atr <= self.v89_max_chase_atr: live_timing += 4.0
        if vr5 >= self.v89_extreme_volume_ratio: live_timing -= 20.0
        live_timing = max(0.0, min(100.0, live_timing))

        # Live agreement does not require a perfect four-timeframe match. The
        # 1m/5m pair is execution-critical; 15m is directional context.
        aligned_count = sum(v == wanted for v in (trend1, trend5, trend15, trend1h))
        opposing_count = sum(v == -wanted for v in (trend1, trend5, trend15))
        live_agreement = 48.0 + aligned_count * 13.0 + (8.0 if slope_ok else 0.0) - opposing_count * 12.0
        if momentum_ok: live_agreement += 8.0
        live_agreement = max(0.0, min(100.0, live_agreement))

        # Current-price opportunity economics. Do not use stale range highs/lows
        # as the reward model for a market that is already trending.
        target_atr = self.trend_target_atr if strategy in ('trend', 'transition') else self.micro_target_atr
        stop_atr = self.trend_stop_atr if strategy in ('trend', 'transition') else self.micro_stop_atr
        reward = max(target_atr * atr, 0.60 * atr)
        risk = max(stop_atr * atr, 0.50 * atr)
        rr = reward / max(risk, 1e-12)
        notional = self.fixed_notional * safe_float(candidate.get('notional_mult', 1.0), 1.0)
        expected_net = (reward / max(price, 1e-12)) * notional - (2.0 * self.estimated_commission_rate * notional * self.fee_slippage_buffer)

        # Exhaustion filters. A continuation can be late and still tradable;
        # an exhausted impulse should not be entered simply because direction is strong.
        exhaustion = []
        if dist_atr > self.v89_hard_chase_atr: exhaustion.append(f'price extension {dist_atr:.2f}ATR exceeds hard limit')
        if vr5 >= self.v89_extreme_volume_ratio: exhaustion.append(f'extreme volume {vr5:.2f}x')
        candle5 = tf.get('candle_5m', {}) or {}
        if safe_float(candle5.get('range_atr')) >= self.v89_extreme_candle_atr: exhaustion.append('5m impulse candle exhaustion risk')
        if opposing_count >= 2: exhaustion.append('live 1m/5m/15m direction conflict')

        # Do not allow a long immediately into obvious resistance or a short
        # immediately into obvious support unless the live trend is breaking through it.
        hi = safe_float(f5.get('range_high_20'))
        lo = safe_float(f5.get('range_low_20'))
        if side == 'BUY' and hi > price:
            resistance_gap = (hi - price) / atr
            if resistance_gap <= 0.18 and not (momentum_ok and vr5 >= self.breakout_volume_ratio_min):
                exhaustion.append(f'resistance only {resistance_gap:.2f}ATR away')
        if side == 'SELL' and lo > 0 and price > lo:
            support_gap = (price - lo) / atr
            if support_gap <= 0.18 and not (momentum_ok and vr5 >= self.breakout_volume_ratio_min):
                exhaustion.append(f'support only {support_gap:.2f}ATR away')

        # Direction is blended with live structure so a stale V7 probability cannot
        # veto a genuine continuation, while a weak live market cannot inherit an old score.
        live_direction = direction
        if live_trend: live_direction += 7.0
        if higher_trend: live_direction += 5.0
        if momentum_ok: live_direction += 6.0
        if not one_higher: live_direction -= 3.0
        live_direction = max(0.0, min(100.0, live_direction))

        live_final = (0.42 * live_direction + 0.28 * live_timing + 0.20 * live_agreement + 0.10 * (100.0 - adverse))
        if acceleration_ok: live_final += 2.0
        live_final = max(0.0, min(100.0, live_final))

        # Three execution tiers.
        strong = (
            live_direction >= self.v89_strong_direction and
            live_final >= self.v89_strong_final and
            live_trend and momentum_ok and strength_ok and
            adverse <= self.v89_max_adverse and
            dist_atr <= self.v89_hard_chase_atr and
            not exhaustion
        )
        continuation = (
            live_direction >= self.v89_min_direction and
            live_final >= self.v89_continuation_min_score and
            live_trend and momentum_ok and
            live_agreement >= self.v89_min_agreement and
            adverse <= self.v89_max_adverse and
            dist_atr <= self.v89_max_chase_atr and
            not exhaustion
        )

        # If the original candidate has a good live direction but no current
        # momentum tick, remain WATCH rather than entering a stalled market.
        early = (
            live_direction >= self.v89_min_direction + 2 and
            live_final >= self.v89_min_final + 2 and
            live_trend and strength_ok and
            (momentum_ok or acceleration_ok) and
            adverse <= self.v89_max_adverse and
            dist_atr <= self.v89_max_chase_atr and
            not exhaustion
        )

        action = 'ENTER_NOW' if strong else 'CONTINUATION' if continuation else 'EARLY_ENTRY' if early else 'WAIT'
        blockers = []
        if live_direction < self.v89_min_direction: blockers.append(f'V8.9 live direction {live_direction:.0f}<minimum {self.v89_min_direction:.0f}')
        if live_final < self.v89_min_final: blockers.append(f'V8.9 live final {live_final:.0f}<minimum {self.v89_min_final:.0f}')
        if not live_trend: blockers.append('V8.9 live 1m/5m trend not aligned')
        if not momentum_ok and not acceleration_ok: blockers.append('V8.9 live momentum not aligned')
        if not strength_ok: blockers.append(f'V8.9 trend strength weak (ADX {adx5:.1f})')
        if adverse > self.v89_max_adverse: blockers.append(f'V8.9 adverse risk {adverse:.0f}>{self.v89_max_adverse:.0f}')
        if dist_atr > self.v89_hard_chase_atr: blockers.append(f'V8.9 hard extension {dist_atr:.2f}ATR')
        blockers.extend(exhaustion)
        # Only retain genuine blockers; obsolete timing/pullback/limit messages are discarded.
        candidate['blockers'] = list(dict.fromkeys(blockers))
        candidate['score'] = int(round(live_final))
        candidate['quality'] = int(round(live_final))
        candidate['rr'] = rr
        candidate['expected_net'] = expected_net
        candidate['direction'] = int(round(live_direction))
        candidate['direction_confidence'] = int(round(live_direction))
        candidate['market_entry_action'] = action
        candidate['v89'] = {
            'version': '8.9', 'action': action, 'live_direction': live_direction,
            'live_timing': live_timing, 'live_agreement': live_agreement,
            'live_final': live_final, 'adverse_risk': adverse, 'dist_atr': dist_atr,
            'momentum_ok': momentum_ok, 'acceleration_ok': acceleration_ok,
            'live_trend_ok': live_trend, 'higher_trend_ok': higher_trend,
            'one_higher_ok': one_higher, 'exhaustion': exhaustion,
            'rr': rr, 'expected_net': expected_net,
            'market_entry': True, 'no_limit': True
        }
        if action in ('ENTER_NOW', 'CONTINUATION', 'EARLY_ENTRY'):
            candidate['reason'] = ((candidate.get('reason', '') +
                f'; V8.9 {action}: current-price opportunity confirmed; MARKET execution').strip('; '))
        else:
            candidate['reason'] = '; '.join(candidate['blockers'][:5]) or 'V8.9 opportunity not executable yet'
        return candidate

    def generate_v88_live_directional_candidate(self, symbol: str, route: dict) -> Optional[dict]:
        """Create a directional market candidate when the older closed-candle signal
        says HOLD but the live multi-timeframe structure is already moving clearly.

        This is the key V8.8 fix for the 'analysis tool' problem: a good live move
        must not be ignored simply because the previous closed-candle trigger was not
        present at the exact scan moment.
        """
        if not getattr(self, 'v88_live_fallback_enabled', True):
            return None
        f1 = self.market_features(symbol, '1m')
        f5 = self.market_features(symbol, '5m')
        f15 = self.market_features(symbol, '15m')
        if not f1 or not f5 or not f15:
            return None

        def side_score(side):
            want = 1 if side == 'BUY' else -1
            score = 0.0
            if f15.get('trend') == want: score += 34
            if f5.get('trend') == want: score += 30
            if f1.get('trend') == want: score += 22
            mom = safe_float(f5.get('momentum'))
            mom1 = safe_float(f1.get('momentum'))
            slope = safe_float(f5.get('ema_slope'))
            if (mom > self.v88_min_momentum and side == 'BUY') or (mom < -self.v88_min_momentum and side == 'SELL'): score += 7
            if (mom1 > self.v88_min_momentum/2 and side == 'BUY') or (mom1 < -self.v88_min_momentum/2 and side == 'SELL'): score += 4
            if (slope > 0 and side == 'BUY') or (slope < 0 and side == 'SELL'): score += 5
            return min(100, int(round(score)))

        long_score = side_score('BUY')
        short_score = side_score('SELL')
        if max(long_score, short_score) < int(self.v88_min_direction):
            return None
        side = 'BUY' if long_score > short_score else 'SELL'
        direction = max(long_score, short_score)
        if abs(long_score - short_score) < 8:
            return None

        f = f5
        price = safe_float(f.get('live_price', f.get('price')))
        atr = max(safe_float(f.get('atr')), 1e-12)
        dist = abs(price-safe_float(f.get('ema20',price))) / atr if price > 0 else 99
        mom = safe_float(f.get('momentum'))
        live_ok = (f5.get('trend') == (1 if side=='BUY' else -1) and
                   f1.get('trend') == (1 if side=='BUY' else -1))
        higher_ok = f15.get('trend') == (1 if side=='BUY' else -1)
        mom_ok = mom > self.v88_min_momentum if side=='BUY' else mom < -self.v88_min_momentum
        slope_ok = safe_float(f.get('ema_slope')) > 0 if side=='BUY' else safe_float(f.get('ema_slope')) < 0
        adx_ok = safe_float(f.get('adx')) >= self.v88_min_5m_adx
        if not (live_ok and higher_ok and mom_ok and (adx_ok or slope_ok) and dist <= self.v88_max_hard_chase_atr):
            return None

        # Market-price forward target model. This avoids a zero expected-net result
        # when price has already moved beyond the old 20-candle high/low.
        target_atr = self.trend_target_atr if side in ('BUY','SELL') else 1.0
        stop_atr = self.trend_stop_atr
        reward = target_atr * atr
        risk = max(stop_atr * atr, 0.50 * atr)
        rr = reward / max(risk, 1e-12)
        notional = self.fixed_notional
        expected_net = (reward/max(price,1e-12))*notional - (2*self.estimated_commission_rate*notional*self.fee_slippage_buffer)

        timing = min(100, int(60 + (10 if mom_ok else 0) + (8 if slope_ok else 0) + (7 if f1.get('trend') == f5.get('trend') else 0)))
        agreement = min(100, int(55 + (18 if live_ok else 0) + (17 if higher_ok else 0) + (10 if slope_ok else 0)))
        adverse = max(5, int(45 - (12 if live_ok else 0) - (8 if higher_ok else 0) + (10 if dist > self.v88_max_chase_atr else 0)))
        final = int(round(0.40*direction + 0.30*timing + 0.20*agreement + 0.10*(100-adverse)))
        quality = max(0, min(100, final))
        reason = 'live continuation detected; current-price entry candidate; no pullback required'
        return {
            'strategy':'trend', 'side':side, 'score':quality, 'direction':direction,
            'quality':quality, 'rr':rr, 'expected_net':expected_net,
            'notional_mult':1.0, 'loss_budget':None, 'reason':reason,
            'blockers':[], 'candle_time':f5.get('candle_time',0), 'symbol':symbol,
            'v88_fallback':True,
            'v7': {
                'action':'ENTER', 'direction_probability':float(direction),
                'entry_timing':float(timing), 'market_agreement':float(agreement),
                'adverse_risk':float(adverse), 'final_score':float(final), 'blockers':[],
                'reason':'V8.8 live directional fallback'
            }
        }

    def strategy_router(self,symbol: str)->dict:
        """Evaluate all four engines and rank the best net-quality opportunity."""
        result={"regime":"unknown","candidates":[],"best":None,"long":0,"short":0,"bias":"NEUTRAL","price":0.0}
        df5=self.get_klines(symbol,"5m",80)
        if df5 is None or len(df5)<50: return result
        regime=self.detect_regime(df5.iloc[:-1]); result["regime"]=regime
        f5=self.market_features(symbol,"5m")
        if not f5: return result
        result["price"]=safe_float(f5.get("live_price")) or safe_float(f5.get("price"))
        ts,tc,tf=self.generate_signal(symbol); result["long"]=int(tf.get("long_score",0)); result["short"]=int(tf.get("short_score",0))
        result['trend_features'] = tf
        # V8.8: if the closed-candle model says HOLD but live structure is clearly moving,
        # create a current-price directional candidate instead of waiting for a pullback.
        live_fallback = None
        if ts not in ("BUY","SELL"):
            live_fallback = self.generate_v88_live_directional_candidate(symbol, result)
            if live_fallback:
                result['candidates'].append(live_fallback)
                result['long'] = max(result['long'], live_fallback['direction'] if live_fallback['side']=='BUY' else 0)
                result['short'] = max(result['short'], live_fallback['direction'] if live_fallback['side']=='SELL' else 0)
        if result["long"]>result["short"]+self.router_min_margin: result["bias"]="LONG"
        elif result["short"]>result["long"]+self.router_min_margin: result["bias"]="SHORT"
        if ts in ("BUY","SELL"):
            side=ts; q,comp=self.compute_entry_quality(symbol,side,{**tf,"regime":regime}); rr=safe_float(comp.get("risk_reward")); direction=int(tf.get("long_score" if side=="BUY" else "short_score",0)); blockers=list(comp.get("blockers",[]))
            if not tf.get("trigger"): blockers.append("closed-candle trigger missing")
            # V8.8: structural R:R remains visible, but directional entries use a
            # forward ATR risk/reward estimate so a moving market is not rejected
            # simply because the old range has already been crossed.
            if rr <= 0 or rr < self.v88_min_rr:
                atr_now=max(safe_float(tf.get('5m',{}).get('atr')),1e-12)
                rr=max(rr, self.trend_target_atr/max(self.trend_stop_atr,0.50)) if atr_now>0 else rr
            if rr<self.v88_min_rr: blockers.append(f"R:R {rr:.2f} below V8.8 minimum {self.v88_min_rr:.2f}")
            # Approximate net opportunity using the same structural risk model used by
            # compute_entry_quality. This makes the dashboard economically meaningful
            # instead of reporting $0.00 for every trend candidate.
            expected_net=0.0
            try:
                f5x=tf.get('5m',{}); dfx=f5x.get('df'); atrx=max(safe_float(f5x.get('atr')),1e-12); px=safe_float(f5x.get('price'))
                if dfx is not None and len(dfx)>=20 and px>0:
                    rh=float(dfx['high'].iloc[-20:].max()); rl=float(dfx['low'].iloc[-20:].min())
                    # V8.8 forward opportunity model: do not make expected net zero
                    # merely because price has already crossed the old 20-candle high/low.
                    riskx=max(self.trend_stop_atr*atrx,0.50*atrx)
                    rewardx=max(self.trend_target_atr*atrx,0.80*atrx)
                    notionalx=self.fixed_notional
                    expected_net=(rewardx/max(px,1e-12))*notionalx-(2*self.estimated_commission_rate*notionalx*self.fee_slippage_buffer)
            except Exception:
                expected_net=0.0
            score=int(q)-(0 if "trending" in regime else 8)
            btc=self.cross_symbol_context(symbol)
            if (side=="BUY" and btc["reason"]=="BTC bearish impulse") or (side=="SELL" and btc["reason"]=="BTC bullish impulse"):
                score-=btc["penalty"]; blockers.append(btc["reason"])
            result["candidates"].append({"strategy":"trend","side":side,"score":max(0,min(100,score)),"direction":direction,"quality":q,"rr":rr,"expected_net":expected_net,"notional_mult":1.0,"loss_budget":None,"reason":"; ".join(blockers) if blockers else tf.get("reason","trend setup"),"blockers":blockers,"candle_time":tf.get("5m",{}).get("candle_time",0)})
            result['candidates'][-1]['symbol']=symbol; result['trend_features']=tf; self.v7_gate_candidate(result['candidates'][-1], result)
        if self.ranging_enabled:
            rq=self._range_quality(symbol); rs,rc,rf=self.generate_ranging_signal(symbol)
            if rs in ("BUY","SELL") and rq.get("quality",0)>=self.range_quality_min:
                side=rs; price=rf["price"]; atr=rf["atr"]; mid=(rf["range_high"]+rf["range_low"])/2
                if side=="BUY": target=min(mid,price+self.range_target_atr*atr); stop=rf["range_low"]-self.range_stop_atr*atr; reward=max(target-price,0); risk=max(price-stop,.5*atr)
                else: target=max(mid,price-self.range_target_atr*atr); stop=rf["range_high"]+self.range_stop_atr*atr; reward=max(price-target,0); risk=max(stop-price,.5*atr)
                rr=reward/max(risk,1e-12); mult=self.ranging_notional_multiplier; notional=self.fixed_notional*mult; expected=(reward/max(price,1e-12))*notional-(2*self.estimated_commission_rate*notional*self.fee_slippage_buffer)
                blockers=[]
                if rr<self.min_risk_reward: blockers.append(f"R:R {rr:.2f} below minimum")
                if expected<self.min_expected_net_profit: blockers.append(f"expected net ${expected:.2f} below minimum")
                score=int(round(.55*rc+.45*rq["quality"]))-(0 if "ranging" in regime else 6)
                result["candidates"].append({"strategy":"range","side":side,"score":max(0,min(100,score)),"direction":int(rf.get("long_score" if side=="BUY" else "short_score",0)),"quality":score,"rr":rr,"expected_net":expected,"notional_mult":mult,"loss_budget":self.ranging_loss_budget,"reason":"; ".join(blockers) if blockers else rf.get("reason","range reversal"),"blockers":blockers,"candle_time":f5.get("candle_time",0)})
        if self.transition_enabled:
            xs,xc,xf=self.generate_transition_signal(symbol)
            if xs in ("BUY","SELL"):
                blockers=[]
                if not xf.get("trigger"): blockers.append("breakout candle confirmation missing")
                if xf.get("volume_ratio",0)<self.breakout_volume_ratio_min: blockers.append("breakout volume weak")
                score=int(xc)-(0 if "transition" in regime else 5)
                px=safe_float(f5.get('live_price',f5.get('price'))); atrx=max(safe_float(f5.get('atr')),1e-12)
                rr=self.transition_target_atr/max(self.transition_stop_atr,0.50)
                notional=self.fixed_notional*self.transition_notional_multiplier
                expected=(self.transition_target_atr*atrx/max(px,1e-12))*notional-(2*self.estimated_commission_rate*notional*self.fee_slippage_buffer)
                if rr<self.v88_min_rr: blockers.append(f"R:R {rr:.2f} below V8.8 minimum")
                if expected<self.v88_expected_net_floor: blockers.append(f"expected net ${expected:.2f} below V8.8 minimum")
                result["candidates"].append({"strategy":"transition","side":xs,"score":max(0,min(100,score)),"direction":int(xf.get("long_score" if xs=="BUY" else "short_score",0)),"quality":score,"rr":rr,"expected_net":expected,"notional_mult":self.transition_notional_multiplier,"loss_budget":self.transition_loss_budget,"reason":"; ".join(blockers) if blockers else xf.get("reason","breakout"),"blockers":blockers,"candle_time":f5.get("candle_time",0)})
        # V7 Smart Boundary candidate is evaluated separately. It is intentionally
        # not added as a market-entry candidate: it may create an exchange LIMIT order
        # only after activation-zone proximity and immediate revalidation.
        # V8.8: Smart Boundary is retained only as historical code; it cannot generate entry orders.
        result['smart_boundary'] = {}

        if self.micro_enabled:
            ms,mc,mf=self.generate_micro_signal(symbol)
            if ms in ("BUY","SELL"):
                blockers=[]; rr=safe_float(mf.get("rr")); expected=safe_float(mf.get("expected_net"))
                if rr<self.micro_min_rr: blockers.append(f"R:R {rr:.2f} below minimum")
                if expected<self.min_expected_net_profit: blockers.append(f"expected net ${expected:.2f} below minimum")
                score=int(mc)-(0 if "ranging" in regime else 3)
                result["candidates"].append({"strategy":"micro","side":ms,"score":max(0,min(100,score)),"direction":int(mf.get("long_score" if ms=="BUY" else "short_score",0)),"quality":score,"rr":rr,"expected_net":expected,"notional_mult":self.micro_notional_multiplier,"loss_budget":self.micro_loss_budget,"reason":"; ".join(blockers) if blockers else mf.get("reason","micro reversal"),"blockers":blockers,"candle_time":mf.get("candle_time",0)})
        # V8.8: V7 gate applies to EVERY market-entry strategy; all automated entries are MARKET-only.
        for cand in result['candidates']:
            cand['symbol'] = symbol
            if 'v7' not in cand:
                self.v7_gate_candidate(cand, result)
            self._v85_market_entry_policy(cand, result)
            self._v89_market_entry_policy(cand, result)

        result["candidates"].sort(key=lambda x:(x["score"],x.get("expected_net",0),self.strategy_priority.get(x["strategy"],0)),reverse=True)
        # V7 intelligence summary is exposed to the dashboard for the strongest candidate.
        if result["candidates"]:
            top_for_intel=result["candidates"][0]
            result['v7']=top_for_intel.get('v7',{})
        eligible=[]
        for c in result["candidates"]:
            if c.get("blockers") or c.get("score",0) < self.router_min_score:
                continue
            action=c.get("market_entry_action")
            if c.get("strategy") in ("trend","transition") and action not in ("ENTER_NOW","CONTINUATION"):
                continue
            eligible.append(c)
        result["best"]=eligible[0] if eligible else None
        return result

    def compute_entry_quality(self, symbol: str, side: str, features: dict) -> Tuple[int, dict]:
        f5, f15, f1 = features.get("5m",{}), features.get("15m",{}), features.get("1m",{})
        direction = safe_float(features.get("long_score" if side=="BUY" else "short_score", 0))
        trend = 0
        if f15.get("trend") == (1 if side=="BUY" else -1): trend += 45
        if f5.get("trend") == (1 if side=="BUY" else -1): trend += 35
        if f1.get("trend") == (1 if side=="BUY" else -1): trend += 20
        trend=min(100,trend)
        mom=f5.get("momentum",0); slope=f5.get("ema_slope",0)
        momentum=60
        if side=="BUY":
            momentum = (70 if mom>0.0002 else 35 if mom<-0.0002 else 55) + (20 if slope>0 else 0)
        else:
            momentum = (70 if mom<-0.0002 else 35 if mom>0.0002 else 55) + (20 if slope<0 else 0)
        momentum=min(100,momentum)
        vr=f5.get("volume_ratio",0)
        volume=100 if 1.05<=vr<=self.entry_volume_ratio_max else 80 if 0.95<=vr<1.05 else 50 if vr<0.95 else 40
        atr=max(f5.get("atr",0),1e-12); price=f5.get("price",0); dist=abs(price-f5.get("ema20",price))/atr
        if dist <= self.chase_max_atr: location=95
        elif dist <= self.max_deviation_atr: location=80
        elif dist <= self.hard_max_deviation_atr: location=35
        else: location=0
        pullback=80 if self.pullback_min_atr <= dist <= self.pullback_max_atr else 65 if dist < self.pullback_min_atr else 25
        c=features.get("candle_5m",{})
        candle=85 if c.get("bull" if side=="BUY" else "bear") else 45
        if c.get("range_atr",0)>self.candle_max_atr: candle=20
        regime=100 if "trending" in str(features.get("regime","")) else 80
        weights={"direction":.20,"trend":.20,"momentum":.10,"volume":.10,"location":.20,"pullback":.10,"candle":.10}
        final=int(round(weights["direction"]*min(100,direction)+weights["trend"]*trend+weights["momentum"]*momentum+
                       weights["volume"]*volume+weights["location"]*location+weights["pullback"]*pullback+weights["candle"]*candle))
        blockers=[]
        if dist>=self.hard_max_deviation_atr: blockers.append(f"overextended {dist:.2f}ATR")
        if vr>self.entry_volume_ratio_max: blockers.append(f"abnormal volume {vr:.2f}x")
        if c.get("range_atr",0)>self.candle_max_atr: blockers.append("impulse candle too large")
        if f15.get("trend") != (1 if side=="BUY" else -1): blockers.append("15m trend disagreement")
        if f5.get("trend") != (1 if side=="BUY" else -1): blockers.append("5m trend disagreement")
        # Risk-reward estimate
        df=f5.get("df")
        rr=0.0
        if df is not None and len(df)>=20 and atr>0:
            recent_high=float(df["high"].iloc[-20:].max())
            recent_low=float(df["low"].iloc[-20:].min())
            if side=="BUY":
                risk=max(price-(recent_low-0.20*atr),0.60*atr)
                reward=max((recent_high-price),1.50*atr)
            else:
                risk=max((recent_high+0.20*atr)-price,0.60*atr)
                reward=max((price-recent_low),1.50*atr)
            rr=reward/max(risk,1e-12)
        # V8.8 handles R:R adaptively at the strategy-router level.  Keeping a
        # hard blocker here caused strong trend opportunities to be discarded
        # before the adaptive opportunity policy could evaluate them.
        components={"direction":int(direction),"trend":trend,"momentum":int(momentum),"volume":int(volume),
                    "location":int(location),"pullback":int(pullback),"candle":int(candle),
                    "final":final,"dist_atr":dist,"risk_reward":rr,"blockers":blockers}
        return final, components

    # -------------------------------------------------------------------------
    # EXIT ANALYSIS (enhanced)
    # -------------------------------------------------------------------------
    def exit_analysis(self, position: "Position") -> Tuple[int, str, dict]:
        f1 = self.market_features(position.symbol, "1m")
        f5 = self.market_features(position.symbol, "5m")
        if not f1 or not f5:
            return 0, "MARKET_STATE_UNKNOWN", {}

        score = 0
        reasons = []
        long = position.side == "BUY"

        if long:
            if f5["trend"] == -1:
                score += 30; reasons.append("5m trend reversed bearish")
            elif f5["trend"] == 0:
                score += 10; reasons.append("5m trend lost clarity")
            if f5["price"] < f5["ema20"]:
                score += 15; reasons.append("price below 5m EMA20")
            if f1["trend"] == -1:
                score += 15; reasons.append("1m trend bearish")
            if f1["momentum"] < -0.0003:
                score += 10; reasons.append("1m negative momentum")
            if f5["momentum"] < -0.0002:
                score += 10; reasons.append("5m negative momentum")
            if f5["ema_slope"] < 0:
                score += 10; reasons.append("5m EMA slope negative")
            if f5["rsi"] < 45:
                score += 10; reasons.append("RSI below bullish zone")
            if f5["adx"] < 15:
                score += 5; reasons.append("trend strength weak")
        else:
            if f5["trend"] == 1:
                score += 30; reasons.append("5m trend reversed bullish")
            elif f5["trend"] == 0:
                score += 10; reasons.append("5m trend lost clarity")
            if f5["price"] > f5["ema20"]:
                score += 15; reasons.append("price above 5m EMA20")
            if f1["trend"] == 1:
                score += 15; reasons.append("1m trend bullish")
            if f1["momentum"] > 0.0003:
                score += 10; reasons.append("1m positive momentum")
            if f5["momentum"] > 0.0002:
                score += 10; reasons.append("5m positive momentum")
            if f5["ema_slope"] > 0:
                score += 10; reasons.append("5m EMA slope positive")
            if f5["rsi"] > 55:
                score += 10; reasons.append("RSI above bearish zone")
            if f5["adx"] < 15:
                score += 5; reasons.append("trend strength weak")

        pnl = position.pnl_estimate()
        if pnl > 0:
            if long and f1["price"] < f1["ema20"] and f5["price"] < f5["ema20"]:
                score += 10; reasons.append("profitable position lost both EMA20 structures")
            if not long and f1["price"] > f1["ema20"] and f5["price"] > f5["ema20"]:
                score += 10; reasons.append("profitable position lost both EMA20 structures")

        score = min(100, int(score))
        reason = "; ".join(reasons[:4]) if reasons else "No confirmed reversal"
        save_feature(position.symbol, "exit", f5, self.db_path, "HOLD", 0, score, reason)
        return score, reason, {"1m": f1, "5m": f5}

    # -------------------------------------------------------------------------
    # DIRECTION CONFIDENCE
    # -------------------------------------------------------------------------
    def current_direction_confidence(self, position: "Position") -> int:
        try:
            _, _, features = self.generate_signal(position.symbol)
            key = "long_score" if position.side == "BUY" else "short_score"
            return int(max(0, min(100, safe_float(features.get(key, 0)))))
        except Exception as exc:
            logger.warning("Direction confidence unavailable %s %s", position.symbol, exc)
            return 0

    def _v9_scaled_profit_threshold(self, base: float, notional: float, power: float, cap: float) -> float:
        ref = max(self.v9_reference_notional, 1e-9)
        ratio = max(0.25, safe_float(notional, ref) / ref)
        value = base * (ratio ** power)
        return min(cap, max(base, value))

    def _v127_is_smart_brain_position(self, position: "Position") -> bool:
        strategy = str(getattr(position, 'strategy', getattr(position, '_strategy', ''))).lower()
        regime = str(getattr(position, 'regime', '')).upper()
        return strategy == 'v13_smart_brain' or strategy == 'v13_direction_first' or regime.startswith('V13')

    def _v127_activation_threshold(self, position: "Position") -> float:
        """Choose the early NET-PNL activation from the entry's recorded quality."""
        if not self.v127_early_profit_protection or not self._v127_is_smart_brain_position(position):
            return float('inf')
        quality = safe_float(getattr(position, 'entry_quality', getattr(position, '_entry_quality', 0)))
        direction = safe_float(getattr(position, 'direction_confidence', getattr(position, '_direction_confidence', 0)))
        if quality >= self.v127_strong_entry_score and direction >= self.v127_strong_direction_confidence:
            return max(0.05, float(self.v127_activation_strong))
        if quality >= self.v127_good_entry_score and direction >= self.v127_good_direction_confidence:
            return max(0.05, float(self.v127_activation_good))
        return max(0.05, float(self.v127_activation_standard))

    def _v127_execution_buffer(self, peak_net_pnl: float) -> float:
        """Small early execution cushion; grows slowly as the protected profit grows."""
        peak = max(0.0, safe_float(peak_net_pnl))
        if peak < 1.0:
            return max(0.03, min(float(self.v127_max_execution_buffer), float(self.v127_early_execution_buffer)))
        return max(0.05, min(float(self.v127_max_execution_buffer), 0.05 + 0.03 * peak))

    def _v127_trailing_retention(self, peak_net_pnl: float) -> float:
        """Early winner retention: protect a small winner without choking normal noise."""
        peak = max(0.0, safe_float(peak_net_pnl))
        if peak < 0.75:
            return max(0.65, min(0.80, float(self.v127_initial_retention)))
        if peak < 1.50:
            return 0.75
        if peak < 3.00:
            return 0.82
        return self._v126_adaptive_trailing_retention(peak)

    def profit_lock_activation_threshold(self, position: "Position") -> float:
        scaled = self._v9_scaled_profit_threshold(
            self.profit_protection_activation, position.notional,
            self.v9_activation_scale_power, self.v9_activation_max
        )
        if self._v127_is_smart_brain_position(position):
            early = self._v127_activation_threshold(position)
            return min(float(scaled), float(early)) if self.v127_early_profit_protection else float(scaled)
        if (getattr(position, 'strategy', '') == 'v12_predictive' or
                str(getattr(position, 'regime', '')).upper().startswith('V12')):
            return max(float(self.v126_profit_lock_min_activation), float(scaled))
        return scaled

    def scaled_min_lock_floor(self, position: "Position") -> float:
        return min(
            self.v9_min_floor_max,
            max(
                self.profit_min_lock_floor,
                self._v9_scaled_profit_threshold(
                    self.profit_min_lock_floor, position.notional,
                    self.v9_min_floor_scale_power, self.v9_min_floor_max
                )
            )
        )

    def profit_giveback_ratio(self, exit_score: int, direction_confidence: int, peak_net_pnl: float) -> float:
        # V9 tightens the permitted give-back as the trade proves itself.
        # Market weakness can tighten the floor further, but never loosen it.
        if not self.v9_peak_tiers:
            base = self.profit_giveback_strong
        elif peak_net_pnl < 1.0:
            base = self.profit_giveback_strong
        elif peak_net_pnl < 2.0:
            base = self.v9_peak_1_giveback
        elif peak_net_pnl < 3.0:
            base = self.v9_peak_2_giveback
        elif peak_net_pnl < 5.0:
            base = self.v9_peak_3_giveback
        elif peak_net_pnl < 7.5:
            base = self.v9_peak_5_giveback
        else:
            base = self.v9_peak_5plus_giveback

        if exit_score >= 50 or direction_confidence <= 40:
            adjustment = self.v9_defensive_adjustment
        elif exit_score >= 30 or direction_confidence <= 60:
            adjustment = self.v9_normal_adjustment
        else:
            adjustment = self.v9_strong_adjustment

        giveback = max(0.04, min(0.30, base - adjustment))
        return giveback

    def _v125_client_id(self, kind: str, p: "Position") -> str:
        # <=36 chars for Binance clientOrderId.
        return f"QT125{kind}_{p.symbol}_{p.position_side}"[:36]

    def _v125_net_target_price(self, p: "Position", target_net_pnl: float) -> float:
        """Convert a desired NET PNL boundary into an approximate MARK/STOP price.
        Commission is included algebraically so the boundary is not based on gross PNL.
        """
        q = max(float(p.quantity), 1e-12)
        c = max(float(self.estimated_commission_rate), 0.0)
        g = float(target_net_pnl) / q
        e = float(p.entry_price)
        if p.side == "BUY":
            stop = (g + e * (1.0 + c)) / max(1.0 - c, 1e-12)
        else:
            stop = (e * (1.0 - c) - g) / max(1.0 + c, 1e-12)
        return self.round_price(p.symbol, stop)

    def _v125_find_protection_orders(self, p: "Position") -> Dict[str, dict]:
        if self.mode == "PAPER":
            return {}
        orders = self.get_open_orders(p.symbol)
        if orders is None:
            return {}
        out = {}
        prefix = f"QT125"
        for o in orders:
            cid = str(o.get("clientOrderId", ""))
            if not cid.startswith(prefix):
                continue
            if str(o.get("positionSide", "")).upper() != p.position_side:
                continue
            if str(o.get("status", "NEW")).upper() not in ("NEW", "PARTIALLY_FILLED"):
                continue
            if "PF" in cid:
                out["profit"] = o
            elif "SL" in cid:
                out["loss"] = o
        return out

    def _v125_cancel_kind(self, p: "Position", kind: str) -> None:
        if self.mode == "PAPER":
            return
        orders = self._v125_find_protection_orders(p)
        o = orders.get("profit" if kind == "PF" else "loss")
        if o and o.get("orderId"):
            self.cancel_order(p.symbol, str(o["orderId"]), f"V12.5 refresh {kind}")

    def _v125_place_stop(self, p: "Position", kind: str, target_net_pnl: float) -> bool:
        if self.mode == "PAPER" or not self.state_known:
            return True
        stop_price = self._v125_net_target_price(p, target_net_pnl)
        if stop_price <= 0:
            return False
        side = "SELL" if p.side == "BUY" else "BUY"
        cid = self._v125_client_id(kind, p)
        try:
            self.throttle_api_call()
            order = self.client.futures_create_order(
                symbol=p.symbol,
                side=side,
                positionSide=p.position_side,
                type="STOP_MARKET",
                stopPrice=stop_price,
                closePosition=True,
                workingType="MARK_PRICE",
                priceProtect=True,
                newClientOrderId=cid,
            )
            oid = str(order.get("orderId", ""))
            self.v125_protection_orders[(p.symbol, p.side)] = self.v125_protection_orders.get((p.symbol, p.side), {})
            self.v125_protection_orders[(p.symbol, p.side)][kind] = oid
            logger.info("V12.5 %s STOP armed %s %s target_net=%+.2f stop=%s order=%s", kind, p.symbol, p.position_side, target_net_pnl, stop_price, oid)
            return True
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.error("V12.5 %s STOP failed %s %s: %s", kind, p.symbol, p.position_side, exc)
            return False

    def _v125_ensure_loss_stop(self, p: "Position") -> None:
        if self.mode == "PAPER":
            return
        now = time.time()
        key = (p.symbol, p.side)
        if now - self.v125_last_protection_refresh.get(key, 0.0) < self.v125_protection_refresh_seconds:
            return
        local = self.v125_protection_orders.get(key, {})
        orders = self._v125_find_protection_orders(p)
        if "loss" not in local and "loss" not in orders:
            self._v125_place_stop(p, "SL", -abs(self.v125_hard_loss_trigger))
        self.v125_last_protection_refresh[key] = now

    def _v125_ensure_profit_stop(self, p: "Position") -> None:
        if self.mode == "PAPER" or not p.profit_lock_active or p.profit_floor <= 0:
            return
        # Smart Brain early protection is initially a local soft lock. Do not place
        # a one-touch exchange STOP_MARKET at a tiny $0.20-$0.40 floor, because that
        # would defeat the two-observation anti-whipsaw logic. Binance protection
        # becomes active after the winner has reached a more material peak.
        if self._v127_is_smart_brain_position(p) and safe_float(getattr(p, 'peak_net_pnl', 0.0)) < float(self.v127_exchange_stop_min_peak):
            return
        key = (p.symbol, p.side)
        key_local = (p.symbol, p.side)
        local = self.v125_protection_orders.get(key_local, {})
        orders = self._v125_find_protection_orders(p)
        existing = orders.get("profit")
        if existing is None and local.get("profit"):
            # Avoid duplicate STOP_MARKET creation while the open-order cache is still fresh.
            return
        # Exchange stop is the primary profit boundary. It is intentionally set a little
        # above the mathematical floor to compensate for mark->execution slippage.
        target = float(p.profit_floor) + max(0.0, float(self.v125_profit_stop_buffer))
        desired_price = self._v125_net_target_price(p, target)
        if existing:
            existing_price = safe_float(existing.get("stopPrice"), 0.0)
            if existing_price > 0 and abs(existing_price - desired_price) <= max(self.symbol_info_cache[p.symbol].get("tick_size", 0.01), 1e-12) * 1.5:
                return
            self.cancel_order(p.symbol, str(existing.get("orderId")), "V12.5 profit floor ratchet")
        self._v125_place_stop(p, "PF", target)

    def _v125_cancel_protection(self, p: "Position") -> None:
        if self.mode == "PAPER":
            return
        for kind in ("PF", "SL"):
            self._v125_cancel_kind(p, kind)
        self.v125_protection_orders.pop((p.symbol, p.side), None)

    def _v126_adaptive_trailing_retention(self, peak_net_pnl: float) -> float:
        """Adaptive winner trailing: more breathing room at small profit, tighter
        retention only after the winner has become materially large. The floor is
        still one-way ratcheted by update_profit_protection()."""
        peak = max(0.0, safe_float(peak_net_pnl))
        if peak < 2.0:
            retention = self.v126_trail_peak_1
        elif peak < 3.0:
            retention = self.v126_trail_peak_2
        elif peak < 5.0:
            retention = self.v126_trail_peak_3
        elif peak < 10.0:
            retention = self.v126_trail_peak_5
        else:
            retention = self.v126_trail_peak_10
        return max(0.70, min(0.97, retention))

    def update_profit_protection(self, position: "Position", net_pnl: float, exit_score: int,
                                 direction_confidence: int) -> Tuple[bool, str]:
        """Profit protection with V12.2 anti-whipsaw behaviour.

        Critical correction: the old V9 lock could close a position because a
        transient mark-price profit fell below its floor, and the market-order
        execution could then fill below zero. V12.2 gives predictive positions
        more room, requires the live estimate to remain positively profitable,
        requires a buffered floor breach, and requires two consecutive breaches.
        Legacy V9 behaviour is preserved for non-V12 positions.
        """
        is_smart = self._v127_is_smart_brain_position(position)
        is_v12 = (is_smart or getattr(position, 'strategy', '') == 'v12_predictive' or
                  str(getattr(position, 'regime', '')).upper().startswith('V12'))
        activation = self.profit_lock_activation_threshold(position)

        if net_pnl >= activation:
            if net_pnl > position.peak_net_pnl:
                position.peak_net_pnl = net_pnl
            if not position.profit_lock_active:
                position.profit_lock_active = True
                logger.info(
                    "%s PROFIT PROTECTION ACTIVATED %s %s peak=$%.4f activation=$%.4f notional=$%.2f",
                    "V12.2" if is_v12 else "V9", position.symbol, position.position_side,
                    position.peak_net_pnl, activation, position.notional
                )

        if not position.profit_lock_active or position.peak_net_pnl <= 0:
            return False, ""

        # Conservative execution cushion: the close decision must occur above
        # the displayed floor so commission/slippage cannot turn a protected
        # winner into a negative realized result. This is a decision buffer,
        # not a profit cap; stronger favourable moves remain open.
        execution_buffer = (self._v127_execution_buffer(position.peak_net_pnl)
                            if is_smart else
                            max(0.05, min(0.35, 0.20 * max(1.0, abs(position.profit_floor)))))

        if is_v12:
            # V12.4: one-way ratchet. Peak and floor never move downward.
            peak = max(0.0, safe_float(position.peak_net_pnl, 0.0))
            if net_pnl > peak:
                peak = net_pnl
                position.peak_net_pnl = peak
                position.profit_lock_last_peak_update = time.time()
            if net_pnl >= activation and not position.profit_lock_active:
                position.profit_lock_active = True
                position.profit_lock_armed_at = time.time()
                position.peak_net_pnl = max(position.peak_net_pnl, net_pnl)
                peak = position.peak_net_pnl
                logger.info("V12.4 PROFIT LOCK ARMED %s %s peak=$%.4f activation=$%.4f", position.symbol, position.position_side, peak, activation)
            if not position.profit_lock_active or peak <= 0:
                return False, ""
            # V12.6 uses a stable protected-winner retention target. The older
            # V12.5 tier values remain available for compatibility, but the new
            # protected-winner state is intentionally governed by one retention
            # ratio so the floor cannot loosen as profit grows.
            retention = (self._v127_trailing_retention(peak) if is_smart
                         else self._v126_adaptive_trailing_retention(peak))
            candidate_floor = peak * retention
            giveback = 1.0 - retention
            old_floor = safe_float(position.profit_floor, 0.0)
            minimum_floor = (float(self.v127_initial_floor_min) if is_smart
                             else float(self.v126_profit_floor_min))
            # One-way ratchet: once a profit level is protected, it never moves down.
            position.profit_floor = max(old_floor, candidate_floor, minimum_floor)
            floor = position.profit_floor
            key = (position.symbol, position.side)
            # Trigger slightly above the floor to allow for execution costs.
            protected_trigger = floor + execution_buffer
            normal_breach = net_pnl <= protected_trigger
            jump_through = net_pnl <= floor - max(0.75, 0.50 * max(peak, 1.0))
            if jump_through:
                position.profit_lock_breach_count = 0
                self.v12_profit_breach_count[key] = 0
                return True, (f"V12.4 PROFIT LOCK EMERGENCY BREACH: peak ${peak:+.2f}, current ${net_pnl:+.2f}, floor ${floor:+.2f}, give-back {giveback:.0%}, retention {floor/max(peak,1e-9):.0%}; market jumped through floor")
            if normal_breach:
                position.profit_lock_breach_count += 1
                self.v12_profit_breach_count[key] = position.profit_lock_breach_count
            else:
                position.profit_lock_breach_count = 0
                self.v12_profit_breach_count[key] = 0
            confirmations = (self.v127_profit_confirmations if is_smart
                            else self.v12_profit_confirmations)
            if position.profit_lock_breach_count >= max(1, confirmations):
                position.profit_lock_breach_count = 0
                self.v12_profit_breach_count[key] = 0
                return True, (f"V12.4 PROFIT LOCK: peak ${peak:+.2f}, current ${net_pnl:+.2f}, floor ${floor:+.2f}, give-back {giveback:.0%}, retention {floor/max(peak,1e-9):.0%}, confirmed {self.v12_profit_confirmations}x")
            return False, ""

        # Legacy V9 protection for older strategies.
        giveback = self.profit_giveback_ratio(exit_score, direction_confidence, position.peak_net_pnl)
        dynamic_floor = position.peak_net_pnl * (1.0 - giveback)
        minimum_floor = self.scaled_min_lock_floor(position)
        position.profit_floor = max(position.profit_floor, minimum_floor, dynamic_floor)

        protected_trigger = position.profit_floor + execution_buffer
        if direction_confidence <= self.profit_direction_confidence_floor and net_pnl > protected_trigger:
            return True, (f"V9 PROFIT PROTECTION: direction confidence {direction_confidence}/100 "
                          f"<= {self.profit_direction_confidence_floor}/100; net profit ${net_pnl:+.2f}; "
                          f"floor ${position.profit_floor:+.2f}")
        if exit_score >= self.profit_hard_reversal_score and net_pnl > max(0.0, protected_trigger):
            return True, (f"V9 PROFIT PROTECTION: hard reversal score {exit_score}/100 "
                          f"with net profit ${net_pnl:+.2f}; floor ${position.profit_floor:+.2f}")
        if net_pnl > 0 and net_pnl <= protected_trigger and position.peak_net_pnl >= activation:
            retention = max(0.0, position.profit_floor / max(position.peak_net_pnl, 1e-9))
            return True, (f"V9 PROFIT LOCK: peak ${position.peak_net_pnl:+.2f}, current ${net_pnl:+.2f}, "
                          f"floor ${position.profit_floor:+.2f}, give-back {giveback:.0%}, retention {retention:.0%}")
        return False, ""

    # -------------------------------------------------------------------------
    # LOSS GOVERNOR
    # -------------------------------------------------------------------------
    def check_loss_governor(self, p: Position) -> Tuple[bool, str, str]:
        net_pnl = p.estimated_net_pnl(self.estimated_commission_rate)
        if net_pnl >= -self.loss_warning:
            return False, "", "normal"
        if net_pnl < -self.loss_warning and net_pnl >= -self.loss_protective:
            return False, "", "warning"
        if net_pnl < -self.loss_protective and net_pnl >= -self.loss_emergency:
            return True, f"PROTECTIVE LOSS - net loss ${-net_pnl:.2f} >= ${self.loss_protective:.2f}", "protective"
        if net_pnl < -self.loss_emergency:
            return True, f"EMERGENCY LOSS - net loss ${-net_pnl:.2f} >= ${self.loss_emergency:.2f}", "emergency"
        return False, "", "normal"

    def is_cooldown_active(self, symbol: str, side: str) -> bool:
        key = (symbol, side)
        if key not in self.cooldowns:
            return False
        return time.time() < self.cooldowns[key]

    def set_cooldown(self, symbol: str, side: str, duration_minutes: int = None):
        if duration_minutes is None:
            duration_minutes = self.loss_cooldown_minutes
        key = (symbol, side)
        self.cooldowns[key] = time.time() + duration_minutes * 60
        logger.info(f"COOLDOWN set for {symbol} {side} for {duration_minutes} min")

    def entry_quality_min_for_regime(self, regime: str) -> int:
        r = str(regime).lower()
        if "range" in r: return self.entry_quality_min_range
        if "transition" in r: return self.entry_quality_min_transition
        return self.entry_quality_min_trend

    # -------------------------------------------------------------------------
    # ORDER / FINANCIALS
    # -------------------------------------------------------------------------
    def get_order_fills(self, symbol: str, order_id: Optional[str]) -> List[dict]:
        if self.mode == "PAPER" or not order_id:
            return []
        try:
            self.throttle_api_call()
            return self.client.futures_account_trades(symbol=symbol, orderId=int(order_id), limit=100)
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.warning("Fill lookup failed %s/%s: %s", symbol, order_id, exc)
            return []

    def actual_fill_price(self, symbol: str, order_id: str, fallback: float) -> float:
        if self.mode == "PAPER":
            return fallback
        try:
            self.throttle_api_call()
            order = self.client.futures_get_order(symbol=symbol, orderId=int(order_id))
            avg = safe_float(order.get("avgPrice"))
            if avg > 0:
                return avg
            executed = safe_float(order.get("executedQty"))
            quote = safe_float(order.get("cumQuote"))
            if executed > 0 and quote > 0:
                return quote / executed
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.warning("Actual fill lookup failed %s/%s: %s", symbol, order_id, exc)
        return fallback

    def financials(self, symbol: str, entry_order_id: Optional[str], exit_order_id: Optional[str],
                   entry_time_ms: int) -> dict:
        if self.mode == "PAPER":
            return {"realized_pnl": 0.0, "commission": 0.0, "funding": 0.0, "net_pnl": 0.0}
        realized = commission = funding = 0.0
        try:
            entry_fills = self.get_order_fills(symbol, entry_order_id)
            exit_fills = self.get_order_fills(symbol, exit_order_id)
            for f in entry_fills + exit_fills:
                commission += abs(safe_float(f.get("commission")))
                if f in exit_fills:
                    realized += safe_float(f.get("realizedPnl"))
            self.throttle_api_call()
            income = self.client.futures_income_history(
                symbol=symbol,
                startTime=max(0, entry_time_ms - 5000),
                endTime=int(time.time() * 1000) + 5000,
                limit=1000,
            )
            if not (entry_fills or exit_fills):
                for rec in income:
                    typ = rec.get("incomeType")
                    val = safe_float(rec.get("income"))
                    if typ == "REALIZED_PNL": realized += val
                    elif typ == "COMMISSION": commission += abs(val)
            for rec in income:
                if rec.get("incomeType") == "FUNDING_FEE":
                    funding += safe_float(rec.get("income"))
        except Exception as exc:
            self.handle_rate_limit_error(exc)
            logger.warning("Financial reconciliation failed: %s", exc)
        net = realized - commission + funding
        return {"realized_pnl": realized, "commission": commission, "funding": funding, "net_pnl": net}

    # -------------------------------------------------------------------------
    # STARTUP
    # -------------------------------------------------------------------------
    def startup_checks(self):
        logger.info("Mode=%s symbols=%s notional=$%.2f leverage=%sx max_slots=%s max_notional=$%.2f",
                    self.mode, self.symbols, self.fixed_notional, self.leverage,
                    self.max_open_positions, self.max_total_notional)
        if self.mode == "PAPER":
            return
        self.throttle_api_call()
        try:
            server = self.client.futures_time()
            logger.info("Binance Futures connection OK: %s", server)
        except Exception as e:
            logger.error("Binance connection test failed: %s", e)
            self.send_telegram(f"🚨 Binance connection test FAILED: {e}")
            raise RuntimeError(f"Binance connection test failed: {e}")
        self.throttle_api_call()
        mode_info = self.client.futures_get_position_mode()
        if not bool(mode_info.get("dualSidePosition")):
            raise RuntimeError("Binance Hedge Mode is NOT enabled. Enable Hedge Mode before starting.")
        logger.info("HEDGE MODE CONFIRMED: each symbol has independent LONG and SHORT sides.")
        for symbol in self.symbols:
            try:
                self.throttle_api_call()
                self.client.futures_change_leverage(symbol=symbol, leverage=self.leverage)
            except Exception as exc:
                logger.warning("Leverage setup failed %s: %s", symbol, exc)

    def cancel_legacy_smart_boundary_orders(self):
        """V8.8 safety: eliminate any legacy SDBE LIMIT orders left on Binance."""
        if self.mode == 'PAPER' or not getattr(self, 'v88_disable_limit_entries', True):
            return
        try:
            for symbol in self.symbols:
                orders = self.get_open_orders(symbol)
                if orders is None:
                    continue
                for o in orders:
                    cid = str(o.get('clientOrderId', ''))
                    if not cid.startswith('SDBE_'):
                        continue
                    oid = str(o.get('orderId', ''))
                    if not oid:
                        continue
                    self.cancel_order(symbol, oid, 'V8.8 direct-market-only migration')
                    self.send_telegram(
                        f'🧹 <b>LEGACY LIMIT CANCELLED</b>\n{symbol}\n'
                        f'Order: {oid}\nReason: V8.8 uses direct MARKET entries only.'
                    )
        except Exception as exc:
            logger.warning('Legacy SDBE cleanup failed: %s', exc)
            self.send_telegram_alert('legacy_limit_cleanup', f'Legacy LIMIT cleanup failed: {exc}')

    def send_startup(self):
        balance = self.get_wallet_balance()
        self.send_telegram(
            "🚀 <b>SEALABS X-TRADE</b>\n"
            f"Mode: <b>{self.mode}</b>\n"
            f"Symbols: {', '.join(self.symbols)}\n"
            f"Notional/position: ${self.fixed_notional:,.2f}\n"
            f"Max slots: {self.max_open_positions}\n"
            #f"Max total notional: ${self.max_total_notional:,.2f}\n"
            f"Leverage: {self.leverage}x\n"
            #"TP orders: <b>NONE</b>\n"
            #"SL orders: <b>NONE</b>\n"
            "Entry: <b>Direct MARKET / Automated LIMIT Orders</b>\n"
            "Exit: <b>Adaptive Market Analysis</b>\n"
            f"Emergency circuit breaker: {'ON' if self.emergency_max_loss_percent > 0 else 'OFF'}\n"
            f"Balance: ${balance:,.2f}\n"
            #"Binance = source of truth\n"
            #"Restart recovery = enabled"
        )

    # -------------------------------------------------------------------------
    # RECONCILIATION (enhanced with new columns)
    # -------------------------------------------------------------------------
    def reconcile(self, force=False, reason="PERIODIC") -> bool:
        with self.lock:
            if self.mode == "PAPER":
                self.state_known = True
                return True

            live = self.get_open_positions(force=force)
            if live is None:
                self.state_known = False
                self.last_state_error = "Binance position snapshot unavailable"
                logger.error("RECONCILIATION UNKNOWN: local positions preserved; entries blocked.")
                if not self._unknown_alert_sent:
                    self.send_telegram_alert(
                        "state_unknown",
                        "🚨 Binance state is UNKNOWN. Positions preserved, but new entries are BLOCKED until state recovers."
                    )
                    self._unknown_alert_sent = True
                return False

            if not self.state_known:
                self._unknown_alert_sent = False
                self.send_telegram("✅ Binance state recovered – new entries enabled again.")

            real = {(p["symbol"], p["side"]): p for p in live}

            for symbol in list(self.positions.keys()):
                for side in list(self.positions[symbol].keys()):
                    if (symbol, side) not in real:
                        p = self.positions[symbol][side]
                        self.finalize_external_close(p, "BINANCE CONFIRMED POSITION CLOSED")

            for key, rp in real.items():
                symbol, side = key
                self.positions.setdefault(symbol, {})
                p = self.positions[symbol].get(side)
                if p:
                    p.quantity = rp["quantity"]
                    p.entry_price = rp["entry_price"]
                    p.notional = p.quantity * p.entry_price
                    p.current_price = rp["mark_price"] or p.current_price
                    p.update_extremes()
                    save_open_position(p, self.db_path)
                    continue

                p = load_saved_position(symbol, side, rp, self.db_path)
                if p is None:
                    now_price = rp["mark_price"] or rp["entry_price"]
                    p = Position(
                        symbol=symbol,
                        side=side,
                        entry_time=iso_now(),
                        entry_price=rp["entry_price"],
                        quantity=rp["quantity"],
                        notional=rp["quantity"] * rp["entry_price"],
                        entry_order_id=None,
                        highest_price=now_price,
                        lowest_price=now_price,
                        current_price=now_price,
                        recovered=True,
                        regime="recovered"
                    )
                    p.position_origin = "EXCHANGE_RECOVERED"
                    logger.warning("RECOVERED POSITION WITHOUT LOCAL RECORD: %s %s", symbol, side)
                    self.send_telegram(
                        f"🔄 <b>EXCHANGE RECOVERED</b>\n{symbol} {p.position_side}\n"
                        f"Qty: {p.quantity}\nEntry: ${p.entry_price:,.6f}\n"
                        "No close action was taken. Adaptive management resumed."
                    )
                else:
                    p.recovered = True
                    p.position_origin = "EXCHANGE_RECOVERED"
                    logger.info("Exchange recovered saved position %s %s", symbol, side)

                self.positions[symbol][side] = p
                p.current_price = rp["mark_price"] or p.current_price
                p.update_extremes()
                save_open_position(p, self.db_path)

            self.state_known = True
            self.last_state_error = ""
            self.last_rest_reconcile = time.time()
            if force:
                logger.info("RECONCILIATION OK (%s): %s live hedge sides.", reason, len(real))
            else:
                logger.debug("RECONCILIATION OK (%s): %s live hedge sides.", reason, len(real))
            return True

    def finalize_external_close(self, p: Position, reason: str):
        if p.closed:
            return
        p.closed = True
        exit_price = p.current_price or self.get_mark_price(p.symbol)
        fin = self.financials(p.symbol, p.entry_order_id, None, p.entry_timestamp_ms)
        net = fin["net_pnl"]
        duration = int(p.age_seconds() / 60)
        insert_trade({
            "symbol": p.symbol, "side": p.side, "entry_time": p.entry_time,
            "entry_price": p.entry_price, "entry_quantity": p.quantity,
            "entry_order_id": p.entry_order_id, "exit_time": iso_now(),
            "exit_price": exit_price, "exit_quantity": p.quantity,
            "exit_order_id": None, "gross_pnl": fin["realized_pnl"],
            "commission": fin["commission"], "funding": fin["funding"],
            "net_pnl": net, "duration_minutes": duration,
            "exit_reason": reason, "recovered": p.recovered, "regime": p.regime, "source": p.source,
        }, self.db_path)
        self._v13_record_exit(p.symbol, exit_price, p.side, reason)
        delete_open_position(p.symbol, p.side, self.db_path)
        if p.symbol in self.positions:
            self.positions[p.symbol].pop(p.side, None)
            if not self.positions[p.symbol]:
                del self.positions[p.symbol]
        emoji = "✅" if net > 0 else "❌" if net < 0 else "➖"
        result = "PROFIT" if net > 0 else "LOSS" if net < 0 else "BREAKEVEN"
        self.send_telegram(
            f"{emoji} <b>TRADE CLOSED — {result}</b>\n"
            f"{p.symbol} {p.position_side}\n"
            f"Entry: ${p.entry_price:,.6f}\nExit: ${exit_price:,.6f}\n"
            f"Gross PNL: ${fin['realized_pnl']:+.4f}\nCommission: -${fin['commission']:.4f}\nFunding: ${fin['funding']:+.4f}\n"
            f"<b>NET PNL: ${net:+.4f}</b>\nDuration: {duration} min\n"
            f"Regime: {p.regime.upper()}\nSource: EXTERNAL/BINANCE CLOSE\nReason: {reason}"
        )
        self.dashboard()
        logger.info("Finalized confirmed external close %s %s", p.symbol, p.side)

    # -------------------------------------------------------------------------
    # OPEN POSITION (enhanced with regime, quality, loss budget)
    # -------------------------------------------------------------------------
    def open_position(self, symbol: str, side: str, notional=None, manual=False,
                      regime='unknown', loss_budget=None, entry_quality=0, entry_reason="",
                      direction_confidence=0, strategy="trend") -> bool:
        with self.lock:
            if self.paused and not manual:
                return False
            if not self.state_known or side not in ("BUY","SELL") or symbol not in self.symbols:
                return False
            # V8.8: every automated strategy, including range, executes through this MARKET path.
            if symbol in self.positions and side in self.positions[symbol]:
                return False
            if self.is_cooldown_active(symbol, side):
                return False
            # V12.8 continuation entries use a dedicated expert score. They are
            # intentionally smaller-risk, so they must not be forced through the old
            # legacy trend-quality threshold of 78/85 after the Expert Brain has
            # already approved the live continuation.
            quality_floor = 65 if (not manual and strategy == 'v12_continuation') else self.entry_quality_min_for_regime(regime)
            if not manual and entry_quality < quality_floor:
                logger.info("Entry blocked %s %s: quality %s below required %s", symbol, side, entry_quality, quality_floor)
                return False

            price = self.get_mark_price(symbol, force=True)
            if price <= 0:
                return False

            # Extra checks for trending regime to avoid chasing
            if not manual and regime == "trend":
                f = self.market_features(symbol, "5m")
                if not f:
                    return False
                dist = abs(price - f["ema20"]) / max(f["atr"], 1e-12)
                if dist >= self.hard_max_deviation_atr:
                    return False
                if side == "BUY" and f["trend"] != 1:
                    return False
                if side == "SELL" and f["trend"] != -1:
                    return False

            if self.mode != "PAPER" and not self.reconcile(force=False, reason="PRE-ENTRY-CACHED"):
                return False
            if symbol in self.positions and side in self.positions[symbol]:
                return False

            if not manual:
                if self.daily_trade_count(symbol) >= self.max_trades_per_symbol:
                    return False
                last = self.last_trade_time.get((symbol, side), 0.0)
                if time.time() - last < self.trade_throttle_seconds:
                    return False
                allowed, why = self.risk.new_entries_allowed()
                if not allowed:
                    return False

            if notional is None:
                notional = self.fixed_notional
            if notional < self.min_notional:
                return False
            ok, why = self.risk.exposure_allowed(notional)
            if not ok:
                return False
            if sum(len(v) for v in self.positions.values()) >= self.max_open_positions:
                return False

            qty = self.round_quantity(symbol, notional / price)
            if qty <= 0:
                return False

            try:
                if self.mode == "PAPER":
                    order_id = f"PAPER-{int(time.time()*1000)}"
                    fill = price
                else:
                    self.throttle_api_call()
                    order = self.client.futures_create_order(
                        symbol=symbol, side=side, positionSide="LONG" if side=="BUY" else "SHORT",
                        type="MARKET", quantity=qty, newOrderRespType="RESULT"
                    )
                    order_id = str(order["orderId"])
                    fill = self.actual_fill_price(symbol, order_id, price)
                    if fill <= 0:
                        raise RuntimeError("Actual fill price could not be determined")
            except Exception as exc:
                self.handle_rate_limit_error(exc)
                logger.error("ENTRY FAILED %s %s: %s", symbol, side, exc)
                self.send_telegram(f"❌ <b>ENTRY FAILED</b> {symbol} {side}\n{exc}")
                return False

            now = iso_now()
            p = Position(
                symbol=symbol, side=side, entry_time=now, entry_price=fill,
                quantity=qty, notional=qty*fill, entry_order_id=order_id,
                highest_price=fill, lowest_price=fill, current_price=fill,
                recovered=False, regime=regime, source=("MANUAL" if manual else "BOT")
            )
            p.entry_quality_score = int(entry_quality)
            p.last_direction_confidence = int(direction_confidence if not manual else entry_quality)
            p._strategy = strategy
            p._temp_loss_budget = loss_budget
            self.positions.setdefault(symbol, {})[side] = p
            if strategy in ('v12_predictive', 'v12_continuation'):
                self._v126_apply_thesis(p, {
                    'prediction': direction_confidence,
                    'pre_move_remaining': entry_quality,
                    'expert_score': getattr(p, '_v126_expert_score', entry_quality),
                    'regime': regime,
                    'entry_extension_atr': 0.0,
                    'entry_pressure_atr': 0.0,
                    'rr': 0.0,
                }, source='MARKET_OR_REVERSAL')
            self.last_trade_time[(symbol, side)] = time.time()
            save_open_position(p, self.db_path)
            # V12.5: arm exchange-side loss protection immediately after a successful fill.
            if strategy in ('v12_predictive', 'v12_continuation') and self.mode != 'PAPER':
                self._v125_ensure_loss_stop(p)

            entry_mode = 'MANUAL' if manual else 'V9.2 FAST DIRECT MARKET'
            self.send_telegram(
                f"📈 <b>POSITION OPENED</b>\n{symbol} {p.position_side}\n"
                f"Entry: ${fill:,.6f}\nQty: {qty}\nNotional: ${p.notional:,.2f}\n"
                f"Mode: {entry_mode}\nStrategy: {strategy.upper()}\nRegime: {regime.upper()}\n"
                f"Entry quality: {entry_quality}/100\nDirection confidence: {direction_confidence}/100\n"
                f"Reason: {entry_reason or 'manual'}"
            )
            self.dashboard()
            return True

    # -------------------------------------------------------------------------
    # CLOSE POSITION
    # -------------------------------------------------------------------------
    def close_position(self, p: Position, reason: str) -> bool:
        with self.lock:
            if p.closed:
                return False
            if self.mode != "PAPER" and not self.state_known:
                logger.warning("Close blocked because account state is UNKNOWN: %s %s", p.symbol, p.side)
                return False
            if self._v127_is_smart_brain_position(p) or getattr(p, "_strategy", "") == "v12_predictive" or str(getattr(p, "regime", "")).upper().startswith("V12"):
                self._v125_cancel_protection(p)

            live = [] if self.mode == "PAPER" else self.get_open_positions(force=True)
            if self.mode != "PAPER" and live is None:
                self.state_known = False
                return False
            live_dict = {(x["symbol"], x["side"]): x for x in live}

            if self.mode != "PAPER" and (p.symbol, p.side) not in live_dict:
                self.reconcile(force=True, reason="PRE-CLOSE POSITION ALREADY GONE")
                return False

            quantity = p.quantity if self.mode == "PAPER" else live_dict[(p.symbol, p.side)]["quantity"]
            quantity = self.round_quantity(p.symbol, quantity)
            if quantity <= 0:
                return False

            p.closed = True
            exit_order_id = None
            exit_price = self.get_mark_price(p.symbol, force=True)

            try:
                if self.mode == "PAPER":
                    gross = p.pnl_estimate()
                    commission = (p.entry_price * p.quantity + exit_price * p.quantity) * self.estimated_commission_rate
                    funding = 0.0
                    realized = gross
                    net = gross - commission
                    self.paper_daily_pnl += net
                else:
                    self.throttle_api_call()
                    order = self.client.futures_create_order(
                        symbol=p.symbol,
                        side="SELL" if p.side == "BUY" else "BUY",
                        positionSide=p.position_side,
                        type="MARKET",
                        quantity=quantity,
                        newOrderRespType="RESULT",
                    )
                    exit_order_id = str(order["orderId"])
                    exit_price = self.actual_fill_price(p.symbol, exit_order_id, exit_price)
                    fin = self.financials(p.symbol, p.entry_order_id, exit_order_id, p.entry_timestamp_ms)
                    realized = fin["realized_pnl"]
                    commission = fin["commission"]
                    funding = fin["funding"]
                    net = fin["net_pnl"]
                    gross = realized
            except Exception as exc:
                p.closed = False
                self.handle_rate_limit_error(exc)
                logger.error("CLOSE FAILED %s %s: %s", p.symbol, p.side, exc)
                self.send_telegram(f"🚨 <b>CLOSE FAILED</b> {p.symbol} {p.position_side}\n{exc}\nPosition remains under management.")
                return False

            duration = int(p.age_seconds() / 60)
            insert_trade({
                "symbol": p.symbol, "side": p.side, "entry_time": p.entry_time,
                "entry_price": p.entry_price, "entry_quantity": p.quantity,
                "entry_order_id": p.entry_order_id, "exit_time": iso_now(),
                "exit_price": exit_price, "exit_quantity": quantity,
                "exit_order_id": exit_order_id, "gross_pnl": gross,
                "commission": commission, "funding": funding,
                "net_pnl": net, "duration_minutes": duration,
                "exit_reason": reason, "recovered": p.recovered, "regime": p.regime, "source": p.source,
            }, self.db_path)

            # ---------- SEND TRADE REPORT TO DEVELOPER ----------
            try:
                from reporter import send_trade_report
                trade_data = {
                    'symbol': p.symbol,
                    'side': p.side,
                    'entry_price': p.entry_price,
                    'exit_price': exit_price,
                    'net_pnl': net,
                    'developer_fee': net * 0.01 if net > 0 else 0,
                    'duration': duration,
                    'exit_reason': reason,
                    'regime': p.regime,
                }
                threading.Thread(target=send_trade_report, args=(trade_data,), daemon=True).start()
            except Exception:
                pass

            self._v15_close_trade(p, exit_price, net, reason)
            self._v13_record_exit(p.symbol, exit_price, p.side, reason)
            delete_open_position(p.symbol, p.side, self.db_path)
            self.positions.get(p.symbol, {}).pop(p.side, None)
            if p.symbol in self.positions and not self.positions[p.symbol]:
                del self.positions[p.symbol]

            emoji = "✅" if net > 0 else "❌" if net < 0 else "➖"
            result = "PROFIT" if net > 0 else "LOSS" if net < 0 else "BREAKEVEN"
            self.send_telegram(
                f"{emoji} <b>TRADE CLOSED — {result}</b>\n"
                f"{p.symbol} {p.position_side}\n"
                f"Entry: ${p.entry_price:,.6f}\nExit: ${exit_price:,.6f}\n"
                f"Gross PNL: ${gross:+.4f}\nCommission: -${commission:.4f}\nFunding: ${funding:+.4f}\n"
                f"<b>NET PNL: ${net:+.4f}</b>\n"
                f"Duration: {duration} min\nRegime: {p.regime.upper()}\nReason: {reason}"
            )
            self.dashboard()
            return True

    # -------------------------------------------------------------------------
    # V11 PAIR EXPERIMENT MANAGEMENT
    # -------------------------------------------------------------------------
    def _v11_pair_groups(self):
        groups = {}
        for symbol, sides in self.positions.items():
            for side, p in sides.items():
                pair_id = getattr(p, "_v11_pair_id", "")
                if pair_id:
                    groups.setdefault(pair_id, []).append(p)
        return groups

    def manage_v11_pairs(self) -> set:
        """Manage V11 original-vs-reverse pairs as an experiment.

        Critical rule: a paired leg is NOT allowed to hit the ordinary per-position
        loss budget or adaptive-loss exit while the observation window is active.
        Otherwise the experiment is biased: the first small adverse fluctuation
        simply kills one leg before the market has had time to reveal which side is
        actually stronger.

        Equal long/short legs cannot create net directional profit by themselves;
        they are a measurement device. After the observation window, the leg with
        demonstrated profit/direction becomes the survivor and the other leg is
        released. The survivor then returns to normal management.
        """
        managed = set()
        now = time.time()
        for pair_id, members in self._v11_pair_groups().items():
            if not members:
                continue
            for p in members:
                managed.add((p.symbol, p.side))

            # A single remaining leg is an orphaned experiment leg. Let normal
            # management take over after a short grace period.
            if len(members) < 2:
                p = members[0]
                if not getattr(p, "_v11_survivor", False):
                    p._v11_survivor = True
                    p._v11_orphaned_at = now
                    save_open_position(p, self.db_path)
                # Do not suppress ordinary management forever for an orphan.
                managed.discard((p.symbol, p.side))
                continue

            # One symbol should have exactly two hedge sides in a pair. If a data
            # corruption produces a mixed group, keep the safety behavior conservative.
            if len({p.symbol for p in members}) != 1:
                continue

            symbol = members[0].symbol
            price = self.get_mark_price(symbol, force=True)
            if price <= 0:
                continue
            for p in members:
                p.current_price = price
                p.update_extremes()

            started_values = [self._parse_iso_timestamp(getattr(p, "_v11_observation_started", ""), 0.0) for p in members]
            until_values = [self._parse_iso_timestamp(getattr(p, "_v11_observation_until", ""), 0.0) for p in members]
            started = max(started_values) if any(started_values) else min(p.entry_timestamp_ms / 1000.0 for p in members)
            until = max(until_values) if any(until_values) else started + self.v11_pair_observation_seconds
            age = max(0.0, now - started)

            pnls = [(p, p.estimated_net_pnl(self.estimated_commission_rate)) for p in members]
            winner, winner_net = max(pnls, key=lambda x: x[1])
            loser, loser_net = min(pnls, key=lambda x: x[1])

            # Catastrophic protection remains active even during observation.
            catastrophic = [p for p, net in pnls if net <= -abs(self.v11_pair_emergency_leg_loss)]
            if catastrophic:
                for p in catastrophic:
                    self.set_cooldown(p.symbol, p.side)
                    self.close_position(p, f"V11 PAIR EMERGENCY LEG LOSS (${abs(p.estimated_net_pnl(self.estimated_commission_rate)):.2f})")
                continue

            # During the observation window, neither leg is judged by normal exit
            # analysis. This is the exact correction to the behavior you observed.
            if now < until:
                reason = (f"V11 PAIR OBSERVING {age/60:.1f}m/{self.v11_pair_observation_seconds/60:.1f}m | "
                          f"winner={winner.position_side} ${winner_net:+.2f} | "
                          f"other={loser.position_side} ${loser_net:+.2f} | no early leg exit")
                for p in members:
                    p.last_exit_score = 0
                    p.last_exit_reason = reason
                    p.exit_confirmation_count = 0
                    save_open_position(p, self.db_path)
                continue

            # After the observation period, release the weaker side only when the
            # winner has actually demonstrated a meaningful edge.
            direction = int(getattr(winner, "_v11_direction", winner.last_direction_confidence))
            winner_side = winner.side
            try:
                live_dir = self.current_direction_confidence(winner)
            except Exception:
                live_dir = direction
            demonstrated = (
                winner_net >= (self.v11_pair_min_winner_net + self.v11_pair_release_buffer) and
                (live_dir >= self.v11_pair_min_winner_direction or winner_net >= self.v11_pair_min_winner_net * 1.75)
            )

            if demonstrated:
                loser._v11_survivor = False
                winner._v11_survivor = True
                winner._v11_pair_released_at = now
                save_open_position(winner, self.db_path)
                self.close_position(
                    loser,
                    f"V11 PAIR RELEASE: {winner.position_side} demonstrated ${winner_net:+.2f} net "
                    f"after {age/60:.1f}m; live direction {live_dir}/100"
                )
                logger.info("V11 PAIR RESOLVED %s winner=%s net=%.2f loser=%s net=%.2f",
                            pair_id, winner.position_side, winner_net, loser.position_side, loser_net)
                managed.discard((loser.symbol, loser.side))
                # Keep the winner protected from an immediate legacy exit on the
                # same tick. It has just passed the pair decision gate.
                continue

            # Maximum observation reached without a convincing winner. Do not force
            # a directional guess. Close both to stop paying fees on a dead experiment.
            if age >= self.v11_pair_max_observation_seconds:
                for p in members:
                    self.close_position(p, f"V11 PAIR EXPIRED: no demonstrated directional edge after {age/60:.1f}m")
                logger.info("V11 PAIR EXPIRED %s after %.1f minutes", pair_id, age/60.0)
                continue

            # Between observation expiry and a convincing edge: continue observing.
            reason = (f"V11 PAIR HOLDING | winner={winner.position_side} ${winner_net:+.2f} | "
                      f"other={loser.position_side} ${loser_net:+.2f} | live_dir={live_dir}/100 | "
                      f"waiting for demonstrated edge")
            for p in members:
                p.last_exit_score = 0
                p.last_exit_reason = reason
                p.exit_confirmation_count = 0
                save_open_position(p, self.db_path)
        return managed

    # -------------------------------------------------------------------------
    # POSITION MANAGEMENT (enhanced with loss governors, profit protection, cooldowns)
    # -------------------------------------------------------------------------
    def manage_positions(self):
        pair_managed = self.manage_v11_pairs()
        for symbol in list(self.positions.keys()):
            for side in list(self.positions.get(symbol, {}).keys()):
                p = self.positions[symbol].get(side)
                if not p:
                    continue
                if (symbol, side) in pair_managed:
                    continue
                if getattr(p, "_v11_survivor", False):
                    released_at = safe_float(getattr(p, "_v11_pair_released_at", 0), 0.0)
                    if released_at > 0 and time.time() - released_at < self.v11_pair_post_release_min_hold:
                        p.last_exit_score = 0
                        p.last_exit_reason = "V11 SURVIVOR GRACE: pair decision just made; allowing continuation to develop"
                        save_open_position(p, self.db_path)
                        continue
                try:
                    price = self.get_mark_price(symbol, force=True)
                    if price <= 0:
                        continue
                    p.current_price = price
                    p.update_extremes()
                    net_pnl = p.estimated_net_pnl(self.estimated_commission_rate)
                    self._v15_update_position(p, net_pnl)

                    loss_budget = getattr(p, "_temp_loss_budget", self.max_trade_loss)

                    is_v12 = (self._v127_is_smart_brain_position(p) or getattr(p, "_strategy", "") == "v12_predictive" or
                              str(getattr(p, "regime", "")).upper().startswith("V12"))

                    thesis_info = self._v126_monitor_thesis(p, net_pnl) if is_v12 else {'state':'N/A','failure':False}
                    if is_v12 and thesis_info.get('state') == 'THESIS_FAILURE':
                        p.last_exit_reason = f'V12.6 THESIS FAILURE: {thesis_info.get("reason", "")}'

                    # IMPORTANT V12.6 FIX:
                    # A predictive trade that has already demonstrated meaningful
                    # profit enters a PROTECTED-WINNER state. Its profit floor is
                    # evaluated BEFORE the ordinary loss budget, so a winner cannot
                    # round-trip through zero and later be closed at -$6 merely
                    # because the local polling loop missed the exchange stop.
                    # The exchange-side PF STOP_MARKET remains the primary defense;
                    # the local market close is only a fallback if the PF order is
                    # unavailable or fails to execute after a short grace period.
                    if is_v12:
                        self._v125_ensure_loss_stop(p)
                        v12_budget = abs(safe_float(getattr(self, "v12_max_loss", 6.0), 6.0))
                        v12_emergency = abs(safe_float(getattr(self, "v12_emergency_loss", 9.0), 9.0))
                        key_v126 = (symbol, side)
                        lock_active = bool(getattr(p, 'profit_lock_active', False) and
                                           safe_float(getattr(p, 'profit_floor', 0.0), 0.0) > 0.0)

                        if lock_active:
                            floor_now = safe_float(getattr(p, 'profit_floor', 0.0), 0.0)
                            if net_pnl <= floor_now:
                                started = self.v126_profit_floor_breach_started.setdefault(key_v126, time.time())
                                self._v125_ensure_profit_stop(p)
                                protection_orders = self._v125_find_protection_orders(p)
                                grace = max(0.0, float(self.v126_profit_floor_grace_seconds))
                                if ('profit' not in protection_orders and
                                        time.time() - started >= grace):
                                    self.set_cooldown(symbol, side)
                                    self.close_position(
                                        p,
                                        f"V12.6 PROTECTED WINNER FLOOR FALLBACK - peak ${p.peak_net_pnl:+.2f}, "
                                        f"floor ${floor_now:+.2f}, current ${net_pnl:+.2f}; exchange PF unavailable"
                                    )
                                    continue
                            else:
                                self.v126_profit_floor_breach_started.pop(key_v126, None)

                            # Once profit protection is active, do NOT apply the
                            # ordinary -$6 V12 budget. Only the emergency circuit
                            # remains as a final catastrophe fallback.
                            if net_pnl <= -v12_emergency:
                                self.set_cooldown(symbol, side)
                                self.close_position(p, f"V12.6 EMERGENCY LOSS - net loss ${-net_pnl:.2f} >= ${v12_emergency:.2f} while protected winner")
                                continue
                        else:
                            if net_pnl <= -v12_emergency:
                                self.set_cooldown(symbol, side)
                                self.close_position(p, f"V12 EMERGENCY LOSS - net loss ${-net_pnl:.2f} >= ${v12_emergency:.2f}")
                                continue
                            if net_pnl <= -v12_budget:
                                self.set_cooldown(symbol, side)
                                self.close_position(p, f"V12 MAX LOSS - net loss ${-net_pnl:.2f} >= ${v12_budget:.2f}")
                                continue
                    else:
                        # Legacy strategies retain the older risk governor.
                        close_now, reason, level = self.check_loss_governor(p)
                        if close_now:
                            self.set_cooldown(symbol, side)
                            p.last_exit_reason = reason
                            self.close_position(p, reason)
                            continue
                        if loss_budget is not None and net_pnl < -loss_budget:
                            self.close_position(p, f"POSITION LOSS BUDGET EXCEEDED (${loss_budget:.2f})")
                            continue

                    # Early validation
                    if self.early_validation_seconds > 0 and p.age_seconds() <= self.early_validation_seconds:
                        if net_pnl < -self.early_validation_max_adverse * p.notional:
                            self.close_position(p, f"EARLY VALIDATION FAILED (loss {net_pnl:+.2f})")
                            continue

                    loss_pct = max(0, -net_pnl / max(p.notional, 1e-9) * 100)
                    if self.max_position_loss_percent > 0 and loss_pct >= self.max_position_loss_percent:
                        self.set_cooldown(symbol, side)
                        self.close_position(p, f"MAX POSITION LOSS {loss_pct:.2f}%")
                        continue

                    if self.emergency_max_loss_percent > 0 and loss_pct >= self.emergency_max_loss_percent:
                        self.set_cooldown(symbol, side)
                        self.close_position(p, "EMERGENCY RISK CIRCUIT BREAKER")
                        continue

                    if p.age_seconds() < self.exit_min_hold_seconds:
                        save_open_position(p, self.db_path)
                        continue

                    # Exit analysis
                    score, reason, _ = self.exit_analysis(p)
                    p.last_exit_score = score
                    p.last_exit_reason = reason

                    direction_confidence = self.current_direction_confidence(p)
                    p.last_direction_confidence = direction_confidence

                    # V12 controlled reversal: never hedge both directions. If the
                    # original thesis is failing, wait for a fresh, high-confidence
                    # opposite prediction before closing and reopening the symbol.
                    # One reversal maximum per V12 position prevents ping-pong behavior.
                    if self._v127_is_smart_brain_position(p) or getattr(p, 'strategy', '') == 'v12_predictive' or str(p.regime).upper().startswith('V12'):
                        if not getattr(p, '_v12_reversal_used', False) and p.age_seconds() >= self.v12_reversal_min_age:
                            try:
                                route_now = self.strategy_router(symbol)
                                opp_side = 'SELL' if p.side == 'BUY' else 'BUY'
                                opp_pred = self._v12_reversal_signal(symbol, route_now, p.side, p)
                                if (thesis_info.get('failure', False) and net_pnl < 0 and net_pnl >= -abs(self.v12_reversal_max_loss) and
                                    opp_pred.get('prediction', 0) >= self.v12_reversal_prediction and
                                    opp_pred.get('margin', 0) >= self.v12_reversal_margin and
                                    opp_pred.get('score', 0) >= self.v12_reversal_score and
                                    opp_pred.get('ok', False)):
                                    old_side = p.side
                                    old_price = p.current_price
                                    p._v12_reversal_used = True
                                    self.close_position(p,
                                        f'V12 THESIS FAILURE: opposite prediction {opp_pred["prediction"]:.0f}% / '
                                        f'pre-move {opp_pred["score"]:.0f}; controlled reversal')
                                    self.v12_last_close[symbol] = {'time': time.time(), 'price': old_price, 'side': old_side, 'reason': 'V12 controlled reversal'}
                                    rev_reason = (f'V12 CONTROLLED REVERSAL: original {"LONG" if old_side=="BUY" else "SHORT"} '
                                                  f'failed; new {"LONG" if opp_side=="BUY" else "SHORT"} prediction '
                                                  f'{opp_pred["prediction"]:.0f}%; pre-move={opp_pred["score"]:.0f}/100; '
                                                  f'margin={opp_pred["margin"]:.0f}%')
                                    if self.open_position(symbol, opp_side, notional=self.fixed_notional,
                                                          regime='V12_REVERSAL', loss_budget=None,
                                                          entry_quality=int(round(opp_pred['score'])),
                                                          entry_reason=rev_reason,
                                                          direction_confidence=int(round(opp_pred['prediction'])),
                                                          strategy='v12_predictive'):
                                        np = self.positions.get(symbol, {}).get(opp_side)
                                        if np:
                                            np._v12_reversal_used = True
                                            np._v12_prediction_score = opp_pred['score']
                                            np._v12_prediction = opp_pred['prediction']
                                        self.v12_entries_today += 1
                                        logger.info('V12 CONTROLLED REVERSAL %s %s -> %s', symbol, old_side, opp_side)
                                    continue
                            except Exception as rev_exc:
                                logger.warning('V12 reversal evaluation failed %s: %s', symbol, rev_exc)

                    # Profit protection
                    protected, protection_reason = self.update_profit_protection(p, net_pnl, score, direction_confidence)
                    if is_v12:
                        self._v125_ensure_profit_stop(p)
                        # V12.6: when the exchange-side profit STOP_MARKET exists,
                        # do not immediately cancel it and replace it with a market
                        # close just because the local mark estimate crossed the floor.
                        # Let Binance execute the native protection. The early risk
                        # block above provides the local fallback if that order is
                        # unavailable.
                        if (protected and getattr(p, 'profit_lock_active', False) and
                                safe_float(getattr(p, 'profit_floor', 0.0), 0.0) > 0 and
                                net_pnl <= safe_float(getattr(p, 'profit_floor', 0.0), 0.0)):
                            # The risk block above owns this boundary. Do not let
                            # the generic local profit-lock path issue a second
                            # market close and defeat the native STOP_MARKET.
                            protected = False
                    if protected:
                        self.latest_exit[(symbol, side)] = (score, protection_reason)
                        self.close_position(p, protection_reason)
                        continue

                    # Exit logic
                    if net_pnl < 0:
                        if is_v12:
                            # V12.3 THESIS HOLD: indicator disagreement is not by
                            # itself a reason to liquidate. A predictive trade is
                            # allowed to breathe until either the monetary loss
                            # budget is reached or the separate controlled-reversal
                            # detector has a genuinely new opposite thesis.
                            adverse_atr = max(0.0,
                                ((p.entry_price - p.current_price) / max(safe_float((self.market_features(symbol, "5m") or {}).get("atr"), 1e-12), 1e-12))
                                if p.side == "BUY" else
                                ((p.current_price - p.entry_price) / max(safe_float((self.market_features(symbol, "5m") or {}).get("atr"), 1e-12), 1e-12)))
                            if (p.age_seconds() < self.v12_adaptive_loss_min_age or
                                adverse_atr < self.v12_adaptive_loss_min_adverse_atr or
                                direction_confidence >= self.v12_hold_direction_floor):
                                p.exit_confirmation_count = 0
                                self.latest_exit[(symbol, side)] = (
                                    score,
                                    f"V12.3 THESIS HOLD: net ${net_pnl:+.2f}; adverse {adverse_atr:.2f}ATR; "
                                    f"direction {direction_confidence}/100; waiting for loss budget or true thesis failure"
                                )
                            else:
                                p.exit_confirmation_count = p.exit_confirmation_count + 1 if score >= self.exit_score_threshold else 0
                                self.latest_exit[(symbol, side)] = (score, f"V12.3 monitored weakness: {reason}; dir_conf={direction_confidence}")
                                # Even after the market has moved adversely, require
                                # five consecutive high-score observations before an
                                # adaptive exit. This is a last-resort path; monetary
                                # loss limits remain the primary hard protection.
                                if p.exit_confirmation_count >= self.v12_adaptive_loss_confirmations:
                                    self.close_position(p, f"V12.3 ADAPTIVE LOSS EXIT ({score}/100, {p.exit_confirmation_count}/{self.v12_adaptive_loss_confirmations} confirmations): {reason}")
                                    continue
                        else:
                            if score >= self.exit_score_hard_threshold:
                                self.latest_exit[(symbol, side)] = (score, f"HARD REVERSAL; dir_conf={direction_confidence}")
                                self.close_position(p, f"HARD REVERSAL ({score}/100): {reason}")
                                continue
                            p.exit_confirmation_count = p.exit_confirmation_count + 1 if score >= self.exit_score_threshold else 0
                            self.latest_exit[(symbol, side)] = (score, f"{reason}; dir_conf={direction_confidence}")
                            if p.exit_confirmation_count >= self.exit_confirmations_required:
                                self.close_position(p, f"ADAPTIVE LOSS EXIT ({score}/100, {p.exit_confirmation_count}/{self.exit_confirmations_required} confirmations): {reason}")
                                continue
                    else:
                        p.exit_confirmation_count = 0
                        self.latest_exit[(symbol, side)] = (score, f"{reason}; dir_conf={direction_confidence}")

                    save_open_position(p, self.db_path)

                except Exception as exc:
                    logger.exception("Position management error %s %s: %s", symbol, side, exc)

    # -------------------------------------------------------------------------
    # ENTRY SCANNING (regime-aware)
    # -------------------------------------------------------------------------
    def _v89_entry_state_ready(self) -> bool:
        """Use the cached Binance snapshot for normal scans and refresh only when stale.
        A transient REST timeout must not turn a healthy, known account into an
        artificial flat/blocked state during every candidate evaluation.
        """
        if self.mode == 'PAPER':
            return True
        if not self.state_known:
            return False
        age = time.time() - safe_float(self.last_rest_reconcile, 0.0)
        if age <= self.v89_fast_reconcile_seconds:
            return True
        ok = self.reconcile(force=True, reason='V8.9 ENTRY STATE REFRESH')
        return bool(ok and self.state_known)

    def _v92_live_entry_gate(self, symbol: str, candidate: dict, route: dict) -> dict:
        """V9.2 live execution gate.

        V9.0/V8.9 correctly identified direction, but the old gate could reject a
        good opportunity because one fast timeframe was temporarily out of phase.
        V9.2 therefore separates THESIS from EXECUTION STATE:

        * the thesis can remain strongly LONG/SHORT;
        * a small counter-move is classified as a breath, not a new trend;
        * a breath does not start a timer or confirmation countdown;
        * entry is allowed as soon as price is no longer falling/rising against the
          thesis and live momentum has recovered;
        * if there is no breath, a strong live continuation can enter immediately.

        This is deliberately price-state based rather than time based.
        """
        if not self.v92_enabled:
            return candidate
        side = str(candidate.get('side', '')).upper()
        if side not in ('BUY', 'SELL'):
            return candidate
        want = 1 if side == 'BUY' else -1

        f1 = self.market_features(symbol, '1m') or {}
        f5 = self.market_features(symbol, '5m') or {}
        f15 = self.market_features(symbol, '15m') or {}
        price = safe_float(f5.get('live_price', f5.get('price', 0)))
        atr = max(safe_float(f5.get('atr')), 1e-12)
        if price <= 0:
            return candidate

        # Keep a short live tape. This is not a confirmation timer; it only tells us
        # whether the current tick is continuing, breathing, or recovering.
        hist = self.v92_price_history.setdefault(symbol, [])
        hist.append((time.time(), price))
        if len(hist) > int(self.v92_history_size):
            del hist[:-int(self.v92_history_size)]

        trend1 = 1 if f1.get('trend') == 1 else -1 if f1.get('trend') == -1 else 0
        trend5 = 1 if f5.get('trend') == 1 else -1 if f5.get('trend') == -1 else 0
        trend15 = 1 if f15.get('trend') == 1 else -1 if f15.get('trend') == -1 else 0
        mom1 = safe_float(f1.get('momentum'))
        mom5 = safe_float(f5.get('momentum'))
        slope5 = safe_float(f5.get('ema_slope'))
        adx5 = safe_float(f5.get('adx'))
        direction = safe_float(candidate.get('direction', 0))
        base_score = safe_float(candidate.get('score', 0))

        mom_threshold = max(self.v89_min_momentum, self.v92_min_execution_score / 100000.0)
        mom5_ok = mom5 >= mom_threshold if side == 'BUY' else mom5 <= -mom_threshold
        mom1_ok = mom1 >= mom_threshold * 0.50 if side == 'BUY' else mom1 <= -mom_threshold * 0.50
        slope_ok = slope5 > 0 if side == 'BUY' else slope5 < 0
        higher_ok = trend15 == want
        fast_aligned = trend1 == want and trend5 == want
        five_aligned = trend5 == want

        # Detect the most recent local extreme in the thesis direction and measure
        # how much of that move has been given back. A 0.10 ATR giveback is a real
        # breath; tiny noise is ignored.
        prices = [x[1] for x in hist]
        prior = prices[:-1]
        local_extreme = max(prior) if side == 'BUY' and prior else min(prior) if side == 'SELL' and prior else price
        if side == 'BUY':
            counter_atr = max(0.0, (local_extreme - price) / atr)
            recovery_from_min = (price - min(prices)) / atr if prices else 0.0
        else:
            counter_atr = max(0.0, (price - local_extreme) / atr)
            recovery_from_min = (max(prices) - price) / atr if prices else 0.0
        breathing = counter_atr >= self.v92_breath_atr

        # Net movement over the short tape tells us whether the market is actually
        # resuming the thesis. No six-second/two-candle wait is used.
        if len(prices) >= 3:
            net_move = (prices[-1] - prices[0]) / atr
            recent_move = (prices[-1] - prices[-3]) / atr
        elif len(prices) >= 2:
            net_move = (prices[-1] - prices[0]) / atr
            recent_move = net_move
        else:
            net_move = recent_move = 0.0
        net_aligned = net_move * want > 0.0
        recent_aligned = recent_move * want >= self.v92_recovery_atr

        # A stalled market after a strong directional burst is not automatically a
        # reversal. Treat it as neutral unless price has also produced a meaningful
        # counter-move. This prevents the bot from repeatedly entering at the same top.
        stalled = len(prices) >= 3 and max(prices) - min(prices) <= self.v92_stall_atr * atr

        # Extension is measured from EMA20, but V9.2 allows a stronger continuation
        # than V8.9 if live momentum and higher-timeframe structure are still intact.
        ema20 = safe_float(f5.get('ema20'), price)
        dist_atr = abs(price - ema20) / atr
        chase_ok = dist_atr <= self.v92_max_chase_atr

        # Build an execution score from CURRENT state, not a stale closed candle.
        execution = 45.0
        if higher_ok: execution += 16.0
        if five_aligned: execution += 13.0
        if trend1 == want: execution += 8.0
        if mom5_ok: execution += 10.0
        if mom1_ok: execution += 5.0
        if slope_ok: execution += 7.0
        if net_aligned: execution += 5.0
        if breathing: execution -= 12.0
        if breathing and recent_aligned: execution += 12.0
        if breathing and not recent_aligned: execution -= 12.0
        if stalled: execution -= 6.0
        if dist_atr > 1.60: execution -= 8.0
        if dist_atr > 1.90: execution -= 12.0
        if adx5 >= 20: execution += 4.0
        execution = max(0.0, min(100.0, execution))

        # We do NOT require 1m+5m alignment when the broader thesis is strong and
        # the execution state is recovering. This is the main V9.2 improvement over
        # the observed "V8.9 live 1m/5m trend not aligned" blocker.
        thesis_strong = direction >= self.v92_min_direction and higher_ok
        recovered = (not breathing) or recent_aligned or (mom5_ok and slope_ok and net_aligned)
        fast_ok = (mom5_ok or mom1_ok or slope_ok) and (five_aligned or higher_ok)
        relaxed_ok = self.v92_allow_early_continuation and self.v92_relax_1m5m_alignment and thesis_strong and fast_ok

        blockers = []
        if direction < self.v92_min_direction:
            blockers.append(f'V9.2 direction {direction:.0f}<minimum {self.v92_min_direction:.0f}')
        if not higher_ok:
            blockers.append('V9.2 higher-timeframe thesis not aligned')
        if breathing and not recovered:
            blockers.append(f'V9.2 market breath {counter_atr:.2f}ATR; price has not resumed {"LONG" if side=="BUY" else "SHORT"}')
        if not mom5_ok and not slope_ok and not mom1_ok:
            blockers.append('V9.2 live execution momentum not active')
        if not chase_ok:
            blockers.append(f'V9.2 extension {dist_atr:.2f}ATR too high')
        if not fast_aligned and not relaxed_ok and not (mom5_ok and slope_ok):
            blockers.append('V9.2 fast trend temporarily out of phase')

        # Candidate can enter immediately if it is already moving correctly. If it
        # breathed, only the CURRENT recovery state is needed; no timed confirmation.
        enter = (
            direction >= self.v92_min_direction and
            execution >= self.v92_min_execution_score and
            safe_float(candidate.get('v89', {}).get('live_final', base_score), base_score) >= self.v92_min_final and
            higher_ok and recovered and chase_ok and
            (fast_aligned or relaxed_ok or (mom5_ok and slope_ok)) and
            not (stalled and not net_aligned) and
            not any('extension' in b for b in blockers)
        )

        action = 'ENTER_NOW' if enter else 'WAIT'
        candidate['market_entry_action'] = action
        candidate['blockers'] = list(dict.fromkeys(blockers if not enter else []))
        candidate['score'] = int(round(max(base_score, execution))) if enter else int(round(execution))
        candidate['quality'] = candidate['score']
        candidate['direction'] = int(round(direction))
        candidate['v92'] = {
            'version': '9.2', 'action': action, 'execution_score': execution,
            'breathing': breathing, 'counter_move_atr': counter_atr,
            'recovery_atr': recovery_from_min, 'net_move_atr': net_move,
            'recent_move_atr': recent_move, 'stalled': stalled,
            'fast_aligned': fast_aligned, 'higher_aligned': higher_ok,
            'momentum_5m': mom5_ok, 'momentum_1m': mom1_ok,
            'slope_ok': slope_ok, 'extension_atr': dist_atr,
            'relaxed_alignment': relaxed_ok, 'price': price
        }
        if enter:
            breath_text = 'after live breath recovery' if breathing else 'on live continuation'
            candidate['reason'] = (f'V9.2 FAST MARKET ENTRY: {breath_text}; '
                                   f'execution {execution:.0f}/100; current price ${price:,.6f}').strip()
        else:
            candidate['reason'] = '; '.join(candidate['blockers'][:5]) or 'V9.2 live state not executable'
        self.v92_last_entry_state[symbol] = candidate['v92']
        return candidate

    # -------------------------------------------------------------------------
    # V10 EARLY ENTRY ENGINE
    # -------------------------------------------------------------------------
    def _v10_directional_source(self, symbol: str, route: dict):
        """Extract the strongest *directional thesis* without using legacy entry gates.

        V10 treats direction, timing and execution as different things. A high
        directional score alone does not create a trade; it becomes eligible when
        the movement-birth detector sees a fresh move near its origin.
        """
        candidates = []
        for c in route.get('candidates', []) or []:
            side = str(c.get('side', '')).upper()
            v7 = c.get('v7') or {}
            direction = safe_float(v7.get('direction_probability', c.get('direction', 0)))
            final = safe_float(v7.get('final_score', c.get('score', 0)))
            if side in ('BUY', 'SELL') and direction >= self.v10_thesis_min_direction:
                item = dict(c)
                item['_v10_direction'] = direction
                item['_v10_final'] = final
                candidates.append(item)
        long_d = safe_float(route.get('long', 0))
        short_d = safe_float(route.get('short', 0))
        if max(long_d, short_d) >= self.v10_thesis_min_direction and abs(long_d-short_d) >= 8:
            side = 'BUY' if long_d > short_d else 'SELL'
            candidates.append({
                'side': side, 'strategy': 'trend', 'direction': max(long_d, short_d),
                'score': max(long_d, short_d), 'expected_net': 0.0,
                'notional_mult': 1.0, 'loss_budget': None,
                'reason': 'V10 directional thesis from live intelligence',
                '_v10_direction': max(long_d, short_d),
                '_v10_final': safe_float((route.get('v7') or {}).get('final_score', 0))
            })
        if not candidates:
            return None
        candidates.sort(key=lambda x: (safe_float(x.get('_v10_direction')), safe_float(x.get('_v10_final'))), reverse=True)
        return candidates[0]

    def _v10_update_tape(self, symbol: str, price: float, now: float):
        """Maintain a tiny live tape used only for detecting the birth of movement."""
        tape = self.v10_live_tape.setdefault(symbol, [])
        if price <= 0:
            return tape
        tape.append((now, price))
        cutoff = now - max(90.0, self.v10_movement_window_seconds * 2.5)
        while tape and tape[0][0] < cutoff:
            tape.pop(0)
        return tape

    def _v10_movement_birth(self, symbol: str, side: str, route: dict, now: float):
        """Detect a NEW move, rather than confusing an existing move with its start.

        This is deliberately fast. It uses the already-available live mark price,
        1m/5m structure and a short price tape. It never invokes V9.2 gates.
        """
        f1 = self.market_features(symbol, '1m') or {}
        f5 = self.market_features(symbol, '5m') or {}
        price = safe_float(f1.get('live_price', f5.get('live_price', route.get('price', 0))))
        atr = max(safe_float(f5.get('atr')), 1e-12)
        if price <= 0 or atr <= 0:
            return {'active': False, 'price': price, 'atr': atr, 'score': 0, 'reason': 'live data unavailable'}
        tape = self._v10_update_tape(symbol, price, now)
        want = 1 if side == 'BUY' else -1
        trend1 = 1 if f1.get('trend') == 1 else -1 if f1.get('trend') == -1 else 0
        trend5 = 1 if f5.get('trend') == 1 else -1 if f5.get('trend') == -1 else 0
        mom1 = safe_float(f1.get('momentum'))
        mom5 = safe_float(f5.get('momentum'))
        slope5 = safe_float(f5.get('ema_slope'))
        rsi = safe_float(f1.get('rsi'), 50.0)
        volume = safe_float(f1.get('volume_ratio', f5.get('volume_ratio', 1.0)), 1.0)

        # Recent displacement from the oldest useful tape sample.
        baseline_price = price
        baseline_age = 0.0
        recent_delta = 0.0
        acceleration = 0.0
        if tape:
            eligible = [(t,p) for t,p in tape if now-t <= self.v10_movement_window_seconds]
            if eligible:
                baseline_t, baseline_price = eligible[0]
                baseline_age = now - baseline_t
                recent_delta = (price-baseline_price) / max(baseline_price,1e-12)
            if len(tape) >= 3:
                t0,p0 = tape[-3]; t1,p1 = tape[-2]; t2,p2 = tape[-1]
                dt1=max(t1-t0,0.5); dt2=max(t2-t1,0.5)
                v1=(p1-p0)/max(p0,1e-12)/dt1
                v2=(p2-p1)/max(p1,1e-12)/dt2
                acceleration=v2-v1

        # Micro break: compare live price with the recent closed 1m structure.
        d1 = f1.get('df')
        prior_high = prior_low = price
        if d1 is not None and len(d1) >= 5:
            prior_high = float(d1['high'].iloc[-5:-1].max())
            prior_low = float(d1['low'].iloc[-5:-1].min())
        break_buy = price > prior_high + self.v10_movement_break_atr * atr
        break_sell = price < prior_low - self.v10_movement_break_atr * atr
        micro_break = break_buy if side == 'BUY' else break_sell

        direction_sign = 1 if recent_delta > 0 else -1 if recent_delta < 0 else 0
        displacement_ok = direction_sign == want and abs(recent_delta) >= (self.v10_movement_break_atr * atr / max(price,1e-12))
        accel_ok = (acceleration > self.v10_movement_accel_threshold if side == 'BUY'
                    else acceleration < -self.v10_movement_accel_threshold)
        mom1_ok = (mom1 > 0 if side == 'BUY' else mom1 < 0)
        mom5_ok = (mom5 > 0 if side == 'BUY' else mom5 < 0)
        trend1_ok = trend1 == want
        trend5_ok = trend5 == want
        slope_ok = (slope5 > 0 if side == 'BUY' else slope5 < 0)
        volume_ok = volume >= 0.70

        confirmations = sum((trend1_ok, mom1_ok, mom5_ok, slope_ok, micro_break, accel_ok, displacement_ok, volume_ok))
        score = 35 + confirmations * 8
        if trend5_ok: score += 6
        if micro_break and accel_ok: score += 6
        if trend1_ok and mom1_ok: score += 5
        score = min(100, int(score))

        # A birth requires actual fresh displacement OR a local break/acceleration.
        birth_signal = micro_break or (accel_ok and mom1_ok) or (displacement_ok and mom1_ok)
        structure_support = trend1_ok and (mom1_ok or micro_break)
        active = birth_signal and structure_support and confirmations >= 3

        # Do not call an already mature move a birth. If the live price is already
        # too far from the 5m EMA, it may be a continuation, not a new origin.
        ema_dist_atr = abs(price-safe_float(f5.get('ema20',price))) / atr
        if ema_dist_atr > self.v10_origin_max_distance_atr and not micro_break:
            active = False

        return {
            'active': bool(active), 'price': price, 'atr': atr,
            'baseline_price': baseline_price, 'baseline_age': baseline_age,
            'recent_delta': recent_delta, 'acceleration': acceleration,
            'ema_dist_atr': ema_dist_atr, 'confirmations': int(confirmations),
            'score': int(score), 'micro_break': bool(micro_break),
            'accel_ok': bool(accel_ok), 'displacement_ok': bool(displacement_ok),
            'trend1_ok': bool(trend1_ok), 'trend5_ok': bool(trend5_ok),
            'mom1_ok': bool(mom1_ok), 'mom5_ok': bool(mom5_ok), 'slope_ok': bool(slope_ok),
            'volume_ok': bool(volume_ok),
            'reason': f'movement birth {confirmations}/8 confirmations; displacement {recent_delta*100:.3f}%'
        }

    def _v10_fast_state(self, symbol: str, side: str, origin: float, expected_move: float):
        """Fast live execution state. It is intentionally independent of V9.2."""
        f1 = self.market_features(symbol, '1m') or {}
        f5 = self.market_features(symbol, '5m') or {}
        price = safe_float(f1.get('live_price', f1.get('price', f5.get('live_price', f5.get('price', 0)))))
        atr = max(safe_float(f5.get('atr')), 1e-12)
        if price <= 0 or origin <= 0:
            return {'price': price, 'consumed': 999.0, 'confirmations': 0, 'score': 0, 'reason': 'no live price'}
        favorable = (price-origin) if side == 'BUY' else (origin-price)
        consumed = max(0.0, favorable / max(expected_move, atr * 1e-12) * 100.0)
        want = 1 if side == 'BUY' else -1
        trend1 = 1 if f1.get('trend') == 1 else -1 if f1.get('trend') == -1 else 0
        trend5 = 1 if f5.get('trend') == 1 else -1 if f5.get('trend') == -1 else 0
        mom1 = safe_float(f1.get('momentum')); mom5 = safe_float(f5.get('momentum')); slope5 = safe_float(f5.get('ema_slope'))
        c1 = trend1 == want
        c2 = trend5 == want
        c3 = (mom1 > 0 if side == 'BUY' else mom1 < 0)
        c4 = (mom5 > 0 if side == 'BUY' else mom5 < 0)
        c5 = (slope5 > 0 if side == 'BUY' else slope5 < 0)
        confirmations = sum((c1,c2,c3,c4,c5))
        score = min(100, 45 + confirmations*10 + (5 if c1 and c3 else 0) + (5 if c2 and c4 else 0))
        return {'price':price,'atr':atr,'consumed':consumed,'confirmations':confirmations,'score':score,
                'trend1':c1,'trend5':c2,'mom1':c3,'mom5':c4,'slope5':c5,
                'reason':f'fast confirmations {confirmations}/5; score {score}/100'}

    def _v10_snap_state(self, symbol: str, thesis: dict):
        """Fast opposite-side trigger after the original opportunity is missed."""
        side = thesis['side']; opposite = 'SELL' if side == 'BUY' else 'BUY'
        f1 = self.market_features(symbol, '1m') or {}; f5 = self.market_features(symbol, '5m') or {}
        price = safe_float(f1.get('live_price', f1.get('price', f5.get('live_price', f5.get('price', 0)))))
        atr = max(safe_float(f5.get('atr')), 1e-12); origin = safe_float(thesis.get('origin'))
        expected = max(safe_float(thesis.get('expected_move')), atr)
        favorable = (price-origin) if side=='BUY' else (origin-price)
        consumed = max(0.0, favorable / max(expected, 1e-12) * 100.0)
        mom1 = safe_float(f1.get('momentum')); mom5 = safe_float(f5.get('momentum'))
        tr1 = 1 if f1.get('trend') == 1 else -1 if f1.get('trend') == -1 else 0
        tr5 = 1 if f5.get('trend') == 1 else -1 if f5.get('trend') == -1 else 0
        want = -1 if side == 'BUY' else 1
        confirmations = sum([tr1 == want, tr5 == want,
                              (mom1 < 0 if side=='BUY' else mom1 > 0),
                              (mom5 < 0 if side=='BUY' else mom5 > 0)])
        return {'opposite':opposite,'price':price,'consumed':consumed,'confirmations':int(confirmations),
                'score':min(100,45+confirmations*12),'reason':f'snap reversal confirmations {confirmations}/4'}

    # -------------------------------------------------------------------------
    # V11 ZERO-LAG / REVERSE EXPERIMENT ENGINE
    # -------------------------------------------------------------------------
    def _v11_directional_source(self, symbol: str, route: dict):
        """Use the SAME strategic intelligence already produced by the router.

        V11 does not perform a second confirmation analysis.  The router's current
        directional result is the trigger.  This is intentional: the experiment is
        specifically testing whether the original direction is less useful than its
        opposite when entered at the first available execution point.
        """
        best = None
        for c in route.get('candidates', []) or []:
            side = str(c.get('side', '')).upper()
            v7 = c.get('v7') or {}
            direction = safe_float(v7.get('direction_probability', c.get('direction', 0)))
            final = safe_float(v7.get('final_score', c.get('score', 0)))
            if side not in ('BUY', 'SELL') or direction < self.v11_direction_threshold:
                continue
            item = dict(c)
            item['_v11_direction'] = direction
            item['_v11_final'] = final
            if best is None or (direction, final) > (best['_v11_direction'], best['_v11_final']):
                best = item

        # Do not depend on a legacy candidate surviving its own execution gates.
        long_d = safe_float(route.get('long', 0))
        short_d = safe_float(route.get('short', 0))
        if best is None and max(long_d, short_d) >= self.v11_direction_threshold:
            if abs(long_d - short_d) >= self.v11_direction_margin:
                side = 'BUY' if long_d > short_d else 'SELL'
                d = max(long_d, short_d)
                best = {
                    'side': side,
                    'strategy': 'v11_direct',
                    'direction': d,
                    'score': d,
                    'expected_net': 0.0,
                    'notional_mult': 1.0,
                    'loss_budget': None,
                    'reason': 'V11 direct directional snapshot',
                    '_v11_direction': d,
                    '_v11_final': d,
                }
        return best

    def _v11_live_entry_check(self, symbol: str, route: dict, source: dict):
        """Only a tiny execution-safety check; no second strategic analysis."""
        price = safe_float(self.get_mark_price(symbol, force=True))
        if price <= 0:
            price = safe_float(route.get('price', 0))
        if price <= 0:
            return {'ok': False, 'reason': 'live price unavailable', 'price': 0.0, 'extension_atr': 99.0}

        # Use already-computed 5m features only for an anti-catastrophic chase guard.
        f5 = self.market_features(symbol, '5m') or {}
        atr = max(safe_float(f5.get('atr')), 1e-12)
        ema20 = safe_float(f5.get('ema20'), price)
        extension = abs(price - ema20) / atr
        ok = extension <= self.v11_max_extension_atr
        return {
            'ok': bool(ok), 'price': price, 'atr': atr, 'extension_atr': extension,
            'reason': 'execution point available' if ok else f'extension {extension:.2f}ATR above V11 cap'
        }

    def _v11_pair_limit_reset(self):
        today = datetime.utcnow().date()
        if today != self.v11_pair_day:
            self.v11_pair_day = today
            self.v11_pair_count = 0

    def _v12_prediction(self, symbol: str, route: dict, side: str) -> dict:
        """V12 predictive entry model.

        The objective is to identify a *setup before expansion*, not to wait for a
        breakout that has already travelled.  It uses closed-candle structure,
        compression, directional pressure, room and multi-timeframe bias.  It is
        deliberately tolerant enough to trade genuine pre-move setups rather than
        requiring every timeframe to already be trending.
        """
        buy = str(side).upper() == 'BUY'
        want = 1 if buy else -1
        f1 = self.market_features(symbol, '1m') or {}
        f5 = self.market_features(symbol, '5m') or {}
        f15 = self.market_features(symbol, '15m') or {}
        f1h = self.market_features(symbol, '1h') or {}
        if not all((f1, f5, f15, f1h)):
            return {'ok': False, 'score': 0.0, 'prediction': 0.0, 'margin': 0.0,
                    'structure': 0.0, 'extension_atr': 99.0, 'pressure_atr': 99.0,
                    'compression': 0.0, 'volume_ratio': 0.0, 'room_atr': 0.0,
                    'reason': 'insufficient multi-timeframe data'}

        price = safe_float(f5.get('live_price', f5.get('price', 0)))
        atr = max(safe_float(f5.get('atr')), 1e-12)
        if price <= 0 or atr <= 0:
            return {'ok': False, 'score': 0.0, 'prediction': 0.0, 'margin': 0.0,
                    'structure': 0.0, 'extension_atr': 99.0, 'pressure_atr': 99.0,
                    'compression': 0.0, 'volume_ratio': 0.0, 'room_atr': 0.0,
                    'reason': 'live price/ATR unavailable'}

        long_d = safe_float(route.get('long', 0)); short_d = safe_float(route.get('short', 0))
        prediction = long_d if buy else short_d
        opposite = short_d if buy else long_d
        margin = prediction - opposite

        # Predictive structure: higher timeframes provide context; 1m is confirmation,
        # not a veto.  A developing setup may have one neutral timeframe.
        trends = [f1h.get('trend'), f15.get('trend'), f5.get('trend'), f1.get('trend')]
        weights = [0.20, 0.30, 0.30, 0.20]
        aligned = sum(w for v, w in zip(trends, weights) if v == want)
        opposed = sum(w for v, w in zip(trends, weights) if v == -want)
        structure = max(0.0, min(100.0, 50.0 + aligned * 50.0 - opposed * 35.0))

        ema20 = safe_float(f5.get('ema20'), price)
        extension = abs(price - ema20) / atr
        # The setup is allowed to sit within roughly 1.25 ATR of equilibrium;
        # beyond that it is increasingly likely that the move has already started.
        extension_score = max(0.0, 100.0 - (extension / max(self.v126_hard_extension_atr, 1e-9)) * 75.0)

        d5 = f5.get('df')
        pressure_atr = 0.0
        compression = 0.0
        if d5 is not None and len(d5) >= 24:
            closes = d5['close'].astype(float)
            recent = closes.iloc[-6:]
            pressure_atr = abs(float(recent.iloc[-1]) - float(recent.iloc[0])) / atr
            width8 = float(d5['high'].iloc[-8:].max() - d5['low'].iloc[-8:].min()) / atr
            compression = max(0.0, min(1.0, 1.0 - width8 / 5.0))
            tr = pd.concat([d5['high']-d5['low'],
                            (d5['high']-d5['close'].shift(1)).abs(),
                            (d5['low']-d5['close'].shift(1)).abs()], axis=1).max(axis=1)
            recent_tr = float(tr.iloc[-5:].mean()); old_tr = float(tr.iloc[-20:-8].mean())
            if old_tr > 0:
                compression = max(compression, max(0.0, min(1.0, 0.5 + (1.0 - recent_tr / old_tr))))

        pressure_score = 100.0 if pressure_atr <= 0.20 else max(0.0, 100.0 - pressure_atr / max(self.v12_max_pressure_atr, 1e-9) * 80.0)

        vr = safe_float(f5.get('volume_ratio'), 1.0)
        if 0.45 <= vr <= 1.35:
            volume_score = 100.0
        elif vr <= self.v12_max_volume_ratio:
            volume_score = 82.0
        else:
            volume_score = 20.0

        rsi = safe_float(f5.get('rsi'), 50.0)
        rsi_score = (100.0 if 45 <= rsi <= 63 else 75.0 if 40 <= rsi <= 68 else 35.0) if buy else (100.0 if 37 <= rsi <= 55 else 75.0 if 32 <= rsi <= 60 else 35.0)

        slope = safe_float(f5.get('ema_slope'))
        mom5 = safe_float(f5.get('momentum'))
        mom1 = safe_float(f1.get('momentum'))
        directional_pressure = 100.0 if ((slope > 0 and buy) or (slope < 0 and not buy)) and ((mom5 >= 0 and buy) or (mom5 <= 0 and not buy)) else 70.0 if ((slope > 0 and buy) or (slope < 0 and not buy) or (mom5 >= 0 and buy) or (mom5 <= 0 and not buy)) else 35.0

        hi = safe_float(f5.get('range_high_20'), price); lo = safe_float(f5.get('range_low_20'), price)
        room = (hi - price) / atr if buy else (price - lo) / atr
        room_score = 100.0 if room >= 0.60 else 85.0 if room >= self.v12_min_breakout_room_atr else 45.0

        margin_score = max(0.0, min(100.0, 50.0 + margin * 1.5))
        # The prediction is intentionally weighted most heavily, but structure and
        # compression can still identify a developing move before momentum explodes.
        score = (0.32 * prediction + 0.14 * margin_score + 0.18 * structure +
                 0.10 * extension_score + 0.09 * pressure_score + 0.08 * (compression * 100.0) +
                 0.04 * volume_score + 0.03 * rsi_score + 0.02 * directional_pressure)
        score = max(0.0, min(100.0, score))

        # Expert Guard: estimate how much of the directional impulse has already
        # been consumed.  This is intentionally separate from prediction confidence.
        # extension measures displacement from equilibrium; pressure measures recent
        # directional travel.  The larger of the two is used as a conservative proxy.
        expected_move_atr = max(1.00, room + 0.60)
        move_consumed_atr = max(extension, pressure_atr)
        move_consumed_pct = max(0.0, min(100.0, (move_consumed_atr / expected_move_atr) * 100.0))
        pre_move_remaining = 100.0 - move_consumed_pct

        # Planned execution economics.  This is a price-space R:R gate rather than
        # a claim about guaranteed PNL.  It prevents entries where the remaining room
        # cannot reasonably compensate for the predefined thesis-failure distance.
        planned_risk_atr = max(0.30, min(0.65, self.v126_assumed_stop_atr))
        planned_reward_atr = max(0.0, room + 0.14)
        rr = planned_reward_atr / planned_risk_atr if planned_risk_atr > 0 else 0.0
        qty_est = max(0.0, self.fixed_notional / max(price, 1e-12))
        gross_reward = planned_reward_atr * atr * qty_est
        round_trip_cost = self.fixed_notional * self.estimated_commission_rate * 2.0
        expected_net = gross_reward - round_trip_cost

        blockers = []
        maturity = ('PRE_MOVE' if extension <= 0.45 else
                    'EARLY_MOVE' if extension <= self.v126_early_extension_atr else
                    'DEVELOPING' if extension <= self.v126_developing_extension_atr else
                    'MATURE' if extension <= self.v126_hard_extension_atr else 'EXTENDED')
        # Directional gates stay strict; movement maturity now selects execution style.
        if prediction < self.v12_min_prediction: blockers.append(f'prediction {prediction:.0f}% below {self.v12_min_prediction:.0f}%')
        if margin < self.v12_min_direction_margin: blockers.append(f'direction margin {margin:.0f}% below {self.v12_min_direction_margin:.0f}%')
        if structure < self.v12_min_structure: blockers.append(f'structure {structure:.0f}/100 weak')
        if extension > self.v126_hard_extension_atr: blockers.append(f'extension {extension:.2f}ATR: too extended')
        if pressure_atr > self.v126_developing_pressure_atr: blockers.append(f'pressure {pressure_atr:.2f}ATR: movement too developed')
        if vr > self.v12_max_volume_ratio: blockers.append(f'volume {vr:.2f}x already expanded')
        if maturity == 'EARLY_MOVE' and compression < self.v126_early_compression: blockers.append(f'compression {compression*100:.0f}% too weak for early move')
        if maturity == 'DEVELOPING' and (prediction < 78 or margin < 20 or structure < 70): blockers.append('developing move requires stronger prediction/margin/structure')
        if maturity == 'DEVELOPING' and room < self.v126_developing_room_atr: blockers.append(f'breakout room {room:.2f}ATR too small')
        if maturity == 'MATURE': blockers.append(f'extension {extension:.2f}ATR: mature — no chase')
        if maturity == 'PRE_MOVE' and compression < self.v12_min_compression: blockers.append(f'compression {compression*100:.0f}% insufficient')
        if maturity == 'PRE_MOVE' and room < self.v12_min_breakout_room_atr: blockers.append(f'breakout room {room:.2f}ATR too small')
        if score < self.v12_min_pre_move_score: blockers.append(f'pre-move quality {score:.0f}/100 below {self.v12_min_pre_move_score:.0f}')
        if move_consumed_pct > self.v126_max_move_consumed_pct: blockers.append(f'move already consumed {move_consumed_pct:.0f}%')
        if extension > self.v126_max_entry_extension_atr: blockers.append(f'entry extension {extension:.2f}ATR too late')
        if pressure_atr > self.v126_max_entry_pressure_atr: blockers.append(f'entry pressure {pressure_atr:.2f}ATR too developed')
        if rr < self.v126_min_entry_rr: blockers.append(f'R:R {rr:.2f} below {self.v126_min_entry_rr:.2f}')
        if expected_net < self.v126_min_expected_net: blockers.append(f'expected net ${expected_net:.2f} below ${self.v126_min_expected_net:.2f}')

        return {'ok': not blockers, 'score': score, 'prediction': prediction, 'opposite': opposite,
                'margin': margin, 'structure': structure, 'extension_atr': extension,
                'pressure_atr': pressure_atr, 'compression': compression, 'volume_ratio': vr,
                'room_atr': room, 'rsi': rsi, 'maturity': maturity, 'price': price, 'atr': atr,
                'move_consumed_pct': move_consumed_pct, 'pre_move_remaining': pre_move_remaining,
                'expected_move_atr': expected_move_atr, 'rr': rr, 'expected_net': expected_net,
                'planned_risk_atr': planned_risk_atr, 'planned_reward_atr': planned_reward_atr,
                'reason': '; '.join(blockers[:4]) if blockers else f'{maturity}: strong prediction with controlled execution location'}

    def _v12_reversal_signal(self, symbol: str, route: dict, old_side: str, position: Position) -> dict:
        """Detect a genuine thesis failure and a new opposite movement.

        This is intentionally different from the pre-move entry model. A reversal is
        allowed only after the original position is losing AND the opposite side has
        produced live structural evidence. This avoids using a pre-move filter to
        predict a reversal after the market has already broken.
        """
        opp = 'SELL' if old_side == 'BUY' else 'BUY'
        want = -1 if old_side == 'BUY' else 1
        f1 = self.market_features(symbol, '1m') or {}; f5 = self.market_features(symbol, '5m') or {}
        if not f1 or not f5:
            return {'ok': False, 'score': 0, 'prediction': 0, 'margin': 0, 'reason': 'reversal data unavailable'}
        atr=max(safe_float(f5.get('atr')),1e-12); price=safe_float(f5.get('live_price',f5.get('price',0)))
        adverse=max(0.0, (position.entry_price-price)/atr if old_side=='BUY' else (price-position.entry_price)/atr)
        tr1=1 if f1.get('trend')==1 else -1 if f1.get('trend')==-1 else 0
        tr5=1 if f5.get('trend')==1 else -1 if f5.get('trend')==-1 else 0
        mom1=safe_float(f1.get('momentum')); mom5=safe_float(f5.get('momentum'))
        slope=safe_float(f5.get('ema_slope'))
        direction = safe_float(route.get('short' if opp=='SELL' else 'long',0))
        opposite_dir = safe_float(route.get('long' if opp=='SELL' else 'short',0))
        margin=direction-opposite_dir
        recent_high=float(f1['df']['high'].iloc[-5:-1].max()) if f1.get('df') is not None and len(f1.get('df'))>=5 else price
        recent_low=float(f1['df']['low'].iloc[-5:-1].min()) if f1.get('df') is not None and len(f1.get('df'))>=5 else price
        breakout = price < recent_low if opp=='SELL' else price > recent_high
        confirms=sum([tr1==want,tr5==want,(mom1<0 if opp=='SELL' else mom1>0),(mom5<0 if opp=='SELL' else mom5>0),(slope<0 if opp=='SELL' else slope>0),breakout])
        score=min(100, 35 + confirms*10 + (10 if adverse>=0.35 else 0) + (10 if direction>=70 else 0))
        ok=(adverse>=0.25 and direction>=65 and margin>=12 and confirms>=4 and breakout)
        return {'ok':ok,'score':score,'prediction':direction,'margin':margin,'adverse_atr':adverse,'confirmations':confirms,'reason':f'opposite={opp} dir={direction:.0f}; margin={margin:.0f}; adverse={adverse:.2f}ATR; confirmations={confirms}/6; breakout={breakout}'}

    def _v12_can_enter_symbol(self, symbol: str, price: float, atr: float) -> tuple:
        """Enforce one position per movement and prevent immediate re-entry loops."""
        if self.positions.get(symbol):
            return False, 'position already open — no staged add / no second entry'
        last = self.v12_last_close.get(symbol)
        if last:
            age = time.time() - safe_float(last.get('time'), 0)
            if age < self.v12_reentry_cooldown:
                return False, f're-entry cooldown {self.v12_reentry_cooldown-age:.0f}s remaining'
            last_price = safe_float(last.get('price'), 0)
            if last_price > 0 and atr > 0 and abs(price-last_price)/atr < self.v12_reentry_reset_atr:
                return False, f'movement not reset: only {abs(price-last_price)/atr:.2f}ATR from last close'
        return True, 'single-entry slot available'

    def _v12_record_close(self, symbol: str, price: float, side: str, reason: str):
        self.v12_last_close[symbol] = {'time': time.time(), 'price': price, 'side': side, 'reason': reason}

    def _v126_limit_price(self, symbol: str, side: str, price: float, atr: float, maturity: str) -> float:
        if maturity == 'PRE_MOVE': offset_atr = 0.14
        elif maturity == 'EARLY_MOVE': offset_atr = 0.18
        else: offset_atr = 0.24
        offset_atr = max(self.v126_entry_min_offset_atr, min(self.v126_entry_max_offset_atr, offset_atr))
        raw = price - offset_atr * atr if side == 'BUY' else price + offset_atr * atr
        tick = max(safe_float(self.symbol_info_cache.get(symbol, {}).get('tick_size'), 0.01), 1e-12)
        raw = min(raw, price - tick) if side == 'BUY' else max(raw, price + tick)
        return self.round_price(symbol, raw)

    def _v126_client_id(self, symbol: str, side: str) -> str:
        return f"QT126E{symbol[:6]}{side[0]}{int(time.time()*1000)%100000000}"[:36]

    def _v126_place_limit(self, symbol: str, side: str, price: float, notional: float, pred: dict) -> bool:
        qty = self.round_quantity(symbol, notional / max(price, 1e-12))
        if qty <= 0: return False
        cid = self._v126_client_id(symbol, side)
        try:
            self.throttle_api_call()
            pos_side = "LONG" if side == "BUY" else "SHORT"
            order = self.client.futures_create_order(
                symbol=symbol, side=side, positionSide=pos_side,
                type='LIMIT', timeInForce='GTC', quantity=qty, price=price,
                newClientOrderId=cid, newOrderRespType='RESULT'
            )
            oid = str(order.get('orderId',''))
            self.v126_pending_entries[symbol] = {
                'symbol':symbol,'side':side,'order_id':oid,'client_id':cid,'price':price,'qty':qty,
                'notional':notional,'created':time.time(),'last_check':time.time(),'reprices':0,
                'maturity':pred.get('maturity',''),'prediction':pred.get('prediction',0),
                'score':pred.get('score',0),'atr':pred.get('atr',0),'move_consumed_pct':pred.get('move_consumed_pct',0), 'rr':pred.get('rr',0), 'expected_net':pred.get('expert_scaled_expected_net', pred.get('expected_net',0)),
                'expert_score':pred.get('expert_score',0),'expert_notional':notional,'pre_move_remaining':pred.get('pre_move_remaining',0),
                'extension_atr':pred.get('extension_atr',0),'pressure_atr':pred.get('pressure_atr',0),'regime':pred.get('regime','unknown')}
            self.v12_last_entry_state.setdefault(symbol,{}).update({'state':'LIMIT_ARMED','limit_price':price,'order_id':oid,'maturity':pred.get('maturity','')})
            logger.info('V12.7 LIMIT ARMED %s %s price=%s current=%s maturity=%s prediction=%.0f score=%.0f',
                        symbol, side, price, pred.get('price',0), pred.get('maturity',''), pred.get('prediction',0), pred.get('score',0))
            return True
        except Exception as exc:
            self.handle_rate_limit_error(exc); logger.error('V12.7 LIMIT placement failed %s %s: %s',symbol,side,exc); return False

    def _v126_cancel_pending(self, symbol: str, reason: str) -> bool:
        p=self.v126_pending_entries.get(symbol)
        if not p: return True
        ok=self.cancel_order(symbol,str(p['order_id']),reason)
        if ok:
            self.v126_pending_entries.pop(symbol,None)
            self.v12_last_entry_state.setdefault(symbol,{})['state']='CANCELLED'
            self.v12_last_entry_state[symbol]['reason']=reason
        return ok

    def _v126_recover_exchange_entries(self) -> None:
        """Recover QT126E resting LIMIT entries after a process restart.

        Binance remains the source of truth. This runs once per process, so restart
        recovery does not add recurring REST traffic during normal operation.
        """
        if self.v126_recovery_done or self.mode == 'PAPER' or not self.state_known:
            return
        self.v126_recovery_done = True
        for symbol in self.symbols:
            try:
                orders = self.get_open_orders(symbol)
                if orders is None: continue
                for o in orders:
                    cid=str(o.get('clientOrderId',''))
                    if not cid.startswith('QT126E'): continue
                    side=str(o.get('positionSide') or ('BUY' if str(o.get('side','')).upper()=='BUY' else 'SELL')).upper()
                    if side not in ('BUY','SELL'): continue
                    self.v126_pending_entries[symbol] = {
                        'symbol':symbol,'side':side,'order_id':str(o.get('orderId')),'client_id':cid,
                        'price':safe_float(o.get('price')), 'qty':safe_float(o.get('origQty')),
                        'notional':safe_float(o.get('price'))*safe_float(o.get('origQty')),
                        'created':time.time(),'last_check':time.time(),'reprices':0,
                        'maturity':'RECOVERED','prediction':0,'score':0,'atr':0
                    }
                    self.v12_last_entry_state[symbol]={'state':'LIMIT_RECOVERED','side':side,
                        'limit_price':safe_float(o.get('price')),'order_id':str(o.get('orderId')),'maturity':'RECOVERED',
                        'reason':'resting V12.7 LIMIT recovered from Binance'}
                    logger.info('V12.7 recovered resting LIMIT %s %s @ %s order=%s',symbol,side,o.get('price'),o.get('orderId'))
                    break
            except Exception as exc:
                logger.warning('V12.6 exchange-entry recovery failed %s: %s',symbol,exc)

    def _v126_manage_pending_entries(self) -> None:
        if not self.v126_entry_enabled or self.mode == 'PAPER' or not self.state_known: return
        self._v126_recover_exchange_entries()
        now=time.time()
        for symbol,p in list(self.v126_pending_entries.items()):
            if now-p.get('last_check',0) < self.v126_entry_recheck_seconds: continue
            p['last_check']=now
            try:
                status=self.get_order_status(symbol,str(p['order_id']))
                if status is None: continue
                st=str(status.get('status','')).upper()
                if st in ('FILLED','PARTIALLY_FILLED'):
                    self.reconcile(force=True,reason='V12.7 LIMIT FILL')
                    actual=self.positions.get(symbol,{}).get(p['side'])
                    if actual:
                        self.v126_pending_entries.pop(symbol,None) if st=='FILLED' else None
                        if not getattr(actual,'_v126_counted',False):
                            self.v12_entries_today += 1; actual._v126_counted=True
                        thesis = dict(p)
                        thesis['regime'] = self.v12_last_entry_state.get(symbol, {}).get('regime', getattr(actual, 'regime', 'unknown'))
                        thesis['expert_score'] = safe_float(p.get('expert_score', self.v12_last_entry_state.get(symbol, {}).get('expert_score', 0)))
                        thesis['pre_move_remaining'] = safe_float(p.get('score', self.v12_last_entry_state.get(symbol, {}).get('pre_move_remaining', 0)))
                        self._v126_apply_thesis(actual, thesis, source='LIMIT_FILL')
                        self.v12_last_entry_state.setdefault(symbol,{}).update({'state':st,'fill_price':actual.entry_price,'order_id':p['order_id']})
                    if st=='FILLED': continue
                    # Partial fill: keep remainder resting; do not reprice it.
                    continue
                if st in ('CANCELED','EXPIRED','REJECTED'):
                    self.v126_pending_entries.pop(symbol,None)
                    self.v12_last_entry_state.setdefault(symbol,{}).update({'state':st,'reason':'exchange ended pending LIMIT'})
                    continue
                if now-p['created'] >= self.v126_entry_max_age_seconds:
                    self._v126_cancel_pending(symbol,'V12.7 LIMIT timeout'); continue
                route=self.strategy_router(symbol); pred=self._v12_prediction(symbol,route,p['side'])
                if not pred.get('ok'):
                    self._v126_cancel_pending(symbol,'V12.7 thesis invalidated: '+str(pred.get('reason',''))); continue
                if p.get('reprices',0) < self.v126_entry_max_reprices:
                    new_price=self._v126_limit_price(symbol,p['side'],safe_float(pred.get('price')),safe_float(pred.get('atr')),pred.get('maturity',''))
                    tick=max(safe_float(self.symbol_info_cache.get(symbol,{}).get('tick_size'),0.01),1e-12)
                    if abs(new_price-p['price']) >= max(3*tick,0.05*max(safe_float(pred.get('atr')),tick)):
                        if self.cancel_order(symbol,str(p['order_id']),'V12.7 controlled reprice'):
                            self.v126_pending_entries.pop(symbol,None)
                            self._v126_place_limit(symbol,p['side'],new_price,safe_float(p.get('expert_notional', p.get('notional', self.fixed_notional))),pred)
            except Exception as exc:
                logger.warning('V12.7 pending manager failed %s: %s',symbol,exc)

    # -------------------------------------------------------------------------
    # V12.6 EXPERT ADAPTIVE DECISION LAYER
    # -------------------------------------------------------------------------
    def _v126_regime_quality(self, regime: str, pred: dict) -> tuple:
        """Return (quality, hard_block, reason) for the current market regime.

        V12 predictive entries are designed for the birth/development of a move.
        A low-volatility market is not automatically rejected because compression
        can be the setup; however, low-volatility entries need stronger compression
        and more remaining move. Chop is always rejected for this entry engine.
        """
        r = str(regime or 'unknown').lower()
        compression = safe_float(pred.get('compression', 0.0))
        premove = safe_float(pred.get('pre_move_remaining', 0.0))
        if 'chop' in r:
            return 25.0, True, 'chop regime'
        quality = 78.0
        if 'trending' in r:
            quality = 100.0
        elif 'transition' in r:
            quality = 95.0
        elif 'ranging' in r:
            quality = 88.0
        elif 'mixed' in r:
            quality = 76.0
        elif 'unknown' in r:
            quality = 55.0
        if 'low_vol' in r:
            quality -= 12.0
            if compression < self.v126_expert_low_vol_min_compression or premove < self.v126_expert_low_vol_min_premove:
                return quality, True, 'low-volatility setup lacks sufficient compression/remaining move'
        return max(0.0, min(100.0, quality)), False, f'{r} regime'

    def _v126_expert_notional(self, pred: dict) -> tuple:
        """Size the position from technical stop distance instead of widening risk.

        The configured fixed notional remains the ceiling. If the technical stop is
        wider than normal, notional is reduced so the estimated loss stays inside the
        V12.6 risk budget. This is sizing discipline, not a wider stop.
        """
        price = max(safe_float(pred.get('price')), 1e-12)
        atr = max(safe_float(pred.get('atr')), 1e-12)
        stop_atr = max(0.30, min(0.80, safe_float(pred.get('planned_risk_atr'), self.v126_assumed_stop_atr)))
        stop_move = stop_atr * atr
        unit_risk = stop_move / price + (2.0 * self.estimated_commission_rate * self.fee_slippage_buffer)
        if unit_risk <= 0:
            return self.fixed_notional, 0.0, 'risk model unavailable'
        risk_cap = max(0.50, safe_float(self.v126_expert_max_risk_dollars, 4.50))
        max_by_risk = risk_cap / unit_risk
        cap = min(self.fixed_notional, safe_float(self.v126_expert_max_notional, self.fixed_notional))
        floor = max(self.min_notional, safe_float(self.v126_expert_min_notional, self.min_notional))
        notional = min(cap, max_by_risk)
        if notional < floor:
            return 0.0, notional * unit_risk, f'technical risk requires notional ${notional:.2f} below minimum ${floor:.2f}'
        return notional, notional * unit_risk, 'risk-sized'

    def _v126_expert_evaluate(self, symbol: str, side: str, route: dict, pred: dict) -> dict:
        """V12.8 Expert Market Brain.

        The engine no longer assumes that every profitable opportunity must be
        caught before the first expansion. It distinguishes:

        PREDICTIVE  -> early setup, preferably LIMIT/retrace.
        CONTINUATION -> an already-moving trend with enough live evidence and
                        remaining statistical room to justify a smaller MARKET entry.

        The brain still refuses exhausted/choppy moves. It does not wait for a
        mathematically perfect setup, but it also does not chase blindly.
        """
        if not self.v126_expert_enabled:
            return dict(pred, expert_ok=bool(pred.get('ok')), expert_score=safe_float(pred.get('score')),
                        expert_decision='ARM_LIMIT' if pred.get('ok') else 'WAIT',
                        expert_reason='expert layer disabled', expert_mode='PREDICTIVE')

        regime = str(route.get('regime', 'unknown'))
        regime_score, regime_block, regime_reason = self._v126_regime_quality(regime, pred)
        prediction = safe_float(pred.get('prediction'))
        premove = safe_float(pred.get('pre_move_remaining'))
        structure = safe_float(pred.get('structure'))
        consumed = safe_float(pred.get('move_consumed_pct'))
        extension = safe_float(pred.get('extension_atr'), 99.0)
        pressure = safe_float(pred.get('pressure_atr'), 99.0)
        rr_pred = safe_float(pred.get('rr'))
        expected_net_raw = safe_float(pred.get('expected_net'))
        room = safe_float(pred.get('room_atr'))
        compression = safe_float(pred.get('compression'))
        f1 = self.market_features(symbol, '1m') or {}
        f5 = self.market_features(symbol, '5m') or {}
        adx5 = safe_float(f5.get('adx'))
        mom1 = safe_float(f1.get('momentum'))
        mom5 = safe_float(f5.get('momentum'))
        slope5 = safe_float(f5.get('ema_slope'))
        trend5 = f5.get('trend', 0)
        want = 1 if side == 'BUY' else -1
        live_trend_ok = trend5 == want
        live_momentum_ok = (mom1 >= self.v128_cont_min_momentum if want > 0 else mom1 <= -self.v128_cont_min_momentum)
        five_momentum_ok = (mom5 >= 0 if want > 0 else mom5 <= 0)
        slope_ok = (slope5 > 0 if want > 0 else slope5 < 0)
        live_strength = (adx5 >= self.v128_cont_min_adx) or (live_trend_ok and slope_ok)

        # Hard safety vetoes apply to BOTH modes.
        hard = []
        if regime_block:
            hard.append(regime_reason)
        if extension >= self.v128_cont_hard_extension:
            hard.append(f'extension {extension:.2f}ATR: exhausted/chase risk')
        if pressure > 1.10:
            hard.append(f'pressure {pressure:.2f}ATR: impulse too mature')
        if consumed > 82.0:
            hard.append(f'move consumed {consumed:.0f}%')
        if prediction < 65.0:
            hard.append(f'prediction {prediction:.0f}% too weak')
        if structure < 50.0:
            hard.append(f'structure {structure:.0f}/100 weak')

        # ---------------- PREDICTIVE MODE ----------------
        tier = 'REJECT'
        mode = 'NONE'
        notional_fraction = 0.0
        decision = 'WAIT'
        tier_reason = 'no actionable opportunity'
        blockers = list(hard)

        predictive_ok = False
        if not hard:
            if (prediction >= self.v127_tier_a_prediction and premove >= self.v127_tier_a_premove
                and consumed <= self.v127_tier_a_consumed and extension <= self.v127_tier_a_extension
                and pressure <= self.v127_tier_a_pressure and rr_pred >= self.v127_tier_a_rr
                and structure >= self.v127_tier_a_structure and expected_net_raw >= self.v127_tier_a_expected_net):
                tier, notional_fraction, tier_reason = 'A', 1.00, 'excellent predictive setup'
                predictive_ok = True
            elif (prediction >= self.v127_tier_b_prediction and premove >= self.v127_tier_b_premove
                  and consumed <= self.v127_tier_b_consumed and extension <= self.v127_tier_b_extension
                  and pressure <= self.v127_tier_b_pressure and rr_pred >= self.v127_tier_b_rr
                  and structure >= self.v127_tier_b_structure and expected_net_raw >= self.v127_tier_b_expected_net):
                tier, notional_fraction, tier_reason = 'B', self.v127_tier_b_max_notional_fraction, 'good predictive setup'
                predictive_ok = True
            elif (prediction >= self.v127_tier_c_prediction and premove >= self.v127_tier_c_premove
                  and consumed <= self.v127_tier_c_consumed and extension <= self.v127_tier_c_extension
                  and pressure <= self.v127_tier_c_pressure and rr_pred >= self.v127_tier_c_rr
                  and structure >= self.v127_tier_c_structure and expected_net_raw >= self.v127_tier_c_expected_net):
                tier, notional_fraction, tier_reason = 'C', self.v127_tier_c_max_notional_fraction, 'acceptable predictive setup; reduced risk'
                predictive_ok = True

        # ---------------- V12.8.1 MICRO-TREND MODE ----------------
        # Controlled participation in smaller developing moves. This mode is
        # deliberately smaller and remains subject to every hard safety veto.
        micro_blockers = []
        micro_score = (
            0.28 * prediction + 0.18 * structure +
            0.14 * min(100.0, max(0.0, premove + 20.0)) +
            0.12 * (100.0 if live_trend_ok else 35.0) +
            0.12 * (100.0 if live_momentum_ok else 35.0) +
            0.08 * (100.0 if slope_ok else 40.0) +
            0.08 * min(100.0, adx5 * 4.0)
        )
        micro_ok = self.v1281_micro_enabled and not hard
        if prediction < self.v1281_micro_min_prediction: micro_blockers.append('micro prediction weak')
        if safe_float(pred.get('margin')) < self.v1281_micro_min_margin: micro_blockers.append('micro direction margin weak')
        if structure < self.v1281_micro_min_structure: micro_blockers.append('micro structure weak')
        if premove < self.v1281_micro_min_remaining: micro_blockers.append('micro remaining room low')
        if extension > self.v1281_micro_max_extension: micro_blockers.append('micro extension too mature')
        if pressure > self.v1281_micro_max_pressure: micro_blockers.append('micro pressure too high')
        if adx5 < self.v1281_micro_min_adx and not (live_trend_ok and live_momentum_ok and slope_ok): micro_blockers.append('micro trend strength weak')
        if not live_momentum_ok: micro_blockers.append('micro 1m momentum not aligned')
        if not five_momentum_ok: micro_blockers.append('micro 5m momentum not aligned')
        if not slope_ok: micro_blockers.append('micro EMA slope not aligned')
        micro_ok = micro_ok and not micro_blockers and micro_score >= self.v1281_micro_min_score

        # ---------------- CONTINUATION MODE ----------------
        # This is the missing behaviour the user has been asking for: if the move
        # already started, the engine can participate rather than simply declaring
        # WAIT, provided the live tape says the trend is still healthy.
        continuation_score = (
            0.30 * prediction +
            0.18 * structure +
            0.15 * min(100.0, max(0.0, premove + 35.0)) +
            0.12 * (100.0 if live_trend_ok else 35.0) +
            0.10 * (100.0 if live_momentum_ok else 35.0) +
            0.08 * (100.0 if slope_ok else 40.0) +
            0.07 * min(100.0, adx5 * 3.0)
        )
        continuation_reasons = []
        continuation_blockers = []
        if not self.v128_enabled:
            continuation_blockers.append('continuation brain disabled')
        if regime_block:
            continuation_blockers.append(regime_reason)
        if prediction < self.v128_cont_min_prediction:
            continuation_blockers.append(f'prediction {prediction:.0f}% below continuation {self.v128_cont_min_prediction:.0f}%')
        if safe_float(pred.get('margin')) < self.v128_cont_min_margin:
            continuation_blockers.append(f'direction margin {safe_float(pred.get("margin")):.0f}% weak')
        if structure < self.v128_cont_min_structure:
            continuation_blockers.append(f'structure {structure:.0f}/100 weak')
        if premove < self.v128_cont_min_remaining:
            continuation_blockers.append(f'remaining move {premove:.0f}% too low')
        if extension > self.v128_cont_max_extension:
            continuation_blockers.append(f'extension {extension:.2f}ATR beyond continuation zone')
        if pressure > self.v128_cont_max_pressure:
            continuation_blockers.append(f'pressure {pressure:.2f}ATR too mature')
        if not live_strength:
            continuation_blockers.append(f'live trend strength weak (ADX {adx5:.1f})')
        if not live_momentum_ok:
            continuation_blockers.append('1m momentum not aligned')
        if not five_momentum_ok:
            continuation_blockers.append('5m momentum not aligned')
        if not slope_ok:
            continuation_blockers.append('EMA slope not aligned')
        vr = safe_float(f5.get('volume_ratio'), 1.0)
        if vr > self.v128_cont_volume_max:
            continuation_blockers.append(f'volume {vr:.2f}x looks like blow-off')
        if 'low_vol' in regime.lower() and adx5 < self.v128_cont_min_adx:
            continuation_blockers.append('low-vol continuation lacks trend strength')

        continuation_ok = (self.v128_enabled and not hard and not continuation_blockers
                           and continuation_score >= self.v128_cont_min_score)
        if continuation_ok:
            mode = 'CONTINUATION'
            tier = 'D'
            strong_cont = (prediction >= 82 and structure >= 68 and adx5 >= 24 and
                            live_momentum_ok and slope_ok and extension <= 1.25 and premove >= 25)
            notional_fraction = self.v128_cont_strong_fraction if strong_cont else self.v128_cont_notional_fraction
            tier_reason = 'strong live continuation' if strong_cont else 'healthy trend continuation'
            decision = 'ENTER_MARKET' if self.v128_allow_market_entry else 'WAIT'
            continuation_target_atr = max(0.65, self.v128_cont_target_atr)
            continuation_stop_atr = max(0.40, self.v128_cont_stop_atr)
            continuation_rr = continuation_target_atr / continuation_stop_atr
            qty_est = self.fixed_notional / max(safe_float(pred.get('price')), 1e-12)
            gross_reward = continuation_target_atr * safe_float(pred.get('atr')) * qty_est
            continuation_cost = self.fixed_notional * self.estimated_commission_rate * 2.0
            continuation_expected = gross_reward - continuation_cost
        else:
            continuation_rr = 0.0
            continuation_expected = 0.0

        if not predictive_ok and not continuation_ok and micro_ok:
            mode = 'MICRO_TREND'
            tier = 'M1'
            notional_fraction = self.v1281_micro_notional_fraction
            decision = 'ENTER_MARKET' if self.v128_allow_market_entry else 'WAIT'
            tier_reason = 'controlled micro-trend opportunity'
            continuation_rr = 1.15
            continuation_expected = max(0.0, expected_net_raw * 0.55)

        # A predictive opportunity has priority because it is economically better
        # positioned. Otherwise the continuation brain is allowed to participate.
        if predictive_ok and not hard:
            mode = 'PREDICTIVE'
            decision = 'ARM_LIMIT'
        elif continuation_ok:
            mode = 'CONTINUATION'
        else:
            if not hard:
                blockers.extend(continuation_blockers[:3])
                if not predictive_ok and tier == 'REJECT':
                    blockers.append('predictive tier not reached')

        # Continuous ranking score. Continuation is deliberately penalised a little
        # versus a comparable early setup, but it is no longer invisible to the bot.
        location_score = max(0.0, min(100.0, 55.0 + min(2.0, max(0.0, room)) * 22.5))
        rr_score = max(0.0, min(100.0, rr_pred / 2.0 * 70.0))
        net_score = max(0.0, min(100.0, expected_net_raw / 2.0 * 70.0))
        expert_score = (0.34 * prediction + 0.16 * structure + 0.14 * premove +
                        0.10 * (100.0 if live_trend_ok else 35.0) +
                        0.10 * (100.0 if live_momentum_ok else 35.0) +
                        0.06 * rr_score + 0.05 * net_score + 0.03 * regime_score +
                        0.02 * location_score)
        if mode == 'CONTINUATION':
            expert_score = min(100.0, expert_score + 5.0)
        cross = self.cross_symbol_context(symbol)
        cross_penalty = min(safe_float(cross.get('penalty', 0.0)), self.v126_expert_cross_penalty)
        expert_score = max(0.0, min(100.0, expert_score - cross_penalty))

        # Size according to technical risk, then apply mode fraction. Continuation
        # deliberately uses smaller size than an early predictive setup.
        if mode in ('CONTINUATION', 'MICRO_TREND'):
            pred_for_size = dict(pred)
            pred_for_size['planned_risk_atr'] = self.v128_cont_stop_atr
            notional, estimated_risk, sizing_reason = self._v126_expert_notional(pred_for_size)
            notional *= notional_fraction
            expected_net = continuation_expected * (notional / max(self.fixed_notional, 1e-12))
            execution = 'MARKET'
            if mode == 'MICRO_TREND':
                expected_net = max(0.0, expected_net_raw * 0.55) * (notional / max(self.fixed_notional, 1e-12))
            if continuation_rr < self.v128_cont_min_rr:
                blockers.append(f'continuation R:R {continuation_rr:.2f} below {self.v128_cont_min_rr:.2f}')
                decision = 'WAIT'; mode = 'NONE'; tier = 'REJECT'; notional = 0.0
            if expected_net < (self.v1281_micro_min_expected_net if mode == 'MICRO_TREND' else 0.40):
                blockers.append(f'continuation expected net ${expected_net:.2f} too small')
                decision = 'WAIT'; mode = 'NONE'; tier = 'REJECT'; notional = 0.0
        else:
            notional, estimated_risk, sizing_reason = self._v126_expert_notional(pred)
            if mode == 'PREDICTIVE':
                notional *= notional_fraction
                expected_net = expected_net_raw * (notional / max(self.fixed_notional, 1e-12))
                min_scaled = 0.60 if tier == 'C' else 0.85 if tier == 'B' else self.v127_tier_a_expected_net
                if expected_net < min_scaled:
                    blockers.append(f'sized expected net ${expected_net:.2f} below tier minimum ${min_scaled:.2f}')
                    notional = 0.0
                    decision = 'WAIT'; tier = 'REJECT'; mode = 'NONE'
            else:
                expected_net = expected_net_raw
                notional = 0.0
            execution = 'LIMIT_RETRACE' if premove < 70 else 'LIMIT'

        if notional < self.min_notional:
            if mode != 'NONE':
                blockers.append(f'notional ${notional:.2f} below minimum ${self.min_notional:.2f}')
            notional = 0.0
            decision = 'WAIT'
            if tier != 'REJECT':
                tier = 'REJECT'
                mode = 'NONE'

        expert_ok = bool(decision in ('ARM_LIMIT','ENTER_MARKET') and not blockers and not hard and notional > 0)
        if not expert_ok and decision == 'ENTER_MARKET':
            decision = 'WAIT'
        reason = (f'{mode} | {tier}: {tier_reason}' if expert_ok
                  else '; '.join(dict.fromkeys(blockers))[:500] or 'no qualified opportunity')
        out = dict(pred)
        out.update({
            'expert_ok': expert_ok,
            'expert_score': expert_score,
            'expert_decision': decision,
            'expert_regime_score': regime_score,
            'expert_location_score': location_score,
            'expert_rr_score': rr_score,
            'expert_net_score': net_score,
            'expert_cross_penalty': cross_penalty,
            'expert_notional': notional,
            'expert_estimated_risk': estimated_risk,
            'expert_scaled_expected_net': expected_net,
            'expert_execution': execution,
            'expert_blockers': blockers,
            'expert_reason': reason,
            'expert_tier': tier,
            'expert_notional_fraction': notional_fraction,
            'expert_mode': mode,
            'continuation_score': continuation_score,
            'continuation_rr': continuation_rr,
            'continuation_expected_net': continuation_expected,
            'live_adx': adx5,
            'live_momentum_1m': mom1,
            'live_momentum_5m': mom5,
            'live_trend_ok': live_trend_ok,
            'live_momentum_ok': live_momentum_ok,
        })
        return out

    def _v126_rank_key(self, item: dict) -> tuple:
        """Rank quality first, economics second, raw prediction third."""
        return (
            safe_float(item.get('expert_score', 0.0)),
            safe_float(item.get('expert_scaled_expected_net', item.get('expected_net', 0.0))),
            safe_float(item.get('prediction', 0.0)),
            safe_float(item.get('pre_move_remaining', 0.0))
        )

    def _v126_apply_thesis(self, p: Position, thesis: dict, source: str = 'ENTRY') -> None:
        """Attach the original entry thesis to a live position for reality checks."""
        if not p:
            return
        key = (p.symbol, p.side)
        state = self.v126_thesis_states.setdefault(key, {})
        state.update({
            'side': p.side,
            'prediction': safe_float(thesis.get('prediction', getattr(p, '_v12_prediction', 0))),
            'expert_score': safe_float(thesis.get('expert_score', getattr(p, '_v126_expert_score', 0))),
            'pre_move': safe_float(thesis.get('pre_move_remaining', getattr(p, '_v12_premove', 0))),
            'expected_move_atr': safe_float(thesis.get('expected_move_atr', 0)),
            'entry_extension_atr': safe_float(thesis.get('extension_atr', 0)),
            'entry_pressure_atr': safe_float(thesis.get('pressure_atr', 0)),
            'entry_rr': safe_float(thesis.get('rr', 0)),
            'entry_regime': thesis.get('regime', getattr(p, 'regime', 'unknown')),
            'entry_price': p.entry_price,
            'created': state.get('created', time.time()),
            'last_check': time.time(),
            'state': state.get('state', 'THESIS_ACTIVE'),
            'source': source,
        })
        p._v12_prediction = safe_float(thesis.get('prediction', 0))
        p._v12_prediction_score = safe_float(thesis.get('pre_move_remaining', 0))
        p._v126_expert_score = safe_float(thesis.get('expert_score', 0))
        p._v126_expert_notional = safe_float(thesis.get('expert_notional', p.notional))
        p._v126_thesis_state = state['state']
        save_open_position(p, self.db_path)

    def _v126_monitor_thesis(self, p: Position, net_pnl: float) -> dict:
        """Compare current market behavior with the original entry thesis.

        PNL is deliberately not the thesis. A small early adverse move is normal;
        thesis failure requires adverse displacement plus time and structural evidence.
        """
        key = (p.symbol, p.side)
        state = self.v126_thesis_states.get(key)
        if not state:
            return {'state': 'UNTRACKED', 'failure': False, 'adverse_atr': 0.0, 'reason': 'thesis state unavailable'}
        now = time.time()
        if now - safe_float(state.get('last_check', 0.0)) < self.v126_thesis_recheck_seconds:
            return {'state': state.get('state', 'THESIS_ACTIVE'), 'failure': state.get('state') == 'THESIS_FAILURE', 'adverse_atr': safe_float(state.get('adverse_atr', 0.0)), 'reason': state.get('reason', '')}
        state['last_check'] = now
        f5 = self.market_features(p.symbol, '5m') or {}
        atr = max(safe_float(f5.get('atr')), 1e-12)
        price = safe_float(p.current_price)
        adverse = max(0.0, (p.entry_price - price) / atr if p.side == 'BUY' else (price - p.entry_price) / atr)
        favorable = max(0.0, (price - p.entry_price) / atr if p.side == 'BUY' else (p.entry_price - price) / atr)
        direction = self.current_direction_confidence(p)
        state['adverse_atr'] = adverse
        state['favorable_atr'] = favorable
        state['direction_now'] = direction

        if favorable >= self.v126_thesis_recovery_atr and adverse <= self.v126_thesis_soft_adverse_atr:
            state['state'] = 'THESIS_WORKING'
            state['reason'] = f'favorable {favorable:.2f}ATR; direction {direction:.0f}'
        elif adverse < self.v126_thesis_soft_adverse_atr:
            state['state'] = 'THESIS_DEVELOPING'
            state['reason'] = f'normal early noise; adverse {adverse:.2f}ATR; direction {direction:.0f}'
        elif adverse >= self.v126_thesis_failure_atr and p.age_seconds() >= self.v126_thesis_failure_min_age and direction < self.v12_hold_direction_floor:
            state['state'] = 'THESIS_FAILURE'
            state['reason'] = f'thesis failure: adverse {adverse:.2f}ATR; direction {direction:.0f}'
        else:
            state['state'] = 'THESIS_STRESSED'
            state['reason'] = f'stressed but not failed: adverse {adverse:.2f}ATR; direction {direction:.0f}'
        p._v126_thesis_state = state['state']
        return {'state': state['state'], 'failure': state['state'] == 'THESIS_FAILURE', 'adverse_atr': adverse, 'favorable_atr': favorable, 'direction': direction, 'reason': state['reason']}

    def _v13_candle_trigger(self, f: dict, want: int) -> tuple:
        """Evaluate a fresh directional trigger without requiring a single large candle.

        A valid trigger can be either:
        1) a local structure break with directional close, or
        2) a pullback/reclaim sequence where the latest closed candle rejects the
           pullback and resumes the higher-timeframe direction.
        """
        df = f.get('df') if isinstance(f, dict) else None
        if df is None or len(df) < 10:
            return False, 0.0, 'trigger data unavailable'
        r = df.iloc[-1]; p = df.iloc[-2]; p2 = df.iloc[-3]
        def vals(row):
            o,h,l,c = map(float,(row['open'],row['high'],row['low'],row['close']))
            rng=max(h-l,1e-12); body=abs(c-o)/rng
            return o,h,l,c,rng,body
        o,h,l,c,rng,body=vals(r); po,ph,pl,pc,prng,pbody=vals(p); _,p2h,p2l,p2c,_,_=vals(p2)
        same = c>o if want>0 else c<o
        prev_same = pc>po if want>0 else pc<po
        close_extreme = ((c-l)/rng)>=0.62 if want>0 else ((h-c)/rng)>=0.62
        prior_high=float(df['high'].iloc[-5:-1].max()); prior_low=float(df['low'].iloc[-5:-1].min())
        breakout=(c>prior_high) if want>0 else (c<prior_low)
        # Pullback/reclaim: previous candle moved against direction, then latest candle
        # closes back through previous midpoint while preserving the broader direction.
        prev_opposite = (pc>po) if want<0 else (pc<po)
        reclaim = (c > (po+pc)/2 and same) if want>0 else (c < (po+pc)/2 and same)
        pullback_reclaim = prev_opposite and reclaim
        follow = (c>pc) if want>0 else (c<pc)
        # Local swing break is stronger than mere candle continuation.
        local_break = breakout
        score=0.0; reasons=[]
        if same: score+=22; reasons.append('directional close')
        if body>=0.45: score+=14; reasons.append('strong body')
        if close_extreme: score+=12; reasons.append('close near directional extreme')
        if local_break: score+=30; reasons.append('local structure break')
        elif pullback_reclaim: score+=30; reasons.append('pullback reclaim')
        elif follow: score+=10; reasons.append('follow-through')
        if prev_same: score+=6; reasons.append('previous candle agrees')
        return score>=self.v13_min_trigger_score, min(100.0,score), '; '.join(reasons) if reasons else 'no fresh directional trigger'

    def _v13_direction_gate(self, symbol: str, side: str, route: dict, pred: dict) -> dict:
        """Integrated Expert Brain entry gate.

        The legacy prediction is supporting evidence only. Direction comes first from
        independent multi-timeframe structure. Entry requires a fresh trigger, good
        location, available room, and no immediate opposite-direction evidence.
        """
        want=1 if side=='BUY' else -1
        fs={tf:(self.market_features(symbol,tf) or {}) for tf in ('1m','5m','15m','1h')}
        if not all(fs.values()):
            return {'ok':False,'score':0.0,'prediction':safe_float(pred.get('prediction')),'margin':safe_float(pred.get('margin')),'reason':'Expert Brain data unavailable'}
        f1,f5,f15,f1h=fs['1m'],fs['5m'],fs['15m'],fs['1h']
        t1,t5,t15,t1h=[int(x.get('trend',0)) for x in (f1,f5,f15,f1h)]
        htf=[t1h,t15,t5]
        htf_count=sum(x==want for x in htf)
        opp_count=sum(x==-want for x in (t1,t5,t15,t1h))
        prediction=safe_float(pred.get('prediction')); margin=safe_float(pred.get('margin'))
        adx5=safe_float(f5.get('adx')); adx15=safe_float(f15.get('adx'))
        mom1=safe_float(f1.get('momentum')); mom5=safe_float(f5.get('momentum'))
        slope5=safe_float(f5.get('ema_slope')); slope15=safe_float(f15.get('ema_slope'))
        price=safe_float(f5.get('live_price',f5.get('price',0))); atr=max(safe_float(f5.get('atr')),1e-12)
        ema20=safe_float(f5.get('ema20'),price); dist=abs(price-ema20)/atr if price>0 else 99
        room=safe_float(pred.get('room_atr'),0); extension=safe_float(pred.get('extension_atr'),99); pressure=safe_float(pred.get('pressure_atr'),99)
        vr=safe_float(f5.get('volume_ratio'),0)
        trig1_ok,trig1_score,trig1_reason=self._v13_candle_trigger(f1,want)
        trig5_ok,trig5_score,trig5_reason=self._v13_candle_trigger(f5,want)
        trigger_score=0.65*trig1_score+0.35*trig5_score
        # 1m is a timing layer: neutral is acceptable; opposite direction is not.
        one_min_ok=t1 in (0,want)
        mom1_ok=(mom1>=self.v13_min_momentum_1m if want>0 else mom1<=-self.v13_min_momentum_1m)
        mom5_ok=(mom5>=self.v13_min_momentum_5m if want>0 else mom5<=-self.v13_min_momentum_5m)
        slope5_ok=(slope5>0 if want>0 else slope5<0); slope15_ok=(slope15>0 if want>0 else slope15<0)
        location_ok=((price>=ema20 and dist<=self.v13_max_chase_atr) if want>0 else (price<=ema20 and dist<=self.v13_max_chase_atr))
        room_ok=room>=self.v13_min_room_atr; extension_ok=extension<=self.v13_max_extension_atr; pressure_ok=pressure<=self.v13_max_pressure_atr
        volume_ok=self.v13_min_volume_ratio<=vr<=self.v13_max_volume_ratio
        regime=str(route.get('regime','unknown')).lower(); regime_ok=('chop' not in regime and 'unknown' not in regime)
        # Direction score must support the independently observed direction, but can never create it.
        direction_ok=prediction>=self.v13_min_prediction and margin>=self.v13_min_direction_margin
        htf_ok=(t1h==want and t15==want and htf_count>=self.v13_min_htf_agreement and t5==want)
        strength_ok=adx5>=self.v13_min_adx_5m and adx15>=self.v13_min_adx_15m
        trigger_ok=trigger_score>=self.v13_min_live_trigger and (trig1_ok or trig5_ok)
        blockers=[]
        if not htf_ok: blockers.append(f'HTF direction {htf_count}/3 aligned (1H={t1h},15M={t15},5M={t5})')
        if not one_min_ok: blockers.append('1M direction opposes selected side')
        if opp_count>=2: blockers.append(f'opposite evidence on {opp_count}/4 timeframes')
        if not direction_ok: blockers.append(f'prediction/margin weak ({prediction:.0f}/{margin:.0f})')
        if not strength_ok: blockers.append(f'ADX too weak (5M {adx5:.1f},15M {adx15:.1f})')
        if not mom1_ok: blockers.append(f'1M momentum weak ({mom1:+.5f})')
        if not mom5_ok: blockers.append(f'5M momentum weak ({mom5:+.5f})')
        if not slope5_ok or not slope15_ok: blockers.append('EMA slopes not aligned')
        if not trigger_ok: blockers.append(f'fresh trigger weak ({trigger_score:.0f}/100)')
        if not location_ok: blockers.append(f'entry too extended from 5M EMA20 ({dist:.2f}ATR)')
        if not room_ok: blockers.append(f'forward room too small ({room:.2f}ATR)')
        if not extension_ok: blockers.append(f'extension too mature ({extension:.2f}ATR)')
        if not pressure_ok: blockers.append(f'pressure too mature ({pressure:.2f}ATR)')
        if not volume_ok: blockers.append(f'volume unsuitable ({vr:.2f}x)')
        if not regime_ok: blockers.append(f'regime unsuitable ({regime})')
        # Penalise late/chasing entries instead of letting a high score compensate for them.
        chase_penalty=max(0.0,(dist/max(self.v13_max_chase_atr,1e-9))*12.0)
        score=(35*(htf_count/3)+15*min(1,prediction/100)+10*min(1,max(0,margin)/40)+10*(1 if mom5_ok else 0)+8*(1 if mom1_ok else 0)+7*(1 if slope5_ok and slope15_ok else 0)+15*min(1,trigger_score/100))-chase_penalty
        score=max(0,min(100,score))
        return {'ok':not blockers,'score':score,'prediction':prediction,'margin':margin,'trend_count':htf_count,'opposite_count':opp_count,
                'adx5':adx5,'adx15':adx15,'mom1':mom1,'mom5':mom5,'slope5':slope5,'slope15':slope15,'dist_atr':dist,'room_atr':room,
                'extension_atr':extension,'pressure_atr':pressure,'volume_ratio':vr,'trigger_score':trigger_score,'trigger_ok':trigger_ok,
                'trigger_1m':trig1_reason,'trigger_5m':trig5_reason,'trend_alignment':f'{t1h}/{t15}/{t5}/{t1}',
                'reason':'EXPERT READY: 1H/15M/5M direction + live trigger confirmed' if not blockers else '; '.join(blockers[:7])}

    def _v13_can_enter_after_close(self, symbol: str, side: str, price: float, atr: float) -> tuple:
        """Require a genuine reset after any V12/V13 close before re-entry."""
        last = self.v13_last_exit.get(symbol)
        if not last:
            # Also honour the older close memory when a process has just upgraded.
            last = getattr(self, 'v12_last_close', {}).get(symbol)
        if not last:
            return True, 'fresh symbol — no previous V13 exit'
        age = time.time() - safe_float(last.get('time'), 0.0)
        if age < self.v13_reentry_cooldown:
            return False, f'V13 re-entry lock {self.v13_reentry_cooldown-age:.0f}s remaining after {last.get("side","UNKNOWN")} close'
        last_price = safe_float(last.get('price'), 0.0)
        if last_price > 0 and atr > 0:
            displacement = abs(price-last_price)/atr
            if displacement < self.v13_reentry_reset_atr:
                return False, f'V13 movement reset incomplete: {displacement:.2f}ATR from last exit'
        # Same-direction re-entry needs a fresh pullback/retest rather than chasing the
        # continuation that was just exited. A new opposite direction may be evaluated
        # normally, but still needs the full V13 gate.
        old_side = str(last.get('side','')).upper()
        if old_side == side and last_price > 0 and atr > 0:
            displacement = abs(price-last_price)/atr
            required_reset = self.v13_reentry_reset_atr + self.v13_reentry_pullback_atr
            if displacement < required_reset:
                return False, f'same-direction thesis not reset: only {displacement:.2f}ATR since exit (need {required_reset:.2f}ATR)'
            # Do not allow a same-direction re-entry merely because price travelled far.
            # A fresh trigger must be present in the current scan.
        return True, 're-entry reset complete — fresh thesis permitted'

    # -------------------------------------------------------------------------
    # V15 HISTORICAL LEARNING BRAIN
    # -------------------------------------------------------------------------
    def _v15_init_learning_db(self):
        if not getattr(self, 'learning_enabled', True):
            return
        try:
            with db(self.db_path) as conn:
                conn.execute("""CREATE TABLE IF NOT EXISTS v15_trade_learning (
                    trade_id TEXT PRIMARY KEY, symbol TEXT NOT NULL, side TEXT NOT NULL,
                    entry_time TEXT NOT NULL, exit_time TEXT, regime TEXT, strategy TEXT,
                    entry_price REAL, exit_price REAL, quantity REAL, notional REAL,
                    entry_score REAL DEFAULT 0, direction_confidence REAL DEFAULT 0,
                    htf_agreement REAL DEFAULT 0, trigger_type TEXT DEFAULT '',
                    extension_atr REAL DEFAULT 0, room_atr REAL DEFAULT 0, volume_ratio REAL DEFAULT 0,
                    momentum_1m REAL DEFAULT 0, momentum_5m REAL DEFAULT 0, adx5 REAL DEFAULT 0, adx15 REAL DEFAULT 0,
                    entry_location REAL DEFAULT 0, news_direction TEXT DEFAULT '', news_score REAL DEFAULT 0,
                    news_confidence REAL DEFAULT 0, news_impact REAL DEFAULT 0, news_reaction_status TEXT DEFAULT '',
                    news_reaction_move_atr REAL DEFAULT 0, realized_pnl REAL DEFAULT 0, exit_reason TEXT DEFAULT '',
                    mfe_pnl REAL DEFAULT 0, mae_pnl REAL DEFAULT 0, mfe_price REAL DEFAULT 0, mae_price REAL DEFAULT 0,
                    mfe_time TEXT, thesis_correct INTEGER DEFAULT -1, continued_after_exit INTEGER DEFAULT -1,
                    post_exit_price REAL DEFAULT 0, post_exit_pnl REAL DEFAULT 0, closed INTEGER DEFAULT 0,
                    updated_at TEXT NOT NULL)""")
                conn.execute("CREATE INDEX IF NOT EXISTS idx_v15_sym_side ON v15_trade_learning(symbol,side)")
                conn.execute("CREATE INDEX IF NOT EXISTS idx_v15_regime ON v15_trade_learning(regime)")
        except Exception as exc:
            logger.warning("V15 learning DB init failed: %s", exc)

    def _v15_trade_id(self, p):
        return f"{p.symbol}:{p.side}:{p.entry_time}:{getattr(p,'entry_order_id','') or ''}"

    def _v15_capture_entry(self, p, f=None):
        if not getattr(self,'learning_enabled',True): return
        try:
            f=dict(f or getattr(p,'_learning_features',{}) or {})
            tid=self._v15_trade_id(p); now=iso_now()
            cols=['trade_id','symbol','side','entry_time','regime','strategy','entry_price','quantity','notional',
                  'entry_score','direction_confidence','htf_agreement','trigger_type','extension_atr','room_atr','volume_ratio',
                  'momentum_1m','momentum_5m','adx5','adx15','entry_location','news_direction','news_score','news_confidence',
                  'news_impact','news_reaction_status','news_reaction_move_atr','updated_at']
            vals=[tid,p.symbol,p.side,p.entry_time,getattr(p,'regime','unknown'),getattr(p,'_strategy',''),p.entry_price,p.quantity,p.notional,
                  safe_float(f.get('expert_score',getattr(p,'entry_quality_score',0))),safe_float(f.get('direction_confidence',getattr(p,'last_direction_confidence',0))),
                  safe_float(f.get('htf_agreement',0)),str(f.get('trigger_type','V13_TRIGGER')),safe_float(f.get('extension_atr',0)),safe_float(f.get('room_atr',0)),safe_float(f.get('volume_ratio',0)),
                  safe_float(f.get('momentum_1m',0)),safe_float(f.get('momentum_5m',0)),safe_float(f.get('adx5',0)),safe_float(f.get('adx15',0)),safe_float(f.get('entry_location',0)),
                  str(f.get('news_direction','')),safe_float(f.get('news_score',0)),safe_float(f.get('news_confidence',0)),safe_float(f.get('news_impact',0)),
                  str(f.get('news_reaction_status','')),safe_float(f.get('news_reaction_move_atr',0)),now]
            with db(self.db_path) as conn:
                placeholders=','.join('?' for _ in cols)
                conn.execute(f"INSERT OR REPLACE INTO v15_trade_learning ({','.join(cols)}) VALUES ({placeholders})",vals)
            self.learning_open[tid]={'mfe_pnl':0.0,'mae_pnl':0.0,'mfe_price':p.entry_price,'mae_price':p.entry_price,'mfe_time':now}
        except Exception as exc:
            logger.warning("V15 entry journal failed: %s",exc)

    def _v15_update_position(self,p,net_pnl):
        if not getattr(self,'learning_enabled',True): return
        try:
            tid=self._v15_trade_id(p); st=self.learning_open.setdefault(tid,{'mfe_pnl':0.0,'mae_pnl':0.0,'mfe_price':p.entry_price,'mae_price':p.entry_price,'mfe_time':iso_now()})
            if net_pnl>st['mfe_pnl']:
                st['mfe_pnl']=float(net_pnl); st['mfe_price']=float(p.current_price); st['mfe_time']=iso_now()
            if net_pnl<st['mae_pnl']:
                st['mae_pnl']=float(net_pnl); st['mae_price']=float(p.current_price)
            with db(self.db_path) as conn:
                conn.execute("UPDATE v15_trade_learning SET mfe_pnl=?,mae_pnl=?,mfe_price=?,mae_price=?,mfe_time=?,updated_at=? WHERE trade_id=?",(st['mfe_pnl'],st['mae_pnl'],st['mfe_price'],st['mae_price'],st['mfe_time'],iso_now(),tid))
        except Exception: pass

    def _v15_close_trade(self,p,exit_price,net_pnl,reason):
        if not getattr(self,'learning_enabled',True): return
        try:
            tid=self._v15_trade_id(p); st=self.learning_open.get(tid,{})
            move=(exit_price-p.entry_price) if p.side=='BUY' else (p.entry_price-exit_price)
            thesis=1 if move>0 else 0 if move<0 else -1
            with db(self.db_path) as conn:
                conn.execute("UPDATE v15_trade_learning SET exit_time=?,exit_price=?,realized_pnl=?,exit_reason=?,mfe_pnl=?,mae_pnl=?,mfe_price=?,mae_price=?,mfe_time=?,thesis_correct=?,closed=1,updated_at=? WHERE trade_id=?",(iso_now(),exit_price,net_pnl,str(reason),safe_float(st.get('mfe_pnl',getattr(p,'peak_net_pnl',0))),safe_float(st.get('mae_pnl',0)),safe_float(st.get('mfe_price',exit_price)),safe_float(st.get('mae_price',p.entry_price)),st.get('mfe_time'),thesis,iso_now(),tid))
            self.learning_open.pop(tid,None)
        except Exception as exc: logger.warning("V15 close journal failed: %s",exc)

    def _v15_update_post_exit(self):
        if not getattr(self,'learning_enabled',True) or time.time()-self.learning_last_update<self.learning_update_seconds: return
        self.learning_last_update=time.time()
        try:
            now=time.time(); rows=[]
            with db(self.db_path) as conn:
                rows=conn.execute("SELECT trade_id,symbol,side,entry_price,exit_price,exit_time FROM v15_trade_learning WHERE closed=1 AND continued_after_exit=-1 AND exit_time IS NOT NULL ORDER BY exit_time DESC LIMIT 20").fetchall()
            for tid,symbol,side,entry_price,exit_price,exit_time in rows:
                try: age=now-datetime.fromisoformat(exit_time.replace('Z','+00:00')).timestamp()
                except Exception: continue
                if age<60: continue
                price=self.get_mark_price(symbol,force=False)
                if price<=0: continue
                post_pnl=((price-exit_price) if side=='BUY' else (exit_price-price))*(1.0)
                # After five minutes, record whether the original directional move continued.
                if age>=300:
                    continued=1 if post_pnl>0 else 0 if post_pnl<0 else -1
                    with db(self.db_path) as conn:
                        conn.execute("UPDATE v15_trade_learning SET continued_after_exit=?,post_exit_price=?,post_exit_pnl=?,updated_at=? WHERE trade_id=?",(continued,price,post_pnl,iso_now(),tid))
        except Exception as exc: logger.debug("V15 post-exit update failed: %s",exc)

    def _v15_stats(self,symbol,side,regime):
        try:
            with db(self.db_path) as conn:
                rows=conn.execute("SELECT realized_pnl FROM v15_trade_learning WHERE closed=1 AND symbol=? AND side=? AND regime=?",(symbol,side,regime)).fetchall()
            vals=[safe_float(r[0]) for r in rows]; n=len(vals)
            return n,(sum(v>0 for v in vals)/n if n else 0.5),(sum(vals)/n if n else 0.0)
        except Exception: return 0,0.5,0.0

    def _v15_learning_adjustment(self,pred):
        # IMPORTANT: learning is not a gate. It can only modestly rank and size a setup
        # that has already passed the V13 technical/news gates.
        if not getattr(self,'learning_enabled',True): return 0.0,1.0,(0,0.5,0.0)
        n,wr,exp=self._v15_stats(str(pred.get('symbol','')),str(pred.get('side','')),str(pred.get('regime','')))
        if n<self.learning_min_samples: return 0.0,1.0,(n,wr,exp)
        reliability=min(1.0,(n-self.learning_min_samples+1)/25.0)
        adj=max(-self.learning_max_score_adjustment,min(self.learning_max_score_adjustment,(wr-0.50)*20.0*reliability))
        if wr<0.45 or exp<-0.25: factor=0.82
        elif wr<0.50 or exp<0: factor=0.90
        elif wr>=0.62 and exp>0: factor=1.05
        elif wr>=0.70 and exp>0.20: factor=1.10
        else: factor=1.0
        return adj,max(self.learning_min_size_factor,min(self.learning_max_size_factor,factor)),(n,wr,exp)

    def _v13_record_exit(self, symbol: str, price: float, side: str, reason: str):
        record={'time':time.time(),'price':price,'side':side,'reason':reason}
        self.v13_last_exit[symbol]=record
        self.v12_last_close[symbol]=record

    def scan_entries_v13(self):
        """V13 strict direction-first scanner.

        Only direct MARKET entries are allowed. Predictive LIMIT and MICRO_TREND
        participation are intentionally disabled because the current objective is
        directional accuracy, not trade frequency.
        """
        if not self.v13_enabled or self.paused or not self.state_known or not self.v12_enabled:
            return
        if self.total_daily_trade_count() >= self.max_daily_trades:
            return
        if self.v12_entries_today >= self.v12_max_daily_entries:
            return
        live_positions = sum(len(v) for v in self.positions.values())
        if live_positions >= self.max_open_positions:
            return
        ranked=[]
        for symbol in self.symbols:
            try:
                if self.positions.get(symbol):
                    self.v12_last_entry_state[symbol] = {'state':'WAIT','reason':'position already open'}
                    continue
                # Cancel/ignore old predictive orders. V13 must never inherit a stale
                # V12 limit thesis and fill it later without fresh confirmation.
                if symbol in getattr(self,'v126_pending_entries',{}):
                    try:
                        self._v126_cancel_pending(symbol, 'V13 strict mode: stale predictive entry disabled')
                    except Exception:
                        pass
                route=self.strategy_router(symbol)
                self.latest_market_context[symbol]=route
                # Independent direction vote. The legacy route may rank opportunities,
                # but it is never allowed to choose a direction that the HTF structure rejects.
                tf_vote={}
                for tf in ('1h','15m','5m','1m'):
                    ff=self.market_features(symbol,tf) or {}
                    tf_vote[tf]=int(ff.get('trend',0)) if ff else 0
                if tf_vote.get('1h') in (1,-1) and tf_vote.get('15m')==tf_vote.get('1h') and tf_vote.get('5m')==tf_vote.get('1h'):
                    side='BUY' if tf_vote['1h']==1 else 'SELL'
                else:
                    side=''
                if side not in ('BUY','SELL'):
                    self.v12_last_entry_state[symbol]={'state':'NO_DIRECTION','reason':'V13 found no directional winner'}
                    continue
                pred=self._v12_prediction(symbol,route,side)
                f5=self.market_features(symbol,'5m') or {}
                price=safe_float(f5.get('live_price',f5.get('price',0)))
                atr=max(safe_float(f5.get('atr')),1e-12)
                allowed,slot_reason=self._v13_can_enter_after_close(symbol,side,price,atr)
                gate=self._v13_direction_gate(symbol,side,route,pred)
                # The dashboard receives the entire live decision, not just the entry snapshot.
                expert=dict(pred)
                expert.update({
                    'symbol':symbol,'side':side,'regime':route.get('regime','unknown'),
                    'state':'WAIT','expert_mode':'V13_DIRECTION_FIRST','expert_decision':'WAIT',
                    'expert_ok':False,'expert_score':gate['score'],'direction_confidence':gate['prediction'],
                    'live_adx':gate['adx5'],'live_adx_15m':gate['adx15'],
                    'live_momentum_1m':gate['mom1'],'live_momentum_5m':gate['mom5'],
                    'live_trend_ok':gate['trend_count']==4,'live_momentum_ok':gate['mom1']*(1 if side=='BUY' else -1)>=self.v13_min_momentum_1m,
                    'v13_trigger_score':gate['trigger_score'],'v13_trend_alignment':gate['trend_alignment'],
                    'v13_room_atr':gate['room_atr'],'v13_dist_ema_atr':gate['dist_atr'],
                    'v13_reentry_ok':allowed,'v13_reentry_reason':slot_reason,
                })
                blockers=list(gate.get('reason','').split('; ')) if gate.get('reason') else []
                if not allowed: blockers.insert(0,slot_reason)
                if not gate.get('ok'): expert['expert_blockers']=blockers
                if allowed and gate.get('ok'):
                    expert['expert_ok']=True
                    expert['expert_decision']='ENTER_MARKET'
                    expert['state']='MARKET_READY'
                    ranked.append(expert)
                else:
                    expert['expert_reason']='; '.join(blockers[:6]) if blockers else 'V13 WAIT'
                self.v12_last_entry_state[symbol]=expert
            except Exception as exc:
                logger.exception('V13 Direction-First scan failed %s: %s',symbol,exc)
                self.v12_last_entry_state[symbol]={'state':'ERROR','reason':str(exc)}

        if not ranked:
            return
        ranked.sort(key=lambda x:(safe_float(x.get('expert_score')), safe_float(x.get('prediction')), safe_float(x.get('v13_trigger_score'))), reverse=True)
        pred=ranked[0]
        symbol=pred['symbol']; side=pred['side']
        notional=min(self.fixed_notional, safe_float(getattr(self,'v126_expert_max_notional',self.fixed_notional)))
        # Highest confidence gets full size. Lower qualifying V13 entries use 70%.
        if safe_float(pred.get('expert_score')) < 92 or safe_float(pred.get('prediction')) < 88:
            notional*=0.70
        learn_adj, learn_size, learn_stats = self._v15_learning_adjustment(pred)
        pred['expert_score'] = safe_float(pred.get('expert_score',0)) + learn_adj
        notional *= learn_size
        notional = max(self.min_notional, min(notional, safe_float(getattr(self,'v126_expert_max_notional',self.fixed_notional))))
        pred['_learning_features'] = dict(pred)
        pred['_learning_features'].update({'htf_agreement': pred.get('v13_trend_alignment',''), 'trigger_type': pred.get('v13_trigger_type','V13_TRIGGER'), 'extension_atr': pred.get('extension_atr',0), 'room_atr': pred.get('v13_room_atr',0), 'volume_ratio': pred.get('volume_ratio',0), 'momentum_1m': pred.get('live_momentum_1m',0), 'momentum_5m': pred.get('live_momentum_5m',0), 'adx5': pred.get('live_adx',0), 'adx15': pred.get('live_adx_15m',0), 'entry_location': pred.get('v13_dist_ema_atr',0), 'news_direction': pred.get('v14_news_direction',''), 'news_score': pred.get('v14_news_score',0), 'news_confidence': pred.get('v14_news_confidence',0), 'news_impact': pred.get('v14_news_impact',0), 'news_reaction_status': pred.get('v14_reaction_status',''), 'news_reaction_move_atr': pred.get('v14_reaction_move_atr',0)})
        if learn_stats[0] >= self.learning_min_samples:
            logger.info('V15 LEARNING %s %s n=%d win=%.1f%% exp=%+.3f score_adj=%+.2f size_x=%.2f',symbol,side,learn_stats[0],learn_stats[1]*100,learn_stats[2],learn_adj,learn_size)
        reason=(f'V13 DIRECTION-FIRST {pred.get("expert_score",0):.0f}/100; {side}; '
                f'direction={pred.get("prediction",0):.0f}%; margin={pred.get("margin",0):.0f}%; '
                f'1H/15M/5M/1M={pred.get("v13_trend_alignment","")}; '
                f'1mMom={pred.get("live_momentum_1m",0):+.5f}; 5mMom={pred.get("live_momentum_5m",0):+.5f}; '
                f'ADX5={pred.get("live_adx",0):.1f}; ADX15={pred.get("live_adx_15m",0):.1f}; '
                f'trigger={pred.get("v13_trigger_score",0):.0f}/100; '
                f'location={pred.get("v13_dist_ema_atr",0):.2f}ATR from EMA20; '
                f'room={pred.get("v13_room_atr",0):.2f}ATR; fresh confirmed entry')
        if self.open_position(symbol,side,notional=notional,regime='V13_DIRECTION_FIRST',
                              loss_budget=min(4.50,max(2.50,safe_float(pred.get('expert_estimated_risk',3.0))*1.10)),
                              entry_quality=int(round(pred.get('expert_score',0))),
                              entry_reason=reason,direction_confidence=int(round(pred.get('prediction',0))),
                              strategy='v13_smart_brain'):
            self.last_global_trade_time=time.time()
            self.v12_entries_today+=1
            self.v13_entry_count_today+=1
            p_open=self.positions.get(symbol,{}).get(side)
            if p_open is not None:
                p_open._learning_features=dict(pred.get('_learning_features',pred))
                self._v15_capture_entry(p_open,p_open._learning_features)
            self.v12_last_entry_state[symbol].update({'state':'MARKET_ENTERED','reason':reason,'expert_mode':'V13_DIRECTION_FIRST','expert_notional':notional,'learning_adjustment':learn_adj,'learning_size_factor':learn_size})
            return
        self.v12_last_entry_state[symbol].update({'state':'WAIT','reason':'V13 final market entry attempt failed'})

    def scan_entries_v12_legacy(self):
        """V12.7 Adaptive Opportunity scanner: evaluate all symbols, rank opportunities,
        and arm exactly one predictive LIMIT entry at the best execution location.
        """
        if self.paused or not self.state_known or not self.v12_enabled:
            return
        if self.total_daily_trade_count() >= self.max_daily_trades or self.v12_entries_today >= self.v12_max_daily_entries:
            return
        self._v126_manage_pending_entries()
        live_positions = sum(len(v) for v in self.positions.values())
        if len(self.v126_pending_entries) >= max(1, int(self.v126_max_pending_entries)):
            return
        if live_positions + len(self.v126_pending_entries) >= self.max_open_positions:
            return

        ranked = []
        for symbol in self.symbols:
            try:
                if symbol in self.v126_pending_entries or self.positions.get(symbol):
                    continue
                route = self.strategy_router(symbol)
                self.latest_market_context[symbol] = route
                candidates = route.get('candidates', []) or []
                if candidates:
                    candidates = sorted(candidates, key=lambda c: safe_float((c.get('v7') or {}).get('direction_probability', c.get('direction', 0))), reverse=True)
                    side = str(candidates[0].get('side', '')).upper()
                else:
                    ld, sd = safe_float(route.get('long', 0)), safe_float(route.get('short', 0))
                    side = 'BUY' if ld > sd else 'SELL' if sd > ld else ''
                if side not in ('BUY', 'SELL'):
                    self.v12_last_entry_state[symbol] = {'state': 'NO_DIRECTION', 'reason': 'no directional thesis'}
                    continue

                pred = self._v12_prediction(symbol, route, side)
                f5 = self.market_features(symbol, '5m') or {}
                price = safe_float(pred.get('price', f5.get('live_price', f5.get('price', 0))))
                atr = safe_float(pred.get('atr', f5.get('atr', 0)))
                allowed, slot_reason = self._v12_can_enter_symbol(symbol, price, atr)
                pred.update({'slot_reason': slot_reason, 'side': side, 'state': 'WAIT', 'regime': route.get('regime', 'unknown')})

                expert = self._v126_expert_evaluate(symbol, side, route, pred)
                expert['symbol'] = symbol
                expert['side'] = side
                expert['regime'] = route.get('regime', 'unknown')
                expert['slot_allowed'] = allowed
                if not allowed:
                    expert['expert_ok'] = False
                    expert.setdefault('expert_blockers', []).append(slot_reason)
                    expert['expert_decision'] = 'WAIT'
                    expert['expert_reason'] = slot_reason
                if expert.get('expert_ok') and expert.get('expert_decision') in ('ARM_LIMIT', 'ENTER_MARKET'):
                    expert['state'] = 'MARKET_READY' if expert.get('expert_decision') == 'ENTER_MARKET' else 'LIMIT_READY'
                    ranked.append(expert)
                else:
                    expert['state'] = 'WAIT'
                self.v12_last_entry_state[symbol] = expert
            except Exception as exc:
                logger.exception('V12.8 Adaptive Market Brain scan failed %s: %s', symbol, exc)
                self.v12_last_entry_state[symbol] = {'state': 'ERROR', 'reason': str(exc)}

        if not ranked:
            return
        ranked.sort(key=self._v126_rank_key, reverse=True)
        pred = ranked[0]
        symbol = pred['symbol'] if pred.get('symbol') else None
        if not symbol:
            # _v12_prediction does not normally carry symbol; derive from latest context.
            for sym in self.symbols:
                if self.v12_last_entry_state.get(sym) is pred:
                    symbol = sym
                    break
        if not symbol:
            return
        side = str(pred.get('side', '')).upper()
        notional = safe_float(pred.get('expert_notional', self.fixed_notional))
        if notional < self.min_notional:
            self.v12_last_entry_state[symbol].update({'state': 'WAIT', 'reason': pred.get('expert_reason', 'notional below minimum')})
            return
        mode = pred.get('expert_mode', 'PREDICTIVE')
        if mode in ('CONTINUATION', 'MICRO_TREND') and pred.get('expert_decision') == 'ENTER_MARKET':
            # Late-entry protection: continuation trades need room after costs.
            remaining = safe_float(pred.get('pre_move_remaining', 0))
            extension = safe_float(pred.get('extension_atr', 0))
            expected_net = safe_float(pred.get('expert_scaled_expected_net', pred.get('expected_net', 0)))
            if remaining < 12 or extension > 1.60 or expected_net < 0.25:
                self.v12_last_entry_state[symbol] = dict(pred, state='WAIT',
                    expert_decision='WAIT', expert_reason=(
                        f'continuation timing filter: remaining={remaining:.1f}%, '
                        f'extension={extension:.2f}ATR, expected_net=${expected_net:.2f}'))
                return

            reason = (
                f'V12.8 {mode} {pred.get("expert_score", 0):.0f}: {side}; '
                f'prediction={pred.get("prediction", 0):.0f}%; remaining={pred.get("pre_move_remaining", 0):.0f}%; '
                f'ext={pred.get("extension_atr", 0):.2f}ATR; pressure={pred.get("pressure_atr", 0):.2f}ATR; '
                f'ADX={pred.get("live_adx", 0):.1f}; 1mMom={pred.get("live_momentum_1m", 0):+.5f}; '
                f'RR={pred.get("continuation_rr", 0):.2f}; size={notional:.0f}; join healthy trend'
            )
            # Adaptive participation: do not wait for a perfect score, but reduce exposure
            # when the trend is already extended or the remaining move is moderate.
            if remaining < 20 or extension > 1.10 or safe_float(pred.get('expert_score', 0)) < 82:
                notional = max(self.min_notional, notional * 0.65)
            continuation_loss_budget = min(4.00, max(2.00, safe_float(pred.get('expert_estimated_risk', 2.50)) * 1.20))
            if self.open_position(symbol, side, notional=notional, regime='V12_CONTINUATION',
                                  loss_budget=continuation_loss_budget, entry_quality=int(pred.get('expert_score', 0)),
                                  entry_reason=reason, direction_confidence=int(pred.get('prediction', 0)),
                                  strategy='v12_continuation'):
                self.last_global_trade_time = time.time()
                self.v12_last_entry_state[symbol].update({'reason': reason, 'state': 'MARKET_ENTERED',
                    'expert_mode': mode, 'expert_notional': notional})
                return
            self.v12_last_entry_state[symbol].update({'state': 'WAIT', 'reason': 'continuation entry attempt failed'})
            return

        limit_price = self._v126_limit_price(symbol, side, safe_float(pred.get('price')), safe_float(pred.get('atr')), pred.get('maturity', ''))
        reason = (
            f'V12.8 PREDICTIVE LIMIT {pred.get("expert_score", 0):.0f}: {pred.get("maturity")}; {side}; '
            f'prediction={pred.get("prediction", 0):.0f}%; remaining={pred.get("pre_move_remaining", 0):.0f}%; '
            f'used={pred.get("move_consumed_pct", 0):.0f}%; ext={pred.get("extension_atr", 0):.2f}ATR; '
            f'pressure={pred.get("pressure_atr", 0):.2f}ATR; R:R={pred.get("rr", 0):.2f}; '
            f'expected net=${pred.get("expert_scaled_expected_net", pred.get("expected_net", 0)):.2f}; '
            f'risk=${pred.get("expert_estimated_risk", 0):.2f}; LIMIT ${limit_price:,.6f}'
        )
        if self._v126_place_limit(symbol, side, limit_price, notional, pred):
            self.last_global_trade_time = time.time()
            pred['thesis_armed_at'] = time.time()
            self.v12_last_entry_state[symbol].update({
                'reason': reason, 'state': 'LIMIT_ARMED', 'limit_price': limit_price,
                'expert_score': pred.get('expert_score', 0), 'expert_notional': notional,
                'expert_expected_net': pred.get('expert_scaled_expected_net', pred.get('expected_net', 0))
            })
            self.v126_thesis_states[(symbol, side)] = {
                'state': 'THESIS_ARMED', 'prediction': pred.get('prediction', 0),
                'expert_score': pred.get('expert_score', 0), 'pre_move': pred.get('pre_move_remaining', 0),
                'expected_move_atr': pred.get('expected_move_atr', 0), 'entry_extension_atr': pred.get('extension_atr', 0),
                'entry_pressure_atr': pred.get('pressure_atr', 0), 'entry_rr': pred.get('rr', 0),
                'entry_regime': pred.get('regime', 'unknown'), 'entry_price': limit_price,
                'created': time.time(), 'last_check': time.time(), 'source': 'PREDICTIVE_LIMIT'
            }
            logger.info('V12.8 EXPERT LIMIT %s %s @ %s notional=%.2f expert=%.1f pred=%.1f premove=%.1f',
                        symbol, side, limit_price, notional, pred.get('expert_score', 0), pred.get('prediction', 0), pred.get('pre_move_remaining', 0))

    def scan_entries_v11(self):
        """V11 entry scanner.

        Flow: existing intelligence -> immediate execution snapshot -> chosen side.
        In PAIR_COMPARE mode both the analysed direction and its opposite are opened
        in Hedge Mode with half-notional each.  This makes the experiment directly
        comparable without doubling the configured total exposure.
        """
        if self.paused or not self.state_known or not self.v11_enabled:
            return
        if self.total_daily_trade_count() >= self.max_daily_trades:
            return
        self._v11_pair_limit_reset()
        if self.v11_pair_count >= self.v11_max_pairs_per_day:
            return

        now = time.time()
        # V11 intentionally has no long confirmation cooldown.  A short exchange/API
        # safety throttle remains through open_position itself.
        ranked = []
        for symbol in self.symbols:
            try:
                # This is the same intelligence/router used by V10.
                route = self.strategy_router(symbol)
                self.latest_market_context[symbol] = route
                source = self._v11_directional_source(symbol, route)

                if not source:
                    self.v11_last_entry[symbol] = {
                        'state': 'NO_DIRECTION', 'direction': max(route.get('long', 0), route.get('short', 0)),
                        'side': None, 'opposite': None, 'price': safe_float(route.get('price', 0)),
                        'reason': 'direction below V11 threshold'
                    }
                    continue

                direction = safe_float(source.get('_v11_direction', source.get('direction', 0)))
                side = str(source.get('side')).upper()
                opposite = 'SELL' if side == 'BUY' else 'BUY'
                live = self._v11_live_entry_check(symbol, route, source)
                self.v11_last_entry[symbol] = {
                    'state': 'READY' if live['ok'] else 'BLOCKED',
                    'direction': direction, 'side': side, 'opposite': opposite,
                    'price': live['price'], 'extension_atr': live['extension_atr'],
                    'reason': live['reason']
                }

                if not live['ok']:
                    continue
                # Do not enter if the analysed side is already occupied; pair comparison
                # requires a clean symbol so that both observations start together.
                if symbol in self.positions and self.positions[symbol]:
                    continue
                ranked.append((direction, symbol, route, source, live))
            except Exception as exc:
                logger.exception('V11 scan failed %s: %s', symbol, exc)

        if not ranked:
            return
        ranked.sort(key=lambda x: x[0], reverse=True)
        direction, symbol, route, source, live = ranked[0]
        analysed_side = source['side']
        reverse_side = 'SELL' if analysed_side == 'BUY' else 'BUY'
        pair_id = f'V11-{symbol}-{int(now*1000)}'

        # PAIR_COMPARE is the default experiment: same signal, same moment, opposite
        # hedge side. Each leg receives 50% of the normal notional.
        if self.v11_pair_compare and not self.v11_reverse_only:
            leg_notional = self.fixed_notional * self.v11_leg_notional_multiplier
            opened = []
            observation_until = now + self.v11_pair_observation_seconds
            observation_iso = datetime.fromtimestamp(now).isoformat()
            observation_until_iso = datetime.fromtimestamp(observation_until).isoformat()
            for role, side in (('ORIGINAL', analysed_side), ('REVERSE', reverse_side)):
                reason = (f'V11 PAIR {role}: analysed {analysed_side}; direction {direction:.0f}%; '
                          f'zero-lag execution at ${live["price"]:,.6f}; pair={pair_id}; '
                          f'observation={self.v11_pair_observation_seconds:.0f}s')
                ok = self.open_position(
                    symbol, side, notional=leg_notional, regime='V11_PAIR',
                    loss_budget=None, entry_quality=100,
                    entry_reason=reason, direction_confidence=int(direction),
                    strategy='v11_pair'
                )
                if ok:
                    opened.append((role, side))
                    p = self.positions.get(symbol, {}).get(side)
                    if p is not None:
                        p._v11_pair_id = pair_id
                        p._v11_role = role
                        p._v11_analysed_side = analysed_side
                        p._v11_direction = int(direction)
                        p._v11_observation_started = observation_iso
                        p._v11_observation_until = observation_until_iso
                        p._v11_survivor = False
                        save_open_position(p, self.db_path)

            if opened:
                self.v11_pair_count += 1
                self.last_global_trade_time = now
                self.v11_last_entry[symbol]['state'] = 'PAIR_OPENED'
                self.v11_last_entry[symbol]['pair_id'] = pair_id
                self.v11_last_entry[symbol]['opened'] = opened
                logger.info('V11 PAIR OPENED %s %s', symbol, opened)
        else:
            # Reverse-only mode: the experiment trades only against the analysed signal.
            reason = (f'V11 REVERSE: analysed {analysed_side} {direction:.0f}%; '
                      f'entered {reverse_side} immediately at ${live["price"]:,.6f}; zero-lag')
            ok = self.open_position(
                symbol, reverse_side,
                notional=self.fixed_notional,
                regime='V11_REVERSE', loss_budget=1.50,
                entry_quality=100, entry_reason=reason,
                direction_confidence=int(direction), strategy='v11_reverse'
            )
            if ok:
                self.v11_pair_count += 1
                self.last_global_trade_time = now
                p = self.positions.get(symbol, {}).get(reverse_side)
                if p is not None:
                    p._v11_pair_id = pair_id
                    p._v11_role = 'REVERSE'
                    p._v11_analysed_side = analysed_side
                    p._v11_direction = int(direction)
                    save_open_position(p, self.db_path)

    def scan_entries_v10(self):
        """Definitive V10 scanner: movement birth -> frozen origin -> fast entry -> snap.

        V10 does not wait for V9.2 ENTER_NOW, final-score gates, pullback gates or
        another intelligence cycle. Intelligence supplies direction; the movement
        detector supplies the event; the fast layer supplies execution.
        """
        if self.paused or not self.state_known or not self.v10_enabled:
            return
        if self.total_daily_trade_count() >= self.max_daily_trades:
            return
        now = time.time()
        if now - self.last_global_trade_time < self.global_trade_cooldown_seconds:
            return
        ranked=[]
        for symbol in self.symbols:
            try:
                route=self.strategy_router(symbol)
                self.latest_market_context[symbol]=route
                source=self._v10_directional_source(symbol, route)
                # Truthful dashboard state: preserve strategic intelligence even if no legacy candidate exists.
                if source:
                    d=safe_float(source.get('_v10_direction',source.get('direction',0)))
                    final=safe_float(source.get('_v10_final',source.get('score',0)))
                    self.latest_signals[symbol]=(source['side'],int(d),route.get('regime','unknown'),'WATCH',int(final),
                        source.get('reason','V10 directional thesis'),route.get('long',0),route.get('short',0),
                        source.get('strategy','trend'),source.get('rr',0),source.get('expected_net',0))
                else:
                    self.latest_signals[symbol]=('HOLD',max(route.get('long',0),route.get('short',0)),
                        route.get('regime','unknown'),'WAIT',0,'no directional thesis',route.get('long',0),route.get('short',0),'',0,0)

                thesis=self.v10_theses.get(symbol)
                # Expire old thesis, but never reset a valid thesis merely because a later
                # intelligence snapshot becomes temporarily weaker.
                if thesis and now-thesis['created'] > self.v10_thesis_max_age:
                    thesis['state']='EXPIRED'; self.v10_theses.pop(symbol,None); thesis=None

                # If a thesis exists and intelligence flips strongly, invalidate it rather
                # than letting a stale direction drive an entry.
                if thesis and source and source['side'] != thesis['side']:
                    old_d=safe_float(thesis.get('direction',0)); new_d=safe_float(source.get('_v10_direction',0))
                    if new_d >= self.v10_thesis_min_direction and new_d >= old_d + self.v10_thesis_refresh_direction_gap:
                        thesis['state']='INVALIDATED'; self.v10_theses.pop(symbol,None); thesis=None

                # A new thesis requires a movement-birth event. This is the crucial fix:
                # a high LONG score in the middle of a move is NOT a zero point.
                if thesis is None and source:
                    birth=self._v10_movement_birth(symbol,source['side'],route,now)
                    if birth['active'] and safe_float(source.get('_v10_direction',0)) >= self.v10_movement_start_direction:
                        atr=birth['atr']
                        # If the movement was born through a micro-break, anchor zero
                        # just beyond that break. Otherwise use the first live-tape
                        # baseline. This prevents the current, already-moved price
                        # from becoming a fake zero point.
                        if birth.get('micro_break'):
                            d1 = (self.market_features(symbol, '1m') or {}).get('df')
                            if d1 is not None and len(d1) >= 5:
                                prior_high=float(d1['high'].iloc[-5:-1].max())
                                prior_low=float(d1['low'].iloc[-5:-1].min())
                                origin=(prior_high + self.v10_movement_break_atr*atr) if source['side']=='BUY' else (prior_low - self.v10_movement_break_atr*atr)
                            else:
                                origin=birth['price']
                        else:
                            origin=safe_float(birth.get('baseline_price'),birth['price'])
                        origin=max(origin,1e-12)
                        expected_move=max(1.0*atr,0.80*atr)
                        thesis={'id':f'V10-{symbol}-{int(now)}','symbol':symbol,'side':source['side'],
                                'origin':origin,'expected_move':expected_move,'created':now,
                                'direction':safe_float(source.get('_v10_direction',source.get('direction',0))),
                                'strategy':source.get('strategy','trend'),'source':source,
                                'state':'BIRTH','snap_armed':False,'birth':birth}
                        self.v10_theses[symbol]=thesis
                        self.v10_last_movement[symbol]=birth

                if not thesis:
                    self.v10_last_fast_state[symbol]={'state':'NO_THESIS','consumed':0,'confirmations':0,'score':0}
                    continue

                fast=self._v10_fast_state(symbol,thesis['side'],thesis['origin'],thesis['expected_move'])
                consumed=fast['consumed']
                if consumed > self.v10_last_chance_max_consumed:
                    snap=self._v10_snap_state(symbol,thesis)
                    thesis['snap_armed']=snap['consumed'] >= self.v10_snap_arm_consumed
                    thesis['state']='SNAP_ARMED' if thesis['snap_armed'] else 'EXPIRED'
                    self.v10_last_fast_state[symbol]={'state':thesis['state'],**snap}
                    if thesis['snap_armed'] and snap['confirmations']>=2 and snap['score']>=self.v10_snap_min_score:
                        ranked.append((snap['score'],symbol,thesis,snap,'SNAP'))
                    continue

                zone=('EARLY' if consumed<=self.v10_early_max_consumed else
                      'PRIME' if consumed<=self.v10_prime_max_consumed else
                      'ACCEPT' if consumed<=self.v10_accept_max_consumed else 'LAST_CHANCE')
                thesis['state']=zone
                self.v10_last_fast_state[symbol]={'state':zone,**fast}
                # Early and prime: 2 confirmations. Accept/last chance require more.
                needed=2 if consumed<=self.v10_prime_max_consumed else 3 if consumed<=self.v10_accept_max_consumed else 4
                # Crucially, do not allow an entry whose thesis was created late.
                if fast['confirmations']>=needed and fast['score']>=self.v10_fast_min_score:
                    candidate=dict(thesis['source'])
                    candidate.update({'side':thesis['side'],'strategy':thesis.get('strategy','trend'),
                                      'score':int(max(fast['score'],thesis['direction'])),'direction':int(thesis['direction']),
                                      'notional_mult':safe_float(candidate.get('notional_mult',1.0),1.0),
                                      'loss_budget':candidate.get('loss_budget'),
                                      'reason':(f'V10 {zone} FAST ENTRY: fresh movement birth; thesis {thesis["direction"]:.0f}%; '
                                                f'move consumed {consumed:.1f}%; fast confirmations {fast["confirmations"]}/5; '
                                                f'origin ${thesis["origin"]:,.6f}')})
                    ranked.append((candidate['score'],symbol,thesis,candidate,'ENTRY'))
            except Exception as exc:
                logger.exception('V10 scan failed %s: %s',symbol,exc)

        if not ranked:
            return
        ranked.sort(key=lambda x:x[0],reverse=True)
        _,symbol,thesis,candidate,kind=ranked[0]
        if kind=='SNAP':
            side=candidate['opposite']; notional=self.fixed_notional*self.v10_snap_notional_mult
            reason=(f'V10 SNAP REVERSAL: original {thesis["side"]}; movement consumed '
                    f'{candidate["consumed"]:.1f}%; {candidate["confirmations"]}/4 counter confirmations')
            if self.open_position(symbol,side,notional=notional,regime='SNAP_REVERSAL',loss_budget=self.v10_snap_loss_budget,
                                  entry_quality=int(candidate['score']),entry_reason=reason,
                                  direction_confidence=int(thesis['direction']),strategy='v10_snap'):
                self.last_global_trade_time=now; self.v10_theses.pop(symbol,None)
        else:
            side=thesis['side']; strategy=thesis.get('strategy','trend')
            notional=self.fixed_notional*safe_float(candidate.get('notional_mult',1.0),1.0)
            if self.open_position(symbol,side,notional=notional,regime='V10_'+str(thesis.get('state','EARLY')),
                                  loss_budget=candidate.get('loss_budget'),entry_quality=int(candidate['score']),
                                  entry_reason=candidate['reason'],direction_confidence=int(thesis['direction']),strategy='v10_early'):
                self.last_global_trade_time=now; self.v10_theses.pop(symbol,None)

    def scan_entries(self):
        # V9.2 deliberately does NOT implement a global consecutive-loss pause.
        # Losses are handled at position level by the existing V9 governors. The
        # entry engine keeps scanning so it can diagnose the current market and take
        # the next valid opportunity instead of sleeping through it.
        if self.paused or not self.state_known:
            return
        if not self._v89_entry_state_ready():
            return
        if self.total_daily_trade_count() >= self.max_daily_trades:
            return
        if time.time() - self.last_global_trade_time < self.global_trade_cooldown_seconds:
            return

        ranked = []
        for symbol in self.symbols:
            try:
                route = self.strategy_router(symbol)
                self.latest_strategy_candidates[symbol] = route.get('candidates', [])
                self.latest_market_context[symbol] = route
                current_scan_price = safe_float(route.get('price', 0.0))
                if current_scan_price > 0:
                    self.v8_previous_prices[symbol] = current_scan_price

                # Re-evaluate candidates with V9.2 live execution state. This allows
                # a strong thesis to survive a temporary 1m/5m phase mismatch while
                # still refusing to enter into an actual counter-move.
                evaluated = []
                for c0 in list(route.get('candidates', [])):
                    c = self._v92_live_entry_gate(symbol, dict(c0), route)
                    evaluated.append(c)
                self.latest_strategy_candidates[symbol] = evaluated

                if evaluated:
                    evaluated.sort(key=lambda x: (
                        1 if x.get('market_entry_action') == 'ENTER_NOW' else 0,
                        x.get('score', 0),
                        x.get('expected_net', 0),
                        self.strategy_priority.get(x.get('strategy', ''), 0)
                    ), reverse=True)
                    top = evaluated[0]
                    status = 'READY' if top.get('market_entry_action') == 'ENTER_NOW' and not top.get('blockers') else 'WATCH'
                    self.latest_signals[symbol] = (
                        top.get('side', 'HOLD'), int(top.get('direction', 0)),
                        route.get('regime', 'unknown'), status, int(top.get('score', 0)),
                        top.get('reason', ''), route.get('long', 0), route.get('short', 0),
                        top.get('strategy', ''), top.get('rr', 0), top.get('expected_net', 0)
                    )
                    for c in evaluated:
                        if (c.get('market_entry_action') == 'ENTER_NOW' and
                            not c.get('blockers') and
                            c.get('score', 0) >= self.router_min_score):
                            ranked.append((c.get('score', 0),
                                           self.strategy_priority.get(c.get('strategy', ''), 0),
                                           symbol, c))
                else:
                    self.latest_signals[symbol] = (
                        'HOLD', max(route.get('long', 0), route.get('short', 0)),
                        route.get('regime', 'unknown'), 'WAIT', 0,
                        'no candidate', route.get('long', 0), route.get('short', 0), '', 0, 0
                    )
            except Exception as exc:
                logger.exception('V9.2 entry scan failed %s: %s', symbol, exc)
                self.latest_signals[symbol] = (
                    'HOLD', 0, 'unknown', 'WAIT', 0, f'scan error: {exc}', 0, 0, '', 0, 0
                )

        if not ranked:
            return

        ranked.sort(key=lambda x: (x[0], x[1]), reverse=True)
        _, _, symbol, c = ranked[0]
        side = c['side']
        strategy = c['strategy']
        key = (symbol, side, strategy)

        # Direct MARKET execution remains the V9 design. There is no LIMIT entry,
        # second-candle confirmation, timed breathing wait, or post-loss sleep here.
        direct_ok = (
            c.get('market_entry_action') == 'ENTER_NOW' and
            c.get('score', 0) >= self.router_min_score and
            not c.get('blockers')
        )
        if not direct_ok:
            return

        notional = self.fixed_notional * safe_float(c.get('notional_mult', 1.0), 1.0)
        if self.open_position(
            symbol, side, notional=notional, regime=strategy,
            loss_budget=c.get('loss_budget'), entry_quality=int(c.get('score', 0)),
            entry_reason=c.get('reason', ''),
            direction_confidence=int(c.get('direction', 0)), strategy=strategy
        ):
            self.last_global_trade_time = time.time()
            self.candidates.pop(key, None)

    # -------------------------------------------------------------------------
    # STATS / DASHBOARD (RESET AT MIDNIGHT)
    # -------------------------------------------------------------------------
    def daily_trade_count(self, symbol: str) -> int:
        cutoff = start_of_day_utc().isoformat()
        with db(self.db_path) as conn:
            row = conn.execute(
                "SELECT COUNT(*) FROM trades WHERE symbol=? AND entry_time>=?",
                (symbol, cutoff)
            ).fetchone()
        return int(row[0] or 0)

    def get_trades_for_date(self, date_str=None):
        """
        Return trades for a given date.
        date_str: None/'today', 'yesterday', or 'YYYY-MM-DD'
        Returns a tuple: (summary_message, list_of_trade_tuples)
        """
        if date_str is None or date_str.lower() == 'today':
            day_start = start_of_day_utc()
        elif date_str.lower() == 'yesterday':
            day_start = start_of_day_utc() - timedelta(days=1)
        else:
            try:
                dt = datetime.strptime(date_str, '%Y-%m-%d')
                dt = dt.replace(tzinfo=timezone.utc)
                day_start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
            except ValueError:
                return "Invalid date format. Use YYYY-MM-DD, 'today', or 'yesterday'.", []
        day_end = day_start + timedelta(days=1)
        start_iso = day_start.isoformat()
        end_iso = day_end.isoformat()
        with db(self.db_path) as conn:
            rows = conn.execute(
                "SELECT symbol, side, entry_time, entry_price, exit_time, exit_price, net_pnl, duration_minutes, exit_reason, regime, source "
                "FROM trades WHERE entry_time >= ? AND entry_time < ? ORDER BY entry_time DESC",
                (start_iso, end_iso)
            ).fetchall()
        summary = f"Trades for {day_start.strftime('%Y-%m-%d')}: {len(rows)} trades"
        return summary, rows

    def get_daily_stats(self) -> Tuple[int, int, int, int]:
        cutoff = start_of_day_utc().isoformat()
        with db(self.db_path) as conn:
            rows = conn.execute(
                "SELECT net_pnl FROM trades WHERE exit_time IS NOT NULL AND entry_time >= ?",
                (cutoff,)
            ).fetchall()
        total = len(rows)
        wins = sum(1 for (pnl,) in rows if pnl is not None and pnl > 0.001)
        losses = sum(1 for (pnl,) in rows if pnl is not None and pnl < -0.001)
        breakeven = total - wins - losses
        return total, wins, losses, breakeven

    def get_daily_pnl_stats(self) -> Tuple[float, float, float]:
        cutoff = start_of_day_utc().isoformat()
        with db(self.db_path) as conn:
            rows = conn.execute(
                "SELECT gross_pnl, net_pnl FROM trades WHERE exit_time IS NOT NULL AND entry_time >= ?",
                (cutoff,)
            ).fetchall()
        gross = sum(safe_float(row[0]) for row in rows if row[0] is not None)
        net = sum(safe_float(row[1]) for row in rows if row[1] is not None)
        return gross, net, net

    def get_source_stats(self) -> dict:
        cutoff = start_of_day_utc().isoformat()
        out = {}
        with db(self.db_path) as conn:
            rows = conn.execute("SELECT source, net_pnl FROM trades WHERE exit_time IS NOT NULL AND entry_time >= ?", (cutoff,)).fetchall()
        for source in ("BOT", "MANUAL"):
            vals=[safe_float(p) for src,p in rows if (src or "BOT").upper()==source]
            wins=sum(1 for p in vals if p>0.001); losses=sum(1 for p in vals if p<-0.001)
            out[source]={'total':len(vals),'wins':wins,'losses':losses,'net':sum(vals), 'winrate':(wins/len(vals)*100 if vals else 0.0)}
        return out

    def get_v126_performance_audit(self) -> dict:
        """Lightweight self-audit used by the operator dashboard.

        It measures realized outcomes rather than trying to optimize the strategy
        from a tiny sample. The values are descriptive diagnostics, not guarantees.
        """
        cutoff = start_of_day_utc().isoformat()
        try:
            with db(self.db_path) as conn:
                rows = conn.execute(
                    "SELECT net_pnl, regime, exit_reason FROM trades WHERE exit_time IS NOT NULL AND entry_time >= ?",
                    (cutoff,)
                ).fetchall()
            vals = [safe_float(r[0]) for r in rows if r[0] is not None]
            wins = [v for v in vals if v > 0.001]
            losses = [v for v in vals if v < -0.001]
            gross_win = sum(wins)
            gross_loss = abs(sum(losses))
            pf = gross_win / gross_loss if gross_loss > 1e-9 else (999.0 if gross_win > 0 else 0.0)
            expectancy = sum(vals) / len(vals) if vals else 0.0
            avg_win = sum(wins) / len(wins) if wins else 0.0
            avg_loss = sum(losses) / len(losses) if losses else 0.0
            return {'count': len(vals), 'profit_factor': pf, 'expectancy': expectancy,
                    'avg_win': avg_win, 'avg_loss': avg_loss, 'net': sum(vals)}
        except Exception as exc:
            logger.debug('V12.6 performance audit unavailable: %s', exc)
            return {'count': 0, 'profit_factor': 0.0, 'expectancy': 0.0, 'avg_win': 0.0, 'avg_loss': 0.0, 'net': 0.0}

    # -------------------------------------------------------------------------
    # DASHBOARD (enhanced with regime and quality)
    # -------------------------------------------------------------------------
    def dashboard(self):
        try:
            new_text = self.dashboard_text()
            if new_text == self._last_dashboard_text:
                return
            self._last_dashboard_text = new_text
            kb = self.dashboard_keyboard()
            if self.dashboard_message_id is None:
                message_id = self.send_telegram(new_text, kb)
                if message_id is not None:
                    self.dashboard_message_id = message_id
            else:
                # Do not create consecutive dashboard messages if editing fails.
                # Opening/closing trade notifications remain unchanged.
                if not self.edit_telegram(self.dashboard_message_id, new_text, kb):
                    logger.warning("Dashboard edit failed; no replacement dashboard sent.")
        except Exception as e:
            logger.exception("Dashboard error: %s", e)
            
    def dashboard_keyboard(self):
        return {
            "inline_keyboard": [
                [{"text": "⏸ Pause", "callback_data": "pause"},
                 {"text": "▶️ Resume", "callback_data": "resume"}],
                [{"text": "📊 Status", "callback_data": "status"},
                 {"text": "🔄 Sync", "callback_data": "sync"}],
                [{"text": "❌ Close Trade", "callback_data": "close_trade"},
                 {"text": "❓ Help", "callback_data": "help"}],
                [{"text": "📜 Trade History", "callback_data": "trade_history"}]
            ]
        }
                
    def _sanitize_for_telegram(self, text: str) -> str:
        import re
        allowed_tags = ['b', 'i', 'u', 's', 'code', 'pre', 'a']
        placeholders = {}
        for i, tag in enumerate(allowed_tags):
            open_tag = f'<{tag}>'
            close_tag = f'</{tag}>'
            placeholder_open = f'@@@OPEN_{i}@@@'
            placeholder_close = f'@@@CLOSE_{i}@@@'
            text = text.replace(open_tag, placeholder_open)
            text = text.replace(close_tag, placeholder_close)
            placeholders[placeholder_open] = open_tag
            placeholders[placeholder_close] = close_tag
        text = text.replace('<', '&lt;').replace('>', '&gt;')
        for placeholder, tag in placeholders.items():
            text = text.replace(placeholder, tag)
        return text

    def dashboard_text(self) -> str:
        """Compact operator dashboard. Internal intelligence stays internal; Telegram
        receives only the decision, risk, position and daily-performance essentials.
        """
        balance = self.get_wallet_balance()
        live_count = sum(len(v) for v in self.positions.values())
        state = "KNOWN" if self.state_known else "UNKNOWN — ENTRIES BLOCKED"
        total, wins, losses, breakeven = self.get_daily_stats()
        gross_pnl, net_pnl, _ = self.get_daily_pnl_stats()
        winrate = (wins / total * 100) if total else 0.0
        pending = len(getattr(self, 'v126_pending_entries', {}))

        lines = [
            "📊 <b>SEALABS X-TRADE</b>",
            f"{'⏸ PAUSED' if self.paused else '🟢 RUNNING'} | Binance: <b>{state}</b>",
            f"Balance: <b>${balance:,.2f}</b>",
            f"Open: {live_count}/{self.max_open_positions}     | Pending: {pending}",
            f"Today: {total}/{self.max_daily_trades}           | Win: {winrate:.1f}%",
            f"W: {wins}        | L: {losses}        | BE: {breakeven}",
            f"Net: <b>${net_pnl:+,.2f}</b> | Gross: ${gross_pnl:+,.2f}",
            "─" * 22,
            "<b>LIVE MARKET DECISION</b>",
            #"<i>Values below refresh with the latest market scan.</i>"
        ]
        for sym in self.symbols:
            v = self.v12_last_entry_state.get(sym, {}) or {}
            state_v = v.get('state', 'WAIT')
            side = v.get('side', '')
            label_side = 'LONG' if side == 'BUY' else 'SHORT' if side == 'SELL' else '—'
            pred = safe_float(v.get('prediction', 0))
            premove = safe_float(v.get('pre_move_remaining', v.get('score', 0)))
            ext = safe_float(v.get('extension_atr', 0))
            consumed = safe_float(v.get('move_consumed_pct', 0))
            rr = safe_float(v.get('rr', 0))
            maturity = v.get('maturity', '')
            if state_v in ('LIMIT_ARMED','LIMIT_READY','LIMIT_RECOVERED'):
                icon = '🟢'
            elif state_v in ('CANCELLED','REJECTED','BLOCKED'):
                icon = '🔴'
            else:
                icon = '⏸'
            lines.append(f"{icon} <b>{sym}</b> {label_side} | {state_v}")
            if pred or premove or ext:
                lines.append(f"  Pred {pred:.0f}% | Remain {premove:.0f}% | Ext {ext:.2f}ATR")
                if v.get('expert_mode'):
                    live_score = safe_float(v.get('expert_score', 0))
                    live_adx = safe_float(v.get('live_adx', 0))
                    mom1 = safe_float(v.get('live_momentum_1m', 0))
                    trend_ok = v.get('live_trend_ok')
                    momentum_ok = v.get('live_momentum_ok')
                    live_status = 'FAVORABLE' if trend_ok and momentum_ok else 'WEAKENING' if trend_ok or momentum_ok else 'AGAINST / UNCERTAIN'
                    lines.append(f"  Mode {v.get('expert_mode')} | Brain {live_score:.0f} | ADX {live_adx:.1f}/{safe_float(v.get('live_adx_15m',0)):.1f}")
                    lines.append(f"  Trend {'ALIGNED' if trend_ok else 'OPPOSED'} | 1M Mom {mom1:+.5f} | 5M Mom {safe_float(v.get('live_momentum_5m',0)):+.5f}")
                    if v.get('v13_trigger_score') is not None:
                        lines.append(f"  Trigger {safe_float(v.get('v13_trigger_score')):.0f} | Location {safe_float(v.get('v13_dist_ema_atr')):.2f}ATR | Decision {v.get('expert_decision','WAIT')}")

        lines.append("─" * 22)
        lines.append("<b>OPEN POSITIONS</b>")
        any_pos = False
        for sym, sides in self.positions.items():
            for side, p in sides.items():
                if p.closed: continue
                any_pos = True
                pnl = p.estimated_net_pnl(self.estimated_commission_rate)
                thesis = self.v12_last_entry_state.get(sym, {}) or {}
                entry_price = safe_float(getattr(p, 'entry_price', 0))
                current_price = safe_float(getattr(p, 'mark_price', 0)) or safe_float(getattr(p, 'current_price', 0))
                if not current_price:
                    current_price = entry_price
                favorable = (current_price < entry_price) if p.position_side == 'SHORT' else (current_price > entry_price)
                movement = 'FAVORABLE' if favorable else 'AGAINST' if current_price != entry_price else 'FLAT'
                lines.append(f"{'📈' if pnl > 0 else '📉' if pnl < 0 else '➖'} {sym} {p.position_side} | PNL ${pnl:+.2f} | Move {movement}")
                lines.append(f"  Entry ${entry_price:,.4f} | Current ${current_price:,.4f} | Peak ${p.peak_net_pnl:+.2f} | Floor ${p.profit_floor:+.2f} | Lock {'ON' if p.profit_lock_active else 'OFF'}")
        if not any_pos:
            lines.append("No open positions.")
        lines.append("─" * 22)
        audit = self.get_v126_performance_audit()
        lines.append(f"Audit: PF {audit['profit_factor']:.2f}     | Exp ${audit['expectancy']:+.2f} | Avg W ${audit['avg_win']:+.2f} | Avg L ${audit['avg_loss']:+.2f}")
        lines.append("Expert Brain: ACTIVE")
        #lines.append("direction + location + trigger + thesis + re-entry guard")
        return self._sanitize_for_telegram("\n".join(lines))

    def dashboard(self):
        new_text = self.dashboard_text()
        if new_text == self._last_dashboard_text:
            return
        self._last_dashboard_text = new_text
        kb = self.dashboard_keyboard()
        if self.dashboard_message_id is None:
            message_id = self.send_telegram(new_text, kb)
            if message_id is not None:
                self.dashboard_message_id = message_id
        else:
            # Do not create consecutive dashboard messages if editing fails.
            # Opening/closing trade notifications remain unchanged.
            if not self.edit_telegram(self.dashboard_message_id, new_text, kb):
                logger.warning("Dashboard edit failed; no replacement dashboard sent.")

    # -------------------------------------------------------------------------
    # CLOSE SELECTION (for callback)
    # -------------------------------------------------------------------------
    def send_close_selection(self, callback_query_id: str = None):
        positions = []
        for symbol in self.positions:
            for side, p in self.positions[symbol].items():
                if not p.closed:
                    positions.append((symbol, side, p.position_side))

        if not positions:
            self.send_telegram("ℹ️ No open positions to close.")
            return

        keyboard = []
        for symbol, side, pos_side in positions:
            label = f"{symbol} {pos_side}"
            callback_data = f"close_pos:{symbol}:{side}"
            keyboard.append([{"text": label, "callback_data": callback_data}])

        keyboard.append([{"text": "❌ Cancel", "callback_data": "close_cancel"}])
        reply_markup = {"inline_keyboard": keyboard}
        self.send_telegram("Select the position you want to close:", reply_markup=reply_markup)

    # -------------------------------------------------------------------------
    # TELEGRAM COMMANDS & CALLBACKS
    # -------------------------------------------------------------------------
    def handle_command(self, text: str):
        parts = text.strip().split()
        if not parts:
            return
        cmd = parts[0].lower()
        if cmd in ("/status", "/dashboard"):
            self.dashboard()
        elif cmd == "/pause":
            self.paused = True
            self.send_telegram("⏸️ Auto-entry paused.")
            self.dashboard()
        elif cmd == "/resume":
            if self.reconcile(force=True, reason="TELEGRAM RESUME"):
                self.paused = False
                self.send_telegram("▶️ Auto-entry resumed.")
                self.dashboard()
        elif cmd == "/sync":
            ok = self.reconcile(force=True, reason="TELEGRAM SYNC")
            self.send_telegram("✅ Sync complete." if ok else "⚠️ Sync UNKNOWN.")
            self.dashboard()
        elif cmd == "/start":
            self.send_telegram("🚀 Bot is running!\nType /help for commands.")
        elif cmd == "/close" and len(parts) >= 2:
            symbol = parts[1].upper()
            side = parts[2].upper() if len(parts) >= 3 else None
            if symbol not in self.positions:
                self.send_telegram(f"No managed position for {symbol}.")
                return
            sides = [side] if side in ("BUY", "SELL") else list(self.positions[symbol].keys())
            for s in sides:
                p = self.positions.get(symbol, {}).get(s)
                if p:
                    self.close_position(p, "MANUAL CLOSE")
            self.dashboard()
        elif cmd == "/ping":
            self.send_telegram("🏓 Pong! (listener is alive)")
        elif cmd == "/closeall":
            for symbol in list(self.positions):
                for side in list(self.positions[symbol]):
                    p = self.positions[symbol].get(side)
                    if p:
                        self.close_position(p, "MANUAL CLOSE ALL")
            self.dashboard()
        elif cmd == "/trade" and len(parts) >= 3:
            symbol = parts[1].upper()
            direction = parts[2].upper()
            side = "BUY" if direction in ("BUY", "LONG") else "SELL" if direction in ("SELL", "SHORT") else None
            if symbol not in self.symbols or side is None:
                self.send_telegram("Usage: /trade BTCUSDT LONG 600")
                return
            notional = float(parts[3]) if len(parts) >= 4 else self.fixed_notional
            # Manual entry uses default trend regime, quality high
            self.open_position(symbol, side, notional=notional, manual=True,
                               regime="manual", entry_quality=95, entry_reason="manual trade")
            self.dashboard()
        elif cmd == "/help":
            self.send_telegram(
                "<b>SEALABS X-TRADE</b>\n\n"
                "/status - dashboard\n"
                "/sync - force Binance reconciliation\n"
                "/pause - stop new auto entries\n"
                "/resume - reconcile then resume entries\n"
                "/trade SYMBOL LONG|SHORT [notional] - manual entry\n"
                "/close SYMBOL [BUY|SELL] - close a whole position\n"
                "/closeall - close every managed position\n\n"
                "Legacy intelligence retained; V12.8 Adaptive Market Brain controls new predictive entries.\n\nV8 Predictive Entry Commitment Router: Trend, Range/Mean-Reversion, Transition, Micro-Reversion, Chop detection + Range analysis retained, but V8.8 uses direct MARKET entries only."
            )
        elif cmd == "/history":
            # Example: /history yesterday  or  /history 2026-08-24
            date_arg = parts[1] if len(parts) >= 2 else 'today'
            summary, trades = self.get_trades_for_date(date_arg)
            if not trades:
                self.send_telegram(f"{summary}\nNo trades found.")
                return
            # Build reply – limit to 50 trades to avoid message length
            lines = [summary, ""]
            for trade in trades[:50]:
                (symbol, side, entry_time, entry_price, exit_time, exit_price, net_pnl, duration, reason, regime, source) = trade
                pnl_str = f"+{net_pnl:.2f}" if net_pnl >= 0 else f"{net_pnl:.2f}"
                lines.append(f"{symbol} {side} [{source}] | {pnl_str} | {duration}min | {reason[:30]}")
            if len(trades) > 50:
                lines.append(f"... and {len(trades)-50} more trades")
            self.send_telegram("\n".join(lines))

    def handle_callback(self, callback_data: str, callback_id: str):
        self.answer_callback(callback_id)  # this sends an empty ACK to Telegram (good)
        logger.info(f"📨 Callback received: {callback_data}")

        if callback_data == "pause":
            logger.info("⏸️ Pause command from Telegram")
            self.paused = True
            self.send_telegram("⏸️ Auto-entry paused.")
            self.dashboard()
        elif callback_data == "resume":
            logger.info("▶️ Resume command from Telegram")
            if self.reconcile(force=True, reason="CALLBACK RESUME"):
                self.paused = False
                self.send_telegram("▶️ Auto-entry resumed.")
                self.dashboard()
        elif callback_data == "status":
            logger.info("📊 Status command from Telegram - calling dashboard()")
            self.dashboard()
        elif callback_data == "sync":
            logger.info("🔄 Sync command from Telegram")
            ok = self.reconcile(force=True, reason="CALLBACK SYNC")
            self.send_telegram("✅ Sync complete." if ok else "⚠️ Sync UNKNOWN.")
            self.dashboard()
        elif callback_data == "close_trade":
            self.send_close_selection(callback_id)
        elif callback_data == "close_cancel":
            self.send_telegram("❌ Close cancelled.")
        elif callback_data.startswith("close_pos:"):
            parts = callback_data.split(":")
            if len(parts) == 3:
                symbol = parts[1].upper()
                side = parts[2].upper()
                p = self.positions.get(symbol, {}).get(side)
                if p and not p.closed:
                    if self.mode != "PAPER":
                        live = self.get_open_positions(force=True)
                        if live is None:
                            self.send_telegram("⚠️ Binance state unknown – cannot close.")
                            return
                        live_dict = {(x["symbol"], x["side"]): x for x in live}
                        if (symbol, side) not in live_dict:
                            self.send_telegram(f"ℹ️ Position {symbol} {p.position_side} is already closed. Syncing...")
                            self.reconcile(force=True)
                            self.dashboard()
                            return
                    self.close_position(p, "MANUAL CLOSE")
                    self.dashboard()
                    self.send_telegram(f"✅ Closed {symbol} {p.position_side}.")
                else:
                    self.send_telegram(f"ℹ️ Position {symbol} {side} not found or already closed.")
        elif callback_data == "help":
            self.handle_command("/help")
        elif callback_data == "trade_history":
            self.send_telegram(
                "📜 <b>Trade History</b>\n\n"
                "Send one of these commands:\n"
                "• <code>/history</code> – today's trades\n"
                "• <code>/history yesterday</code> – yesterday's trades\n"
                "• <code>/history 2026-08-24</code> – trades from a specific date (YYYY-MM-DD)"
            )
        else:
            logger.warning(f"Unknown callback: {callback_data}")

    # -------------------------------------------------------------------------
    # TELEGRAM LISTENER
    # -------------------------------------------------------------------------
    def telegram_listener(self):
        if not self.telegram_token:
            return
        import requests
        offset = 0
        session = requests.Session()
        backoff = 2.0
        
        # Delete any existing webhook to allow getUpdates. Telegram is auxiliary;
        # failure here must never prevent Binance trading.
        try:
            del_url = f"https://api.telegram.org/bot{self.telegram_token}/deleteWebhook"
            resp = session.get(del_url, timeout=(10, 10))
            if resp.ok:
                logger.info("✅ Webhook deleted successfully. Polling can start.")
            else:
                logger.warning("Could not delete webhook: %s", resp.text)
        except Exception as e:
            logger.warning("Error deleting webhook: %s", e)
        
        logger.info("✅ Telegram listener thread is now running (polling).")
        while self.running and (self._stop_event is None or not self._stop_event.is_set()):
            try:
                r = session.get(
                    f"https://api.telegram.org/bot{self.telegram_token}/getUpdates",
                    params={"offset": offset + 1, "timeout": 3, "allowed_updates": ["message", "callback_query"]},
                    timeout=(12, 10),
                )
                if r.ok:
                    backoff = 2.0
                    data = r.json()
                    for update in data.get("result", []):
                        offset = update["update_id"]
                        # Log only the relevant part to reduce clutter
                        if "message" in update and "text" in update["message"]:
                            logger.info("📩 Command: %s", update["message"]["text"])
                        elif "callback_query" in update:
                            logger.info("📩 Callback: %s", update["callback_query"].get("data"))
                        else:
                            logger.info("📩 Telegram update (id=%s)", update.get('update_id'))
                        if "callback_query" in update:
                            cb = update["callback_query"]
                            data = cb.get("data", "")
                            cb_id = cb.get("id")
                            if data and cb_id:
                                self.handle_callback(data, cb_id)
                        msg = update.get("message", {})
                        text = msg.get("text")
                        chat_id = str(msg.get("chat", {}).get("id", ""))
                        if text and chat_id == str(self.telegram_chat_id):
                            if text.startswith('/'):
                                self.handle_command(text)
                            else:
                                # Developer note (non‑command)
                                logger.info(f"📝 Developer note: {text}")
                                self.send_telegram("✅ Note logged.")
                else:
                    logger.error("Telegram poll failed: HTTP %s - %s", r.status_code, r.text[:200])
                    if r.status_code == 409:
                        logger.warning("Conflict (409) – attempting to delete webhook again...")
                        try:
                            requests.get(f"https://api.telegram.org/bot{self.telegram_token}/deleteWebhook", timeout=5)
                        except Exception:
                            pass
                        offset = 0
                    elif r.status_code == 401:
                        logger.error("Unauthorized – invalid bot token. Please check your Telegram Bot Token.")
                        break
            except requests.exceptions.RequestException as exc:
                # Telegram is an auxiliary control channel. A network/SSL timeout
                # must never flood the log or interfere with trading. Reconnect with
                # bounded exponential backoff instead of dumping a traceback.
                logger.warning("Telegram connection unavailable: %s | retry %.1fs", exc, backoff)
                time.sleep(backoff)
                backoff = min(30.0, backoff * 1.8)
            except Exception as exc:
                logger.warning("Telegram listener error contained: %s", exc)
                time.sleep(3)
            else:
                time.sleep(0.5)
        logger.info("Telegram listener stopped.")

    # -------------------------------------------------------------------------
    # MAIN LOOP
    # -------------------------------------------------------------------------
    def tick(self):
        self._v15_update_post_exit()
        now = time.time()

        if self.mode != "PAPER":
            live_count = sum(len(v) for v in self.positions.values())
            if self.ws_bridge and self.ws_bridge.user_healthy:
                reconcile_interval = self.websocket_rest_reconcile_position_seconds if live_count else self.websocket_rest_reconcile_flat_seconds
            else:
                reconcile_interval = self.rest_reconcile_seconds
            if now - self.last_rest_reconcile >= reconcile_interval:
                self.reconcile(force=True, reason="PERIODIC REST")

        if self.state_known:
            self.manage_positions()

        if self.state_known and self.mode != 'PAPER':
            self.recover_exchange_smart_orders()
        # V12.6 pending entries are managed independently of the NEW-ENTRY risk gate;
        # an already-resting order must still expire, cancel, or fill safely.
        if self.state_known and not self.paused:
            self._v126_manage_pending_entries()
            allowed, why = self.risk.new_entries_allowed()
            if allowed:
                self.scan_entries_v13()
            else:
                logger.debug("New entries blocked: %s", why)

        if now - self.dashboard_last >= self.dashboard_seconds:
            self.dashboard_last = now
            self.dashboard()

    def run(self):
        logger.info("Unified SEALABS X-TRADE started.")
        logger.info("Capacity: %s symbols x 2 Hedge sides = %s position slots.", len(self.symbols), self.max_open_positions)

        if self.telegram_token and self.telegram_chat_id:
            logger.info("🚀 Starting Telegram listener thread...")
            t = threading.Thread(target=self.telegram_listener, daemon=True, name="telegram-listener")
            t.start()
            # Test the token by sending a startup message
            try:
                import requests
                test_url = f"https://api.telegram.org/bot{self.telegram_token}/getMe"
                resp = requests.get(test_url, timeout=(10, 10))
                if resp.ok:
                    logger.info("✅ Telegram bot token is valid.")
                else:
                    logger.error("❌ Telegram bot token is INVALID! Please check your token.")
            except Exception as e:
                logger.warning("Could not test Telegram token: %s", e)
        else:
            logger.warning("⚠️ Telegram credentials missing – listener NOT started.")

        while self.running and (self._stop_event is None or not self._stop_event.is_set()):
            started = time.time()
            try:
                self.tick()
            except KeyboardInterrupt:
                self.running = False
                break
            except Exception as exc:
                logger.exception("Main loop error contained: %s", exc)
                self.send_telegram(f"⚠️ Main loop error contained: {exc}")
            elapsed = time.time() - started
            time.sleep(max(1, self.loop_seconds - elapsed))

        if self.ws_bridge:
            self.ws_bridge.stop()
        logger.info("Engine stopped.")

# =============================================================================
# RISK ENGINE (also updated to use midnight cutoff)
# =============================================================================

class RiskEngine:
    def __init__(self, engine: TradingEngine):
        self.engine = engine

    def daily_net(self) -> float:
        if self.engine.mode == "PAPER":
            return self.engine.paper_daily_pnl
        key = "risk_daily_net"
        if self.engine.cache_fresh(key, 60.0):
            return safe_float(self.engine._cache[key][1])
        # If the local trade journal says there have been no trades today and no
        # user-data balance event indicates realised income, avoid a needless REST
        # income-history request on every scan.
        try:
            if self.engine.total_daily_trade_count() == 0:
                self.engine.cache_set(key, 0.0)
                return 0.0
        except Exception:
            pass
        cutoff_ms = int(start_of_day_utc().timestamp() * 1000)
        try:
            self.engine.throttle_api_call()
            income = self.engine.client.futures_income_history(startTime=cutoff_ms, limit=1000)
            total = 0.0
            for rec in income:
                if rec.get("incomeType") in ("REALIZED_PNL", "COMMISSION", "FUNDING_FEE"):
                    total += safe_float(rec.get("income"))
            self.engine.cache_set(key, total)
            return total
        except Exception as exc:
            self.engine.handle_rate_limit_error(exc)
            logger.warning("Daily PNL unavailable: %s", exc)
            return 0.0

    def new_entries_allowed(self) -> Tuple[bool, str]:
        balance = self.engine.get_wallet_balance()
        if balance <= 0:
            return False, "Wallet balance unavailable"
        daily = self.daily_net()
        limit = balance * self.engine.max_daily_loss_percent / 100.0
        if daily <= -limit:
            return False, f"daily loss limit reached ({daily:+.2f})"
        return True, "OK"

    def exposure_allowed(self, additional_notional: float) -> Tuple[bool, str]:
        live = self.engine.get_open_positions(force=False)
        if live is None:
            return False, "Binance position state UNKNOWN"
        current = sum(x["quantity"] * x["entry_price"] for x in live)
        pending = sum(safe_float(p.get('notional',0)) for p in self.engine.smart_orders.values() if p.get('status') in ('NEW','PARTIALLY_FILLED'))
        if current + pending + additional_notional > self.engine.max_total_notional:
            return False, f"total notional cap exceeded ({current + additional_notional:.2f})"
        return True, "OK"
