# source: https://raw.githubusercontent.com/acsvalentinacs/HOPE-Minibot/cf4a207b21a7909b8727d38da4dfba70b7699d5b/patches/v11_multi/HopeMultiStrategy.py
# -*- coding: utf-8 -*-
# CONFIG_VERSION: ft-strategy-v11.20-20260213
# MANIFEST_ID: freqtrade_strategy
# DEPENDS_ON: strategy_config
# PHASE: disabled
"""
Github_acsvalentinacs_HOPE_Minibot__HopeMultiStrategy__20260218_190159 V11.20 — 15 Strategies via enter_tag
======================================================
One bot, 15 strategies across 5 groups:
  H0: Scalp (h0s1, h0s2, h0s3/ML)  — 5m
  H1: Pump & Momentum (h1p1, h1p2, h1m1) — 5m
  H2: Mean Reversion (h2r1, h2r2, h2r3) — 5m
  H3: Trend & Swing (h3t1, h3t2, h3t3) — 1h informative
  H4: Sniper (h4b1, h4b2, h4f1) — 15m informative

Architecture:
  - All strategies share one Docker container, one set of API calls
  - Indicators computed ONCE, strategies = conditional logic on shared dataframe
  - Per-tag: SL, TP, sizing, timeout, trailing, max_concurrent
  - Guards: SessionLimits (all), AntiChase (skip for pump), AI Learner (all), ML (h0s3 only)
  - Auto-disable: WR < 30% on 20+ closed trades → tag blocked

Based on V10.3 with all guards, ML, passport preserved.
Author: HOPE AI System
Date: 2026-02-08
"""

import json
import logging
import os
import time
from datetime import datetime, timezone
from typing import Dict, Optional

import numpy as np
import pandas as pd
from freqtrade.persistence import Trade
from freqtrade.strategy import (
    IStrategy, merge_informative_pair,
    DecimalParameter, IntParameter,
)
from collections import namedtuple
from pandas import DataFrame

logger = logging.getLogger(__name__)

MLCacheEntry = namedtuple('MLCacheEntry', ['timestamp', 'multiplier', 'pair', 'decision'])

# ── Import per-tag config ──────────────────────────────────────────────
import sys
sys.path.insert(0, '/freqtrade/user_data/strategies')

from strategy_config import (
    STRATEGY_CONFIG, DEFAULT_CONFIG,
    MIN_TRADES_FOR_EVAL, MIN_WINRATE, MIN_PAIR_WINRATE, MIN_PAIR_TRADES, EVAL_WINDOW,
    EVAL_START_TRADE_ID,
    MAX_EXPOSURE_RATIO, KILL_ZONE_ENABLED,
    STAKE_MODE, BASE_STAKE_PCT, MIN_STAKE_USD,
    HARD_BLOCKED_PAIRS, KILL_HOURS, PRE_DISABLED_TAGS,
    TAG_TO_GROUP, PAIR_LOSS_COOLDOWN_MIN,
)

# ── Import strategy group modules ──────────────────────────────────────
STRATEGY_GROUPS_AVAILABLE = False
try:
    from strategy_groups.scalp import apply_h0s1, apply_h0s2, apply_h0s3
    from strategy_groups.pump import apply_h1p1, apply_h1p2, apply_h1m1
    from strategy_groups.revert import apply_h2r1, apply_h2r2, apply_h2r3
    from strategy_groups.trend import apply_h3t1, apply_h3t2, apply_h3t3
    from strategy_groups.sniper import apply_h4b1, apply_h4b2, apply_h4f1
    STRATEGY_GROUPS_AVAILABLE = True
    logger.info("[V11] All strategy groups loaded")
except ImportError as e:
    logger.error(f"[V11] Strategy groups import FAILED: {e}")

# ── Import ML modules ─────────────────────────────────────────────────
ML_AVAILABLE = False
try:
    sys.path.insert(0, '/freqtrade/user_data/strategies/hope_ml')
    from hope_ml_signal import MLSignalGenerator
    ML_AVAILABLE = True
    logger.info("[V11] ML Signal Generator loaded")
except ImportError as e:
    logger.warning(f"[V11] ML not available: {e}")

# ── Import Guards ──────────────────────────────────────────────────────
GUARDS_AVAILABLE = False
try:
    from guards.session_limits import SessionLimits
    from guards.anti_chase import AntiChaseFilter
    from guards.ai_learner import AITradeLearner
    from guards.stats_collector import GuardStats
    GUARDS_AVAILABLE = True
    logger.info("[V11] Guards loaded")
except ImportError as e:
    logger.warning(f"[V11] Guards not available: {e}")

# ── Import Passport ────────────────────────────────────────────────────
PASSPORT_AVAILABLE = False
try:
    from hope_passport import write_passport
    PASSPORT_AVAILABLE = True
except ImportError:
    pass

# Spam throttle
BLOCK_LOG_THROTTLE = 300  # 5 min per pair


class Github_acsvalentinacs_HOPE_Minibot__HopeMultiStrategy__20260218_190159(IStrategy):
    """
    HOPE Multi-Strategy V11 — 15 strategies in one bot via enter_tag.
    """

    INTERFACE_VERSION = 3
    timeframe = '5m'
    can_short = False
    STRATEGY_VERSION = "V11.15"

    # ═══════ HYPEROPT PARAMETERS — ENTRY (buy space) ═══════
    # H0 Scalp (V11.3: h0s1 rsi 35-72 vol 0.8, h0s2 vol 0.9)
    h0s1_rsi_low = IntParameter(25, 55, default=35, space='buy', optimize=True)
    h0s1_rsi_high = IntParameter(55, 80, default=72, space='buy', optimize=True)
    h0s1_vol_mult = DecimalParameter(0.5, 2.5, default=0.8, decimals=1, space='buy', optimize=True)
    h0s2_vol_mult = DecimalParameter(0.5, 2.5, default=0.9, decimals=1, space='buy', optimize=True)
    h0s3_rsi_high = IntParameter(35, 65, default=55, space='buy', optimize=True)
    h0s3_vol_ratio_min = DecimalParameter(0.2, 1.0, default=0.5, decimals=1, space='buy', optimize=True)
    # H1 Pump & Momentum (V11.3: h1p1 vol 2.0 roc 0.006, h1p2 bb 1.15 vol 1.5, h1m1 vol 1.2)
    h1p1_vol_mult = DecimalParameter(1.5, 6.0, default=2.0, decimals=1, space='buy', optimize=True)
    h1p1_roc_min = DecimalParameter(0.003, 0.030, default=0.006, decimals=3, space='buy', optimize=True)
    h1p2_bb_expand = DecimalParameter(1.05, 2.0, default=1.15, decimals=2, space='buy', optimize=True)
    h1p2_vol_mult = DecimalParameter(1.0, 4.0, default=1.5, decimals=1, space='buy', optimize=True)
    h1m1_vol_mult = DecimalParameter(0.8, 3.0, default=1.2, decimals=1, space='buy', optimize=True)
    # H2 Revert (V11.3: h2r1 rsi 33 stoch 25, h2r2 vwap 0.992 rsi 40, h2r3 rsi 38)
    h2r1_rsi_max = IntParameter(20, 45, default=33, space='buy', optimize=True)
    h2r1_stoch_max = IntParameter(10, 40, default=25, space='buy', optimize=True)
    h2r2_vwap_pct = DecimalParameter(0.980, 0.998, default=0.992, decimals=3, space='buy', optimize=True)
    h2r2_rsi_max = IntParameter(25, 50, default=40, space='buy', optimize=True)
    h2r3_rsi_max = IntParameter(20, 48, default=38, space='buy', optimize=True)
    # H3 Trend (V11.3: h3t1 adx 18 rsi 50 ema wider, h3t3 rsi 48 ema wider)
    h3t1_adx_min = IntParameter(12, 35, default=18, space='buy', optimize=True)
    h3t1_rsi_max = IntParameter(35, 60, default=50, space='buy', optimize=True)
    h3t1_ema_high = DecimalParameter(1.000, 1.015, default=1.008, decimals=3, space='buy', optimize=True)
    h3t1_ema_low = DecimalParameter(0.985, 1.000, default=0.992, decimals=3, space='buy', optimize=True)
    h3t2_vol_mult = DecimalParameter(0.5, 2.0, default=1.0, decimals=1, space='buy', optimize=True)
    h3t3_rsi_max = IntParameter(30, 60, default=48, space='buy', optimize=True)
    h3t3_ema_high = DecimalParameter(1.000, 1.015, default=1.008, decimals=3, space='buy', optimize=True)
    h3t3_ema_low = DecimalParameter(0.985, 1.000, default=0.990, decimals=3, space='buy', optimize=True)
    # H4 Sniper (V11.3: h4b2 rsi 30-55 fib 0.006, h4f1 rsi 22 vol 0.6)
    h4b1_vol_mult = DecimalParameter(1.5, 4.0, default=2.0, decimals=1, space='buy', optimize=True)
    h4b2_rsi_low = IntParameter(20, 45, default=30, space='buy', optimize=True)
    h4b2_rsi_high = IntParameter(40, 65, default=55, space='buy', optimize=True)
    h4b2_fib_tol = DecimalParameter(0.002, 0.010, default=0.006, decimals=3, space='buy', optimize=True)
    h4f1_rsi_min = IntParameter(15, 35, default=22, space='buy', optimize=True)
    h4f1_vol_mult = DecimalParameter(0.3, 1.5, default=0.6, decimals=1, space='buy', optimize=True)

    # ═══════ HYPEROPT PARAMETERS — EXIT (sell space) ═══════
    # SL per tag
    h0s1_sl = DecimalParameter(-0.015, -0.004, default=-0.008, decimals=3, space='sell', optimize=True)
    h0s2_sl = DecimalParameter(-0.015, -0.004, default=-0.008, decimals=3, space='sell', optimize=True)
    h0s3_sl = DecimalParameter(-0.020, -0.006, default=-0.012, decimals=3, space='sell', optimize=True)
    h1p1_sl = DecimalParameter(-0.025, -0.008, default=-0.015, decimals=3, space='sell', optimize=True)
    h1p2_sl = DecimalParameter(-0.025, -0.008, default=-0.015, decimals=3, space='sell', optimize=True)
    h1m1_sl = DecimalParameter(-0.020, -0.006, default=-0.012, decimals=3, space='sell', optimize=True)
    h2r1_sl = DecimalParameter(-0.018, -0.004, default=-0.010, decimals=3, space='sell', optimize=True)
    h2r2_sl = DecimalParameter(-0.018, -0.004, default=-0.010, decimals=3, space='sell', optimize=True)
    h2r3_sl = DecimalParameter(-0.015, -0.004, default=-0.008, decimals=3, space='sell', optimize=True)
    h3t1_sl = DecimalParameter(-0.035, -0.010, default=-0.020, decimals=3, space='sell', optimize=True)
    h3t2_sl = DecimalParameter(-0.040, -0.012, default=-0.025, decimals=3, space='sell', optimize=True)
    h3t3_sl = DecimalParameter(-0.035, -0.010, default=-0.020, decimals=3, space='sell', optimize=True)
    h4b1_sl = DecimalParameter(-0.020, -0.006, default=-0.012, decimals=3, space='sell', optimize=True)
    h4b2_sl = DecimalParameter(-0.018, -0.005, default=-0.010, decimals=3, space='sell', optimize=True)
    h4f1_sl = DecimalParameter(-0.025, -0.008, default=-0.015, decimals=3, space='sell', optimize=True)
    # TP per tag (only tags with fixed TP)
    h0s1_tp = DecimalParameter(0.004, 0.020, default=0.008, decimals=3, space='sell', optimize=True)
    h0s2_tp = DecimalParameter(0.004, 0.020, default=0.010, decimals=3, space='sell', optimize=True)
    h2r1_tp = DecimalParameter(0.004, 0.020, default=0.008, decimals=3, space='sell', optimize=True)
    h2r2_tp = DecimalParameter(0.004, 0.020, default=0.010, decimals=3, space='sell', optimize=True)
    h2r3_tp = DecimalParameter(0.003, 0.015, default=0.006, decimals=3, space='sell', optimize=True)
    h4b1_tp = DecimalParameter(0.010, 0.040, default=0.020, decimals=3, space='sell', optimize=True)
    h4b2_tp = DecimalParameter(0.008, 0.030, default=0.015, decimals=3, space='sell', optimize=True)
    h4f1_tp = DecimalParameter(0.010, 0.040, default=0.020, decimals=3, space='sell', optimize=True)
    # Trailing per tag (only tags with trailing)
    h0s3_trail = DecimalParameter(0.004, 0.020, default=0.008, decimals=3, space='sell', optimize=True)
    h1p1_trail = DecimalParameter(0.008, 0.030, default=0.015, decimals=3, space='sell', optimize=True)
    h1p2_trail = DecimalParameter(0.010, 0.040, default=0.020, decimals=3, space='sell', optimize=True)
    h1m1_trail = DecimalParameter(0.006, 0.025, default=0.012, decimals=3, space='sell', optimize=True)
    h3t1_trail = DecimalParameter(0.015, 0.050, default=0.030, decimals=3, space='sell', optimize=True)
    h3t2_trail = DecimalParameter(0.012, 0.045, default=0.025, decimals=3, space='sell', optimize=True)
    h3t3_trail = DecimalParameter(0.012, 0.045, default=0.025, decimals=3, space='sell', optimize=True)

    # Failsafe ROI — high enough to not interfere with per-tag TP
    minimal_roi = {"0": 0.10}

    # Failsafe SL — per-tag SL is tighter (via custom_stoploss)
    # V11.9: -0.03 → -0.02, V11.16: -0.02 → -0.012 (Trade 156 breached -0.8% SL to -1.25%)
    stoploss = -0.012
    use_custom_stoploss = True
    trailing_stop = False  # per-tag trailing in custom_exit

    startup_candle_count = 200
    process_only_new_candles = True

    # ML config (for h0s3)
    use_ml = True
    ml_fallback_to_v9 = True

    def __init__(self, config: dict) -> None:
        super().__init__(config)

        # ── ML Signal Generator (for h0s3) ──
        self.ml_signal = None
        if self.use_ml and ML_AVAILABLE:
            try:
                self.ml_signal = MLSignalGenerator(config)
                logger.info("[V11] ML Signal Generator initialized")
            except Exception as e:
                logger.error(f"[V11] ML init failed: {e}")

        # ── Guards ──
        self.session_limits = None
        self.anti_chase = None
        self.ai_learner = None
        self.guard_stats = None

        if GUARDS_AVAILABLE:
            try:
                self.session_limits = SessionLimits()
                logger.info("[V11] Guard: SessionLimits OK")
            except Exception as e:
                logger.error(f"[V11] SessionLimits failed: {e}")
            try:
                self.anti_chase = AntiChaseFilter()
                logger.info("[V11] Guard: AntiChase OK")
            except Exception as e:
                logger.error(f"[V11] AntiChase failed: {e}")
            try:
                self.ai_learner = AITradeLearner()
                logger.info("[V11] Guard: AILearner OK")
            except Exception as e:
                logger.error(f"[V11] AILearner failed: {e}")
            try:
                self.guard_stats = GuardStats()
                logger.info("[V11] Guard: StatsCollector OK")
            except Exception as e:
                logger.error(f"[V11] GuardStats failed: {e}")

        # ── Counters (instance-level, not class-level) ──
        self._ml_entries = 0
        self._total_entries = 0
        self._ml_blocks = 0
        self._guard_blocks = 0
        self._tag_entries = {}

        # ── Caches ──
        self._last_block_log = {}
        self._ml_stake_cache = {}  # pair -> MLCacheEntry
        self._ml_logged_pairs = {}   # pair -> timestamp
        self._ml_logged_day = None
        self._signal_debug_done = set()  # one-time debug log per pair

        # ── V11.16: Pair cooldown after loss ──
        self._pair_cooldown = {}  # pair -> timestamp when cooldown expires

        # ── Auto-disable state ──
        self._disabled_tags = set()
        self._tag_wr_cache = {}      # tag -> (winrate, count, timestamp)
        self._pair_wr_cache = {}     # V11.7: pair -> (winrate, count, timestamp)
        self._hour_wr_cache = {}     # V11.8: hour -> (winrate, count, timestamp)
        self._wr_cache_ttl = 300     # 5 min
        self._load_disabled_tags()

        # ── Passport ──
        self._last_passport_ts = 0
        if PASSPORT_AVAILABLE:
            try:
                write_passport(self)
                self._last_passport_ts = time.time()
            except Exception as e:
                logger.warning(f"[V11] Passport write failed: {e}")

        logger.info(f"[V11] Github_acsvalentinacs_HOPE_Minibot__HopeMultiStrategy__20260218_190159 {self.STRATEGY_VERSION} initialized — "
                    f"strategies={len(STRATEGY_CONFIG)}, "
                    f"ML={'ON' if self.ml_signal else 'OFF'}, "
                    f"Guards={'ON' if GUARDS_AVAILABLE else 'OFF'}")

        # V11.12: log effective SL/TP/trailing for every tag on startup
        for tag, cfg in STRATEGY_CONFIG.items():
            tp_str = f"TP={cfg.get('tp')}" if cfg.get('tp') else "trailing"
            trail_str = f" trail={cfg.get('trailing')}" if cfg.get('trailing') else ""
            status = "DISABLED" if tag in self._disabled_tags else "active"
            logger.info(f"[V11] CONFIG {tag}: SL={cfg['sl']} {tp_str}{trail_str} "
                        f"timeout={cfg.get('timeout')} stake={cfg.get('stake_pct', '?')} [{status}]")
        if EVAL_START_TRADE_ID > 0:
            logger.info(f"[V11] EVAL_WINDOW: only trades >= #{EVAL_START_TRADE_ID} "
                        f"(tainted legacy trades excluded)")

    # ══════════════════════════════════════════════════════════════════
    # HYPEROPT PARAMS BUILDER
    # ══════════════════════════════════════════════════════════════════

    def _build_params(self) -> dict:
        """Build params dict from all hyperopt DecimalParameter/IntParameter."""
        result = {}
        for cls in type(self).__mro__:
            for name, obj in vars(cls).items():
                if isinstance(obj, (DecimalParameter, IntParameter)):
                    result[name] = getattr(self, name).value
        return result

    # ══════════════════════════════════════════════════════════════════
    # INFORMATIVE PAIRS — 15m, 1h for all whitelist pairs + BTC
    # ══════════════════════════════════════════════════════════════════

    def informative_pairs(self):
        pairs = self.dp.current_whitelist()
        informative = []
        for pair in pairs:
            informative.append((pair, '15m'))
            informative.append((pair, '1h'))
        informative.append(("BTC/USDT", self.timeframe))
        return informative

    # ══════════════════════════════════════════════════════════════════
    # INDICATORS
    # ══════════════════════════════════════════════════════════════════

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """All indicators for all 15 strategies. Computed ONCE per candle."""
        pair = metadata.get('pair', '')

        # ── V10 CORE INDICATORS (unchanged) ──

        # BTC informative data (for ML features)
        if pair != 'BTC/USDT':
            self._add_btc_features(dataframe)

        # RSI 14
        delta = dataframe['close'].diff()
        gain = delta.where(delta > 0, 0.0).rolling(14).mean()
        loss = (-delta.where(delta < 0, 0.0)).rolling(14).mean()
        rs = gain / loss.replace(0, np.nan)
        dataframe['rsi'] = 100 - (100 / (1 + rs))

        # RSI 7
        gain7 = delta.where(delta > 0, 0.0).rolling(7).mean()
        loss7 = (-delta.where(delta < 0, 0.0)).rolling(7).mean()
        rs7 = gain7 / loss7.replace(0, np.nan)
        dataframe['rsi_7'] = 100 - (100 / (1 + rs7))

        # EMAs — V10 originals
        dataframe['ema_7'] = dataframe['close'].ewm(span=7, adjust=False).mean()
        dataframe['ema_16'] = dataframe['close'].ewm(span=16, adjust=False).mean()
        dataframe['ema_50'] = dataframe['close'].ewm(span=50, adjust=False).mean()
        dataframe['ema_200'] = dataframe['close'].ewm(span=200, adjust=False).mean()
        dataframe['sma_20'] = dataframe['close'].rolling(20).mean()

        # EMAs — V11 additions
        dataframe['ema_8'] = dataframe['close'].ewm(span=8, adjust=False).mean()
        dataframe['ema_9'] = dataframe['close'].ewm(span=9, adjust=False).mean()
        dataframe['ema_21'] = dataframe['close'].ewm(span=21, adjust=False).mean()

        # EMA distances (for ML features)
        dataframe['ema7_dist'] = ((dataframe['close'] - dataframe['ema_7']) / dataframe['ema_7']) * 100
        dataframe['ema16_dist'] = ((dataframe['close'] - dataframe['ema_16']) / dataframe['ema_16']) * 100
        dataframe['ema50_dist'] = ((dataframe['close'] - dataframe['ema_50']) / dataframe['ema_50']) * 100
        dataframe['ema_cross'] = (dataframe['ema_7'] > dataframe['ema_16']).astype(int)

        # Volume
        dataframe['volume_sma'] = dataframe['volume'].rolling(20).mean()
        dataframe['volume_ratio'] = dataframe['volume'] / dataframe['volume_sma'].replace(0, np.nan)
        if 'taker_buy_base' in dataframe.columns:
            dataframe['buy_pressure'] = dataframe['taker_buy_base'] / dataframe['volume'].replace(0, np.nan)
        else:
            dataframe['buy_pressure'] = 0.5

        # ATR
        tr = pd.concat([
            dataframe['high'] - dataframe['low'],
            (dataframe['high'] - dataframe['close'].shift()).abs(),
            (dataframe['low'] - dataframe['close'].shift()).abs(),
        ], axis=1).max(axis=1)
        dataframe['atr'] = tr.rolling(14).mean()
        dataframe['atr_pct'] = (dataframe['atr'] / dataframe['close']) * 100

        # Bollinger Bands
        bb_std = dataframe['close'].rolling(20).std()
        dataframe['bb_upper'] = dataframe['sma_20'] + 2 * bb_std
        dataframe['bb_lower'] = dataframe['sma_20'] - 2 * bb_std
        dataframe['bb_width'] = ((dataframe['bb_upper'] - dataframe['bb_lower']) / dataframe['sma_20'].replace(0, 1)) * 100
        dataframe['bb_position'] = (dataframe['close'] - dataframe['bb_lower']) / (dataframe['bb_upper'] - dataframe['bb_lower']).replace(0, np.nan)

        # MACD
        ema12 = dataframe['close'].ewm(span=12, adjust=False).mean()
        ema26 = dataframe['close'].ewm(span=26, adjust=False).mean()
        dataframe['macd'] = ema12 - ema26
        dataframe['macd_signal'] = dataframe['macd'].ewm(span=9, adjust=False).mean()
        dataframe['macd_hist'] = dataframe['macd'] - dataframe['macd_signal']

        # ADX
        plus_dm = dataframe['high'].diff()
        minus_dm = -dataframe['low'].diff()
        plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0)
        minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0)
        atr14 = tr.rolling(14).mean()
        dataframe['plus_di'] = 100 * (plus_dm.rolling(14).mean() / atr14.replace(0, np.nan))
        dataframe['minus_di'] = 100 * (minus_dm.rolling(14).mean() / atr14.replace(0, np.nan))
        dx = 100 * (dataframe['plus_di'] - dataframe['minus_di']).abs() / (dataframe['plus_di'] + dataframe['minus_di']).replace(0, np.nan)
        dataframe['adx'] = dx.rolling(14).mean()

        # Returns
        dataframe['return_1'] = dataframe['close'].pct_change(1) * 100
        dataframe['return_3'] = dataframe['close'].pct_change(3) * 100
        dataframe['return_6'] = dataframe['close'].pct_change(6) * 100
        dataframe['return_12'] = dataframe['close'].pct_change(12) * 100

        # Volatility
        dataframe['volatility_12'] = dataframe['return_1'].rolling(12).std()
        dataframe['volatility_48'] = dataframe['return_1'].rolling(48).std()

        # Candle features
        dataframe['spread_pct'] = ((dataframe['high'] - dataframe['low']) / dataframe['close']) * 100
        body = (dataframe['close'] - dataframe['open']).abs()
        wick = dataframe['high'] - dataframe['low']
        dataframe['body_ratio'] = body / wick.replace(0, np.nan)

        # Momentum score
        dataframe['momentum_score'] = (
            (dataframe['rsi_7'] - 50) / 50 * 0.3 +
            dataframe['ema7_dist'].clip(-5, 5) / 5 * 0.3 +
            (dataframe['macd_hist'] / dataframe['close'] * 1000).clip(-5, 5) / 5 * 0.2 +
            (dataframe['volume_ratio'] - 1).clip(-2, 2) / 2 * 0.2
        )

        # Time features
        if 'date' in dataframe.columns:
            dataframe['hour'] = pd.to_datetime(dataframe['date']).dt.hour
            dataframe['day_of_week'] = pd.to_datetime(dataframe['date']).dt.dayofweek
        else:
            dataframe['hour'] = 0
            dataframe['day_of_week'] = 0
        dataframe['is_weekend'] = (dataframe['day_of_week'] >= 5).astype(int)
        dataframe['session_asia'] = dataframe['hour'].isin(range(0, 8)).astype(int)
        dataframe['session_europe'] = dataframe['hour'].isin(range(8, 16)).astype(int)
        dataframe['session_us'] = dataframe['hour'].isin(range(13, 22)).astype(int)

        # ── V11 NEW INDICATORS ──

        # StochRSI
        rsi_min = dataframe['rsi'].rolling(14).min()
        rsi_max = dataframe['rsi'].rolling(14).max()
        rsi_range = rsi_max - rsi_min
        stochrsi = ((dataframe['rsi'] - rsi_min) / rsi_range.replace(0, np.nan)) * 100
        dataframe['stochrsi_k'] = stochrsi.rolling(3).mean()

        # Keltner Channel
        keltner_mid = dataframe['close'].ewm(span=20, adjust=False).mean()
        dataframe['keltner_middle'] = keltner_mid
        dataframe['keltner_upper'] = keltner_mid + dataframe['atr'] * 2
        dataframe['keltner_lower'] = keltner_mid - dataframe['atr'] * 2

        # Rolling VWAP (20 periods)
        typical_price = (dataframe['high'] + dataframe['low'] + dataframe['close']) / 3
        vol_tp = (typical_price * dataframe['volume']).rolling(20).sum()
        vol_sum = dataframe['volume'].rolling(20).sum()
        dataframe['vwap'] = vol_tp / vol_sum.replace(0, np.nan)

        # ── MERGE 15m INFORMATIVE ──
        if self.dp:
            try:
                inf_15m = self.dp.get_pair_dataframe(pair=pair, timeframe='15m')
                if inf_15m is not None and len(inf_15m) > 0:
                    inf_15m = self._add_15m_indicators(inf_15m)
                    dataframe = merge_informative_pair(
                        dataframe, inf_15m, self.timeframe, '15m', ffill=True
                    )
            except Exception as e:
                logger.warning(f"[V11] 15m merge failed for {pair}: {e}")

        # ── MERGE 1h INFORMATIVE ──
        if self.dp:
            try:
                inf_1h = self.dp.get_pair_dataframe(pair=pair, timeframe='1h')
                if inf_1h is not None and len(inf_1h) > 0:
                    inf_1h = self._add_1h_indicators(inf_1h)
                    dataframe = merge_informative_pair(
                        dataframe, inf_1h, self.timeframe, '1h', ffill=True
                    )
            except Exception as e:
                logger.warning(f"[V11] 1h merge failed for {pair}: {e}")

        return dataframe

    def _add_btc_features(self, dataframe: DataFrame) -> None:
        """Add BTC correlation/momentum features (for ML)."""
        btc_df = self.dp.get_pair_dataframe(pair="BTC/USDT", timeframe=self.timeframe)
        if btc_df is None or btc_df.empty:
            return
        btc_delta = btc_df['close'].diff()
        btc_gain = btc_delta.where(btc_delta > 0, 0.0).rolling(14).mean()
        btc_loss = (-btc_delta.where(btc_delta < 0, 0.0)).rolling(14).mean()
        btc_rs = btc_gain / btc_loss.replace(0, np.nan)
        btc_df['btc_rsi'] = 100 - (100 / (1 + btc_rs))
        btc_df['btc_return_1'] = btc_df['close'].pct_change(1) * 100
        btc_df['btc_return_12'] = btc_df['close'].pct_change(12) * 100
        btc_df['btc_volume_sma'] = btc_df['volume'].rolling(20).mean()
        btc_df['btc_volume_ratio'] = btc_df['volume'] / btc_df['btc_volume_sma'].replace(0, np.nan)
        btc_tr = pd.concat([
            btc_df['high'] - btc_df['low'],
            (btc_df['high'] - btc_df['close'].shift()).abs(),
            (btc_df['low'] - btc_df['close'].shift()).abs(),
        ], axis=1).max(axis=1)
        btc_df['btc_atr_pct'] = (btc_tr.rolling(14).mean() / btc_df['close']) * 100
        btc_plus_dm = btc_df['high'].diff()
        btc_minus_dm = -btc_df['low'].diff()
        btc_plus_dm = btc_plus_dm.where((btc_plus_dm > btc_minus_dm) & (btc_plus_dm > 0), 0)
        btc_minus_dm = btc_minus_dm.where((btc_minus_dm > btc_plus_dm) & (btc_minus_dm > 0), 0)
        btc_atr14 = btc_tr.rolling(14).mean()
        btc_plus_di = 100 * (btc_plus_dm.rolling(14).mean() / btc_atr14.replace(0, np.nan))
        btc_minus_di = 100 * (btc_minus_dm.rolling(14).mean() / btc_atr14.replace(0, np.nan))
        btc_dx = 100 * (btc_plus_di - btc_minus_di).abs() / (btc_plus_di + btc_minus_di).replace(0, np.nan)
        btc_df['btc_adx'] = btc_dx.rolling(14).mean()
        btc_df['btc_correlation'] = dataframe['close'].rolling(20).corr(btc_df['close'])

        for col in ['btc_rsi', 'btc_return_1', 'btc_return_12', 'btc_volume_ratio',
                    'btc_atr_pct', 'btc_adx', 'btc_correlation']:
            if col in btc_df.columns:
                dataframe[col] = btc_df[col].values[:len(dataframe)] if len(btc_df) >= len(dataframe) else 0.0

    def _add_15m_indicators(self, df: DataFrame) -> DataFrame:
        """Add indicators to 15m informative dataframe before merge."""
        # RSI
        delta = df['close'].diff()
        gain = delta.where(delta > 0, 0.0).rolling(14).mean()
        loss = (-delta.where(delta < 0, 0.0)).rolling(14).mean()
        rs = gain / loss.replace(0, np.nan)
        df['rsi'] = 100 - (100 / (1 + rs))
        # Volume SMA
        df['volume_sma20'] = df['volume'].rolling(20).mean()
        return df

    def _add_1h_indicators(self, df: DataFrame) -> DataFrame:
        """Add indicators to 1h informative dataframe before merge."""
        # EMAs
        df['ema_50'] = df['close'].ewm(span=50, adjust=False).mean()
        df['ema_200'] = df['close'].ewm(span=200, adjust=False).mean()
        # ADX
        tr = pd.concat([
            df['high'] - df['low'],
            (df['high'] - df['close'].shift()).abs(),
            (df['low'] - df['close'].shift()).abs(),
        ], axis=1).max(axis=1)
        plus_dm = df['high'].diff()
        minus_dm = -df['low'].diff()
        plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0)
        minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0)
        atr14 = tr.rolling(14).mean()
        plus_di = 100 * (plus_dm.rolling(14).mean() / atr14.replace(0, np.nan))
        minus_di = 100 * (minus_dm.rolling(14).mean() / atr14.replace(0, np.nan))
        dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
        df['adx'] = dx.rolling(14).mean()
        # Ichimoku
        df['ichi_tenkan'] = (df['high'].rolling(9).max() + df['low'].rolling(9).min()) / 2
        df['ichi_kijun'] = (df['high'].rolling(26).max() + df['low'].rolling(26).min()) / 2
        df['ichi_senkou_a'] = ((df['ichi_tenkan'] + df['ichi_kijun']) / 2).shift(26)
        df['ichi_senkou_b'] = ((df['high'].rolling(52).max() + df['low'].rolling(52).min()) / 2).shift(26)
        return df

    # ══════════════════════════════════════════════════════════════════
    # ENTRY SIGNALS — 15 strategies via enter_tag
    # ══════════════════════════════════════════════════════════════════

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        Apply all 15 strategies in priority order (low → high).
        First match sticks — enter_long != 1 guard prevents overwrite.
        """
        if not STRATEGY_GROUPS_AVAILABLE:
            logger.error("[V11] Strategy groups not loaded — no entries")
            return dataframe

        # Initialize columns (enter_tag MUST exist for signal clearing below)
        if 'enter_long' not in dataframe.columns:
            dataframe['enter_long'] = 0
        if 'enter_tag' not in dataframe.columns:
            dataframe['enter_tag'] = ''

        # Build hyperopt params dict (defaults in live, varied in hyperopt)
        params = self._build_params()

        # Priority: low → high (first match sticks — no overwrite)
        # 1. Revert (most frequent, lowest priority)
        dataframe = apply_h2r1(dataframe, metadata, params)
        dataframe = apply_h2r2(dataframe, metadata, params)
        dataframe = apply_h2r3(dataframe, metadata, params)
        # 2. Scalp (main group)
        dataframe = apply_h0s1(dataframe, metadata, params)
        dataframe = apply_h0s2(dataframe, metadata, params)
        dataframe = apply_h0s3(dataframe, metadata, params)
        # 3. Sniper (precise entries)
        dataframe = apply_h4b1(dataframe, metadata, params)
        dataframe = apply_h4b2(dataframe, metadata, params)
        dataframe = apply_h4f1(dataframe, metadata, params)
        # 4. Trend (long holds)
        dataframe = apply_h3t1(dataframe, metadata, params)
        dataframe = apply_h3t2(dataframe, metadata, params)
        dataframe = apply_h3t3(dataframe, metadata, params)
        # 5. Pump (highest priority — explosive moves)
        dataframe = apply_h1p1(dataframe, metadata, params)
        dataframe = apply_h1p2(dataframe, metadata, params)
        dataframe = apply_h1m1(dataframe, metadata, params)

        # ── One-time debug: evaluate 6 monitored silent tags ──
        _monitored = ('h1p1', 'h1p2', 'h1m1', 'h3t1', 'h3t3', 'h4f1')
        pair = metadata['pair']
        if pair not in self._signal_debug_done:
            self._signal_debug_done.add(pair)
            for tag in _monitored:
                count = int(((dataframe['enter_tag'] == tag) & (dataframe['enter_long'] == 1)).sum())
                logger.info(f"[V11] DEBUG {pair} {tag} evaluated, "
                            f"signal={'True' if count > 0 else 'False'} "
                            f"(rows={count}/{len(dataframe)})")

        # ── Clear signals for full/disabled tags (anti-spam) ──
        # Without this, Freqtrade retries denied signals every 5s → massive log spam
        try:
            open_trades = Trade.get_trades_proxy(is_open=True)
            tag_counts = {}
            for t in open_trades:
                tg = t.enter_tag or ''
                if tg:
                    tag_counts[tg] = tag_counts.get(tg, 0) + 1
            # Clear full tags
            for tag, cfg in STRATEGY_CONFIG.items():
                if tag_counts.get(tag, 0) >= cfg.get('max_open', 1):
                    mask = dataframe['enter_tag'] == tag
                    if mask.any():
                        dataframe.loc[mask, 'enter_long'] = 0
                        dataframe.loc[mask, 'enter_tag'] = ''
            # Clear disabled tags
            for tag in self._disabled_tags:
                mask = dataframe['enter_tag'] == tag
                if mask.any():
                    dataframe.loc[mask, 'enter_long'] = 0
                    dataframe.loc[mask, 'enter_tag'] = ''
        except Exception as e:
            logger.warning(f"[V11] Signal clearing error: {e}")

        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """V11.9: RSI overbought exit — catch reversals before SL."""
        dataframe.loc[
            (dataframe['rsi'] > 75) &
            (dataframe['volume_ratio'] > 0.5),
            'exit_long'
        ] = 1
        return dataframe

    # ══════════════════════════════════════════════════════════════════
    # CONFIRM TRADE ENTRY — Guards + Per-tag limits + Auto-disable
    # ══════════════════════════════════════════════════════════════════

    def _get_tag_winrate(self, tag: str):
        """Get (winrate, trade_count) from cache or DB. Cache TTL 5 min."""
        now = time.time()
        cached = self._tag_wr_cache.get(tag)
        if cached and now - cached[2] < self._wr_cache_ttl:
            return cached[0], cached[1]

        closed_trades = Trade.get_trades_proxy(is_open=False)
        # V11.12: skip tainted trades before eval window start
        tag_trades = [t for t in closed_trades
                      if t.enter_tag == tag and t.id >= EVAL_START_TRADE_ID]
        count = len(tag_trades)
        if count < MIN_TRADES_FOR_EVAL:
            self._tag_wr_cache[tag] = (None, count, now)
            return None, count

        recent = tag_trades[-EVAL_WINDOW:]
        wins = sum(1 for t in recent if t.close_profit and t.close_profit > 0)
        wr = wins / len(recent)
        self._tag_wr_cache[tag] = (wr, count, now)
        return wr, count

    def _get_pair_winrate(self, pair: str):
        """V11.7: Get (winrate, trade_count) for pair. Cache TTL 5 min."""
        now = time.time()
        cached = self._pair_wr_cache.get(pair)
        if cached and now - cached[2] < self._wr_cache_ttl:
            return cached[0], cached[1]

        closed_trades = Trade.get_trades_proxy(is_open=False)
        # V11.12: skip tainted trades before eval window start
        pair_trades = [t for t in closed_trades
                       if t.pair == pair and t.id >= EVAL_START_TRADE_ID]
        count = len(pair_trades)
        if count < MIN_PAIR_TRADES:
            self._pair_wr_cache[pair] = (None, count, now)
            logger.info(f"[V11] PAIR_WR: {pair} → None (count={count} < min={MIN_PAIR_TRADES}), "
                        f"eval_start={EVAL_START_TRADE_ID}")
            return None, count

        recent = pair_trades[-EVAL_WINDOW:]
        wins = sum(1 for t in recent if t.close_profit and t.close_profit > 0)
        wr = wins / len(recent)
        self._pair_wr_cache[pair] = (wr, count, now)
        logger.info(f"[V11] PAIR_WR: {pair} → WR={wr:.1%} ({wins}W/{len(recent)-wins}L) "
                    f"count={count} eval_start={EVAL_START_TRADE_ID}")
        return wr, count

    def _get_hour_winrate(self, hour_utc: int):
        """V11.8: Get (winrate, trade_count) for UTC hour. Cache TTL 30 min."""
        now = time.time()
        cached = self._hour_wr_cache.get(hour_utc)
        if cached and now - cached[2] < 1800:
            return cached[0], cached[1]

        closed_trades = Trade.get_trades_proxy(is_open=False)
        # V11.12: skip tainted trades before eval window start
        hour_trades = [t for t in closed_trades
                       if t.open_date and t.open_date.hour == hour_utc
                       and t.id >= EVAL_START_TRADE_ID]
        count = len(hour_trades)
        if count < 5:
            self._hour_wr_cache[hour_utc] = (None, count, now)
            return None, count

        recent = hour_trades[-20:]
        wins = sum(1 for t in recent if t.close_profit and t.close_profit > 0)
        wr = wins / len(recent)
        self._hour_wr_cache[hour_utc] = (wr, count, now)
        return wr, count

    def confirm_trade_entry(self, pair, order_type, amount, rate, time_in_force,
                            current_time, entry_tag, side, **kwargs) -> bool:
        """
        Multi-layer protection:
          1. Tag disabled check
          2. Per-tag concurrent limit
          3. Total exposure limit
          4. SessionLimits guard (all tags)
          5. AntiChase guard (skip for pump tags)
          6. AI Learner guard (all tags)
          7. ML Gate (h0s3 only — use cached decision)
        """
        tag = entry_tag or ''
        config = STRATEGY_CONFIG.get(tag, DEFAULT_CONFIG)
        now = datetime.now(timezone.utc)

        # 0. V11.10: hard-blocked pairs (blacklist race condition bypass)
        if pair in HARD_BLOCKED_PAIRS:
            return False

        # 1. Check if tag is auto-disabled
        if tag in self._disabled_tags:
            if self._should_log(f"dis_{tag}"):
                logger.info(f"[V11] {pair} [{tag}] BLOCKED: tag auto-disabled")
            return False

        # 2. Auto-disable: cached WR check (DB query max once per 5 min per tag)
        wr, count = self._get_tag_winrate(tag)
        if wr is not None and wr < MIN_WINRATE:
            if tag not in self._disabled_tags:
                self._disabled_tags.add(tag)
                self._save_disabled_tags()
                logger.warning(f"[V11] AUTO-DISABLE: {tag} WR={wr:.0%} < {MIN_WINRATE:.0%} "
                               f"on {count} trades — BLOCKED")
            return False

        # 2.5 V11.7: Per-pair WR guard (blocks toxic pairs like DOGE)
        # V11.15: fail-OPEN — no data = allow (other guards handle risk)
        # Only block when we HAVE data and it shows bad WR
        pair_wr, pair_count = self._get_pair_winrate(pair)
        if pair_wr is not None and pair_wr < MIN_PAIR_WINRATE:
            logger.warning(f"[V11] {pair} [{tag}] PAIR GUARD BLOCKED: "
                           f"WR={pair_wr:.0%} < {MIN_PAIR_WINRATE:.0%} on {pair_count} trades")
            return False

        # 2.6 V11.11: Static kill hours (proven 0% WR, instant block)
        current_hour = datetime.now(timezone.utc).hour
        if KILL_HOURS and current_hour in KILL_HOURS:
            if self._should_log(f"kzs_{current_hour}"):
                logger.warning(f"[V11] KILL HOUR {current_hour}:00 UTC — static block")
            return False
        # 2.6b V11.8: Dynamic kill zone — block hours with WR < 35%
        if KILL_ZONE_ENABLED:
            hour_wr, hour_count = self._get_hour_winrate(current_hour)
            if hour_wr is not None and hour_wr < 0.35:
                if self._should_log(f"kz_{current_hour}"):
                    logger.warning(f"[V11] KILL ZONE BLOCKED: hour {current_hour}:00 UTC "
                                   f"WR={hour_wr:.0%} on {hour_count} trades")
                return False

        # 3. Per-tag concurrent limit
        open_trades = Trade.get_trades_proxy(is_open=True)
        tag_count = sum(1 for t in open_trades if t.enter_tag == tag)
        if tag_count >= config['max_open']:
            if self._should_log(f"tag_{tag}_{pair}"):
                logger.info(f"[V11] {pair} [{tag}] BLOCKED: per-tag limit "
                            f"({tag_count}/{config['max_open']})")
            return False

        # 3. Total exposure check
        total_exposure = sum(t.stake_amount for t in open_trades)
        try:
            balance = self.wallets.get_free('USDT') + total_exposure
        except Exception as e:
            logger.error(f"[V11] FAIL-CLOSED: Cannot read wallet: {e} — BLOCKING entry")
            return False

        # Estimate actual stake (percent mode or fixed fallback)
        new_stake = balance * config.get('stake_pct', BASE_STAKE_PCT) if STAKE_MODE == 'percent' \
            else config['stake']
        if total_exposure + new_stake > balance * MAX_EXPOSURE_RATIO:
            if self._should_log(f"exp_{pair}"):
                logger.warning(f"[V11] EXPOSURE LIMIT: {total_exposure + new_stake:.0f} > "
                               f"{MAX_EXPOSURE_RATIO*100:.0f}% of {balance:.0f}")
            return False

        # 4. SessionLimits (all tags)
        if self.session_limits:
            try:
                closed_trades = Trade.get_trades_proxy(is_open=False)
                _group = config.get('group', 'unknown')
                can, reason = self.session_limits.can_trade(closed_trades, tag=tag, group=_group)
                if not can:
                    self._guard_blocks += 1
                    if self.guard_stats:
                        self.guard_stats.record_block("session_limits", pair, reason)
                    if self._should_log(f"sl_{pair}"):
                        logger.info(f"[V11] {pair} [{tag}] BLOCKED by SessionLimits: {reason}")
                    return False
                if self.guard_stats:
                    self.guard_stats.record_pass("session_limits", pair)
            except Exception as e:
                logger.error(f"[V11] SessionLimits error: {e}")

        # 5. AntiChase (skip for pump strategies)
        if self.anti_chase and not config.get('skip_anti_chase', False):
            try:
                df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
                if df is not None and not df.empty:
                    is_chasing, chase_reason = self.anti_chase.is_chasing(df, -1, pair)
                    if is_chasing:
                        self._guard_blocks += 1
                        if self.guard_stats:
                            self.guard_stats.record_block("anti_chase", pair, chase_reason)
                        if self._should_log(f"ac_{pair}"):
                            logger.info(f"[V11] {pair} [{tag}] BLOCKED by AntiChase: {chase_reason}")
                        return False
                    if self.guard_stats:
                        self.guard_stats.record_pass("anti_chase", pair)
            except Exception as e:
                logger.error(f"[V11] AntiChase error: {e}")

        # 6. AI Learner (all tags)
        if self.ai_learner:
            try:
                can_trade, ai_reason, ai_conf = self.ai_learner.should_trade(
                    pair, now.hour, now.strftime('%A')
                )
                if not can_trade:
                    self._guard_blocks += 1
                    if self.guard_stats:
                        self.guard_stats.record_block("ai_learner", pair, ai_reason)
                    if self._should_log(f"ai_{pair}"):
                        logger.info(f"[V11] {pair} [{tag}] BLOCKED by AI Learner: {ai_reason}")
                    return False
                if self.guard_stats:
                    self.guard_stats.record_pass("ai_learner", pair)
            except Exception as e:
                logger.error(f"[V11] AI Learner error: {e}")

        # 7. ML Gate (only for tags with use_ml=True, i.e. h0s3)
        if config.get('use_ml', False) and self.ml_signal and self.use_ml:
            try:
                # Read ML decision from per-pair stake cache (set in custom_stake_amount)
                decision = None
                cached = self._ml_stake_cache.get(pair)
                if cached and time.time() - cached.timestamp < 300:
                    decision = cached.decision
                if decision is None:
                    df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
                    if df is not None and not df.empty:
                        decision = self.ml_signal.should_enter(pair, df)

                if decision and not decision['enter']:
                    self._ml_blocks += 1
                    if self.guard_stats:
                        self.guard_stats.record_block("ml_gate", pair, decision['reason'])
                    if self._should_log(f"ml_{pair}"):
                        logger.info(f"[V11] {pair} [{tag}] BLOCKED by ML: {decision['reason']}")
                    return False

                if decision and decision['enter']:
                    self._ml_entries += 1
                    if self.guard_stats:
                        self.guard_stats.record_pass("ml_gate", pair)
                    logger.info(f"[V11] {pair} [{tag}] ML APPROVED: "
                                f"conf={decision['confidence']:.3f}")
            except Exception as e:
                logger.error(f"[V11] ML Gate error: {e}")
                if not self.ml_fallback_to_v9:
                    return False

        # ── APPROVED ──
        self._total_entries += 1
        self._tag_entries[tag] = self._tag_entries.get(tag, 0) + 1
        logger.info(f"[V11] {pair} [{tag}] ENTRY APPROVED @ {rate:.6f}")
        return True

    # ══════════════════════════════════════════════════════════════════
    # CUSTOM STAKE — Per-tag sizing
    # ══════════════════════════════════════════════════════════════════

    def custom_stake_amount(self, pair: str, current_time: datetime,
                            current_rate: float, proposed_stake: float,
                            min_stake: Optional[float], max_stake: float,
                            leverage: float, entry_tag: Optional[str],
                            side: str, **kwargs) -> float:
        """
        Per-tag stake from STRATEGY_CONFIG.
        For h0s3 (ML): pre-compute ML decision and cache for confirm_trade_entry.
        NOTE: Freqtrade calls this BEFORE confirm_trade_entry.
        """
        tag = entry_tag or ''
        config = STRATEGY_CONFIG.get(tag, DEFAULT_CONFIG)

        # Early exit: disabled / per-tag limit / exposure / pair WR (prevents retry spam)
        # Returning 0 makes Freqtrade silently skip — no "Long signal found" log
        if tag in self._disabled_tags:
            return 0
        # V11.10: hard-blocked pairs (blacklist race condition bypass)
        if pair in HARD_BLOCKED_PAIRS:
            if self._should_log(f"hb_{pair}"):
                logger.warning(f"[V11] {pair} [{tag}] HARD BLOCKED — returning 0")
            return 0
        # V11.7: block toxic pairs early (before any DB/wallet queries)
        # V11.15: fail-OPEN — no data = allow (other guards handle risk)
        pair_wr, pair_count = self._get_pair_winrate(pair)
        if pair_wr is not None and pair_wr < MIN_PAIR_WINRATE:
            return 0
        # V11.11: static kill hours (proven 0% WR, no warmup needed)
        kz_hour = datetime.now(timezone.utc).hour
        if KILL_HOURS and kz_hour in KILL_HOURS:
            return 0
        # V11.16: pair cooldown after loss (prevents cluster failures like ZRO 3x in 20min)
        cooldown_until = self._pair_cooldown.get(pair)
        if cooldown_until and time.time() < cooldown_until:
            return 0
        # V11.8: dynamic kill zone — block in losing hours (anti-spam: return 0)
        if KILL_ZONE_ENABLED:
            kz_wr, _ = self._get_hour_winrate(kz_hour)
            if kz_wr is not None and kz_wr < 0.35:
                return 0
        try:
            open_trades = Trade.get_trades_proxy(is_open=True)
            tag_count = sum(1 for t in open_trades if t.enter_tag == tag)
            if tag_count >= config.get('max_open', 1):
                return 0
            # Exposure limit — must match confirm_trade_entry (FAIL-CLOSED)
            total_exposure = sum(t.stake_amount for t in open_trades)
            free = self.wallets.get_free('USDT')
            total_balance = free + total_exposure
            if total_balance > 0 and total_exposure / total_balance > MAX_EXPOSURE_RATIO:
                return 0
        except Exception:
            return 0  # FAIL-CLOSED: wallet error → block entry

        # Dynamic stake: percent of total balance (scales with compound)
        if STAKE_MODE == 'percent':
            try:
                free_balance = self.wallets.get_free('USDT')
                total_in_trades = sum(t.stake_amount for t in Trade.get_trades_proxy(is_open=True))
                total_balance = free_balance + total_in_trades
                base_stake = total_balance * config.get('stake_pct', BASE_STAKE_PCT)
                base_stake = max(base_stake, MIN_STAKE_USD)
            except Exception:
                base_stake = config.get('stake', 7)  # fallback to fixed
        else:
            base_stake = config.get('stake', 7)

        # ML sizing for h0s3 (pre-compute and cache)
        # Cache ML results for 300s (one candle) to avoid re-computation on retries
        multiplier = 1.0
        if config.get('use_ml', False) and self.ml_signal and self.use_ml:
            now = time.time()
            # Cleanup stale cache entries (TTL 10 min)
            if len(self._ml_stake_cache) > 50:
                self._ml_stake_cache = {
                    k: v for k, v in self._ml_stake_cache.items()
                    if now - v.timestamp < 600
                }
            cached = self._ml_stake_cache.get(pair)
            if cached and now - cached.timestamp < 300:
                multiplier = cached.multiplier
            else:
                try:
                    df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
                    if df is not None and not df.empty:
                        decision = self.ml_signal.should_enter(pair, df)
                        if decision and decision['enter']:
                            multiplier = decision.get('size_multiplier', 1.0)
                        self._ml_stake_cache[pair] = MLCacheEntry(
                            timestamp=now, multiplier=multiplier,
                            pair=pair, decision=decision
                        )
                except Exception as e:
                    logger.error(f"[V11] ML sizing error: {e}")

        # V11.16: cap ML multiplier at 1.5x (Trade166: 2x pushed stake to 19.5%)
        multiplier = min(multiplier, 1.5)
        stake = base_stake * multiplier
        stake = max(stake, min_stake or 5.0)
        stake = min(stake, max_stake)

        if multiplier != 1.0:
            today = datetime.now(timezone.utc).date()
            if self._ml_logged_day != today:
                self._ml_logged_pairs.clear()
                self._ml_logged_day = today
            last_logged = self._ml_logged_pairs.get(pair, 0)
            if time.time() - last_logged > 300:
                logger.info(f"[V11] {pair} [{tag}] stake: ${base_stake:.0f} x {multiplier:.2f} = ${stake:.2f}")
                self._ml_logged_pairs[pair] = time.time()

        return stake

    # ══════════════════════════════════════════════════════════════════
    # CUSTOM EXIT — Per-tag TP, timeout, trailing, conditions
    # ══════════════════════════════════════════════════════════════════

    def custom_exit(self, pair, trade, current_time, current_rate,
                    current_profit, **kwargs):
        """
        Per-tag exit logic:
          1. TP (fixed take profit)
          2. Trailing stop (activated after profit threshold)
          3. Timeout (max candles held)
          4. Custom conditions per group
        """
        tag = trade.enter_tag or ''
        config = STRATEGY_CONFIG.get(tag, DEFAULT_CONFIG)

        # 1+2. V11.12: config-first — strategy_config.py is the source of truth
        # Hyperopt defaults were OVERRIDING config: h2r1 TP=0.8% instead of 1.2%, etc.
        tp = config.get('tp')
        trailing = config.get('trailing')

        if tp:
            if trailing and trade.max_rate > 0:
                # Hybrid mode (H2 Revert): TP is min exit, trailing captures upside
                # Safety: only activate trailing when peak > tp + callback
                # This guarantees trailing exit >= tp (mathematical proof:
                #   exit = peak - callback >= (tp + callback) - callback = tp)
                peak_profit = (trade.max_rate - trade.open_rate) / trade.open_rate
                callback = trailing * 0.4
                if peak_profit > tp + callback:
                    # Price exceeded TP by enough margin → trail from peak
                    drop_from_peak = peak_profit - current_profit
                    if drop_from_peak >= callback:
                        return f'{tag}_trail'
                    # Trailing not triggered yet — let it ride
                elif current_profit >= tp:
                    # At TP but peak hasn't exceeded safety margin → take profit
                    return f'{tag}_tp'
            elif current_profit >= tp:
                # Pure TP mode (no trailing configured)
                return f'{tag}_tp'
        elif trailing and trade.max_rate > 0:
            # Pure trailing mode (h0s3, h1p1, etc.)
            peak_profit = (trade.max_rate - trade.open_rate) / trade.open_rate
            if peak_profit >= trailing:
                drop_from_peak = peak_profit - current_profit
                callback = trailing * 0.4
                if drop_from_peak >= callback:
                    return f'{tag}_trail'

        # 3. Timeout — V11.9: don't kill profitable trades
        timeout_candles = config.get('timeout', 12)
        trade_candles = (current_time - trade.open_date_utc).total_seconds() / 300
        if trade_candles >= timeout_candles:
            if current_profit > 0.003:
                # Good profit → let trail/custom exit handle it, don't timeout
                pass
            elif current_profit > 0:
                # Small profit (0-0.3%) → give 50% more time
                if trade_candles >= timeout_candles * 1.5:
                    return f'{tag}_time_profit'
            else:
                # In loss → close as before
                return f'{tag}_time'

        # 4. Custom group-specific exits
        return self._custom_group_exit(pair, trade, tag, config, current_profit, current_time)

    def _custom_group_exit(self, pair, trade, tag, config, current_profit, current_time):
        """Additional exit conditions per strategy group."""
        try:
            df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
            if df is None or df.empty:
                return None
            last = df.iloc[-1]

            # h0s2: exit when price reaches BB middle
            if tag == 'h0s2' and current_profit > 0.002:
                if last.get('close', 0) >= last.get('sma_20', 0) and last.get('sma_20', 0) > 0:
                    return 'h0s2_bb'

            # h2r1/h2r2: exit when price reaches BB middle / VWAP
            if tag == 'h2r1' and current_profit > 0.002:
                if last.get('close', 0) >= last.get('sma_20', 0) and last.get('sma_20', 0) > 0:
                    return 'h2r1_bb'
            if tag == 'h2r2' and current_profit > 0.002:
                vwap = last.get('vwap', 0)
                if vwap > 0 and last.get('close', 0) >= vwap:
                    return 'h2r2_vwap'

            # h2r3: exit at Keltner middle
            if tag == 'h2r3' and current_profit > 0.001:
                km = last.get('keltner_middle', 0)
                if km > 0 and last.get('close', 0) >= km:
                    return 'h2r3_kelt'

            # h1m1: exit on EMA9 cross below EMA21
            if tag == 'h1m1' and current_profit > 0:
                if last.get('ema_9', 0) < last.get('ema_21', 0):
                    return 'h1m1_ema'

            # h3t1: exit when 1h price drops below EMA50
            if tag == 'h3t1':
                close_1h = last.get('close_1h', 0)
                ema50_1h = last.get('ema_50_1h', 0)
                if close_1h > 0 and ema50_1h > 0 and close_1h < ema50_1h:
                    return 'h3t1_ema'

            # h3t2: exit when 1h Tenkan crosses below Kijun
            if tag == 'h3t2':
                tenkan = last.get('ichi_tenkan_1h', 0)
                kijun = last.get('ichi_kijun_1h', 0)
                if tenkan > 0 and kijun > 0 and tenkan < kijun:
                    return 'h3t2_ichi'

            # ── V11.9: Smart exits for tags that had NO custom exit ──

            # h0s3: ML Scalp — exit when momentum reversed
            # Entry: RSI<55 + EMA7>EMA16 → Exit: RSI>65 OR EMA7<EMA16
            if tag == 'h0s3' and current_profit > 0.002:
                if last.get('rsi', 50) > 65 or last.get('ema_7', 0) < last.get('ema_16', 0):
                    return 'h0s3_reversal'

            # h4b1: S/R Breakout — exit when volume fades + overbought
            # Entry: vol>2x + breakout → Exit: vol<0.8x + RSI>55
            if tag == 'h4b1' and current_profit > 0.002:
                if last.get('volume_ratio', 1) < 0.8 and last.get('rsi', 50) > 55:
                    return 'h4b1_vol_drop'

            # h4b2: Fibonacci — exit when price returns to BB middle
            # Entry: price at Fib 61.8% (low) → Exit: price >= SMA20 (middle)
            if tag == 'h4b2' and current_profit > 0.002:
                sma20 = last.get('sma_20', 0)
                if sma20 > 0 and last.get('close', 0) >= sma20:
                    return 'h4b2_bb_mid'

            # h1p1: Pump — exit when volume normalizes + overbought
            # Entry: vol>2x + ROC>0.6% → Exit: vol<1x + RSI>60
            if tag == 'h1p1' and current_profit > 0.002:
                if last.get('volume_ratio', 1) < 1.0 and last.get('rsi', 50) > 60:
                    return 'h1p1_pump_fade'

            # h1p2: BB Expansion — exit when price returns to BB middle
            # Entry: close near BB upper → Exit: close <= SMA20
            if tag == 'h1p2' and current_profit > 0.002:
                sma20 = last.get('sma_20', 0)
                if sma20 > 0 and last.get('close', 0) <= sma20:
                    return 'h1p2_bb_squeeze'

            # h3t3: Higher Highs — exit when 1h trend breaks
            # Entry: 1h HH + pullback → Exit: 1h close < EMA50
            if tag == 'h3t3' and current_profit > 0.001:
                close_1h = last.get('close_1h', 0)
                ema50_1h = last.get('ema_50_1h', 0)
                if close_1h > 0 and ema50_1h > 0 and close_1h < ema50_1h:
                    return 'h3t3_trend_break'

        except Exception as e:
            logger.debug(f"[V11] Group exit error {tag}: {e}")

        return None

    # ══════════════════════════════════════════════════════════════════
    # CUSTOM STOPLOSS — Per-tag SL
    # ══════════════════════════════════════════════════════════════════

    def custom_stoploss(self, pair, trade, current_time, current_rate,
                        current_profit, after_fill, **kwargs):
        """
        V11.7: Progressive breakeven SL + per-tag base SL.
        Once trade is in profit, tighten SL to protect gains.
        Freqtrade guarantees SL only moves UP (tighter), never back down.

        SKIP breakeven for trend/pump/momentum — they need wide room.
        Only apply to: scalp, revert, sniper.
        """
        tag = trade.enter_tag or ''
        config = STRATEGY_CONFIG.get(tag, DEFAULT_CONFIG)
        # V11.12: config-first — strategy_config.py is the source of truth
        # Hyperopt defaults were OVERRIDING config values (8/15 tags wrong SL!)
        base_sl = config['sl']

        # Progressive breakeven — ONLY for fast groups (scalp, revert, sniper)
        # Trend/pump/momentum need wide room: their SL is -1.5% to -2.5%
        # V11.8: raised thresholds — +0.3% was too close (< round-trip fee 0.15%)
        group = config.get('group', 'unknown')
        if group in ('scalp', 'revert', 'sniper'):
            if current_profit > 0.015:
                return max(base_sl, 0.008)    # profit > +1.5% → SL at +0.8%
            if current_profit > 0.010:
                return max(base_sl, 0.003)    # profit > +1.0% → SL at +0.3%
            if current_profit > 0.006:
                return max(base_sl, -0.002)   # profit > +0.6% → SL at -0.2%
            # V11.12: revert early breakeven — TP is fixed (+1.0-1.4%), protect from full reversal
            # Scalp has trailing (self-protecting), sniper has wider targets. Revert needs this.
            if group == 'revert' and current_profit > 0.004:
                return max(base_sl, -0.001)   # profit > +0.4% → SL at -0.1% (near breakeven)

        return base_sl

    # ══════════════════════════════════════════════════════════════════
    # TRADE EXIT FEEDBACK — Guards + AI Learner + SessionLimits
    # ══════════════════════════════════════════════════════════════════

    def confirm_trade_exit(self, pair, trade, order_type, amount, rate,
                           time_in_force, exit_reason, current_time,
                           **kwargs) -> bool:
        """Feedback loop to all learning systems + auto-disable evaluation."""
        profit_ratio = trade.calc_profit_ratio(rate)
        hold_minutes = (current_time - trade.open_date_utc).total_seconds() / 60
        entry_hour = trade.open_date_utc.hour
        entry_day = trade.open_date_utc.strftime('%A')
        tag = trade.enter_tag or ''

        logger.info(f"[V11] {pair} [{tag}] EXIT: {exit_reason}, "
                    f"profit={profit_ratio*100:+.2f}%, hold={hold_minutes:.0f}min")

        # GuardStats feedback
        if self.guard_stats:
            try:
                self.guard_stats.record_outcome(
                    pair, profit_ratio, hold_minutes, exit_reason, entry_hour
                )
            except Exception as e:
                logger.error(f"[V11] GuardStats feedback failed: {e}")

        # AI Learner feedback
        if self.ai_learner:
            try:
                self.ai_learner.record_trade(
                    pair=pair, profit_ratio=profit_ratio,
                    hold_minutes=hold_minutes, exit_reason=exit_reason,
                    entry_hour=entry_hour, entry_day=entry_day,
                )
            except Exception as e:
                logger.error(f"[V11] AI Learner feedback failed: {e}")

        # SessionLimits feedback
        if self.session_limits:
            try:
                _grp = TAG_TO_GROUP.get(tag, 'unknown') if tag else 'unknown'
                self.session_limits.record_trade(
                    profit_ratio * 100, exit_reason=exit_reason,
                    tag=tag, group=_grp,
                )
            except Exception as e:
                logger.error(f"[V11] SessionLimits feedback failed: {e}")

        # V11.16: pair cooldown after loss (prevents re-entry cluster failures)
        if profit_ratio < 0 and PAIR_LOSS_COOLDOWN_MIN > 0:
            self._pair_cooldown[pair] = time.time() + PAIR_LOSS_COOLDOWN_MIN * 60
            logger.info(f"[V11] {pair} PAIR COOLDOWN {PAIR_LOSS_COOLDOWN_MIN}min after loss {profit_ratio*100:+.2f}%")

        # Auto-disable evaluation
        self._evaluate_tag_performance(tag)

        return True

    # ══════════════════════════════════════════════════════════════════
    # BOT LOOP — Heartbeat, passport, periodic tasks
    # ══════════════════════════════════════════════════════════════════

    def bot_loop_start(self, current_time: datetime = None, **kwargs) -> None:
        """Hourly heartbeat + passport refresh."""
        if PASSPORT_AVAILABLE and time.time() - self._last_passport_ts > 300:
            try:
                write_passport(self)
                self._last_passport_ts = time.time()
            except Exception:
                pass

        if current_time and current_time.minute == 0 and current_time.second < 60:
            logger.info(f"[V11 HEARTBEAT] entries_by_tag={self._tag_entries}, "
                        f"ML_entries={self._ml_entries}, "
                        f"guard_blocks={self._guard_blocks}, "
                        f"disabled={self._disabled_tags}")
            if self.ml_signal:
                logger.info(f"[V11 ML] {self.ml_signal.get_stats()}")
            if self.guard_stats:
                logger.info(f"[V11 GUARDS] {self.guard_stats.get_report()}")
                # V11.15: force periodic save to disk (fixes stale guard_stats.json)
                try:
                    self.guard_stats._save()
                    self.guard_stats._dirty = False
                except Exception:
                    pass

    # ══════════════════════════════════════════════════════════════════
    # HELPERS
    # ══════════════════════════════════════════════════════════════════

    def _should_log(self, key: str) -> bool:
        """Throttle logging to once per 5 min per key."""
        now = time.time()
        if len(self._last_block_log) > 200:
            cutoff = now - 300
            self._last_block_log = {k: v for k, v in self._last_block_log.items() if v > cutoff}
        last = self._last_block_log.get(key, 0)
        if now - last >= BLOCK_LOG_THROTTLE:
            self._last_block_log[key] = now
            return True
        return False

    def _evaluate_tag_performance(self, tag: str) -> None:
        """Check if tag should be auto-disabled based on cached WR."""
        if not tag or tag in self._disabled_tags:
            return
        try:
            wr, count = self._get_tag_winrate(tag)
            if wr is not None and wr < MIN_WINRATE:
                self._disabled_tags.add(tag)
                self._save_disabled_tags()
                logger.warning(f"[V11] AUTO-DISABLE: {tag} WR={wr:.0%} < {MIN_WINRATE:.0%} "
                               f"on {count} trades — BLOCKED")
        except Exception as e:
            logger.error(f"[V11] Tag eval error: {e}")

    def _load_disabled_tags(self) -> None:
        """Load auto-disabled tags from file + merge PRE_DISABLED_TAGS."""
        path = '/freqtrade/user_data/data/strategies_disabled.json'
        try:
            if os.path.exists(path):
                with open(path, 'r') as f:
                    data = json.load(f)
                    self._disabled_tags = set(data) if isinstance(data, list) else set()
        except Exception as e:
            logger.warning(f"[V11] Failed to load disabled tags: {e}")
            self._disabled_tags = set()
        # V11.11: merge PRE_DISABLED_TAGS (proven losers, skip warmup)
        if PRE_DISABLED_TAGS:
            self._disabled_tags |= PRE_DISABLED_TAGS
        if self._disabled_tags:
            logger.info(f"[V11] Disabled tags: {self._disabled_tags}")

    def _save_disabled_tags(self) -> None:
        """Persist auto-disabled tags to file (atomic write)."""
        path = '/freqtrade/user_data/data/strategies_disabled.json'
        tmp_path = path + '.tmp'
        try:
            with open(tmp_path, 'w') as f:
                json.dump(sorted(self._disabled_tags), f)
                f.flush()
                os.fsync(f.fileno())
            os.replace(tmp_path, path)
        except Exception as e:
            logger.error(f"[V11] Failed to save disabled tags: {e}")
