    return coins


def _refresh_hotlist():
    """Met à jour _HOTLIST depuis les sources externes et persiste spy_hotlist.json."""
    global _HOTLIST
    now_str = datetime.utcnow().isoformat()
    try:
        fetched = _fetch_hotlist_coins()
        # Purger les entrées expirées
        _HOTLIST = {s: d for s, d in _HOTLIST.items() if d.get('expires_at', '') > now_str}
        # Ajouter les nouveaux (ne pas écraser une entrée existante plus fraîche)
        _new = []
        for sym, data in fetched.items():
            if sym not in _HOTLIST:
                _HOTLIST[sym] = data
                _new.append(sym)
        if _new:
            logger.info(
                f"   🔥 [HOTLIST] Nouveaux coins en surveillance: {', '.join(sorted(_new))} "
                f"— sources: {', '.join(set(_HOTLIST[s]['source'] for s in _new))}"
            )
            # Signaler les contradictions blacklist ↔ hotlist (override actif sur surge)
            _bl_conflicts = [s for s in _new if s in SPY_SYMBOL_BLACKLIST and s not in _BL_PERMANENT]
            if _bl_conflicts:
                logger.warning(
                    f"   ⚠️ [HOTLIST-OVERRIDE] {', '.join(_bl_conflicts)}: "
                    f"blacklist temp MAIS trending marché → override actif sur surge"
                )
        # Persister pour le dashboard
        try:
            with open(HOTLIST_FILE, 'w', encoding='utf-8') as _hf:
                json.dump(
                    {'last_update': now_str, 'hot_coins': _HOTLIST},
                    _hf, indent=2, ensure_ascii=False,
                )
        except Exception:
            pass
        if _HOTLIST:
            _names = ', '.join(sorted(_HOTLIST.keys()))
            logger.info(f"   📋 [HOTLIST] {len(_HOTLIST)} coin(s) surveillés: {_names}")
    except Exception as _e:
        logger.debug(f"[HOTLIST] Erreur refresh: {_e}")


# Chargement initial depuis le fichier (survit aux redémarrages sans refetch immédiat)
try:
    if os.path.exists(HOTLIST_FILE):
        with open(HOTLIST_FILE, 'r', encoding='utf-8') as _hf:
            _hl_data = json.load(_hf)
        _now_str = datetime.utcnow().isoformat()
        _HOTLIST = {s: d for s, d in _hl_data.get('hot_coins', {}).items()
                    if d.get('expires_at', '') > _now_str}
        if _HOTLIST:
            logger.info(f"   📋 [HOTLIST] Restauré depuis fichier: {', '.join(sorted(_HOTLIST.keys()))}")
except Exception:
    pass


# ═══════════════════════════════════════════════════════════════════════════════
# API CLIENT LÉGER
# ═══════════════════════════════════════════════════════════════════════════════

# 🔧 FIX 13/04: Fichier persistant de blacklist des symboles invalides (survit aux restarts)
_INVALID_SYMBOLS_CACHE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'invalid_symbols_cache.json')

def _load_invalid_symbols_cache() -> set:
    try:
        with open(_INVALID_SYMBOLS_CACHE_FILE, 'r') as f:
            return set(json.load(f))
    except Exception:
        return set()

def _save_invalid_symbols_cache(symbols: set):
    try:
        with open(_INVALID_SYMBOLS_CACHE_FILE, 'w') as f:
            json.dump(sorted(symbols), f)
    except Exception:
        pass


class SpyApiClient:
    """Client API ultraléger pour le scanner — Kraken (migration Binance→Kraken 07/08/2026).

    Les méthodes publiques (public_get, get_balance, market_buy, market_sell,
    get_symbol_step_size) conservent exactement le format de retour Binance
    pour ne modifier aucun appelant (SurgeDetector/SpyPositionManager/MarketSpy) :
    seul ce client traduit vers l'API réelle Kraken en interne.
    """

    # Symboles invalides sur cet exchange — auto-blacklistés après erreur -1121
    # 🔧 FIX 13/04: Chargés depuis fichier persistant au démarrage
    _invalid_symbols: set = _load_invalid_symbols_cache()

    def __init__(self):
        self.api_key = KRAKEN_API_KEY
        self.api_secret = KRAKEN_API_SECRET
        self.session = requests.Session()
        # 🔧 OPT 10/04: Pool de connexions persistant — évite la renégociation TLS à chaque requête
        _adapter = requests.adapters.HTTPAdapter(
            pool_connections=2, pool_maxsize=8, max_retries=0
        )
        self.session.mount("https://", _adapter)
        _load_kraken_pairs()
        _load_kraken_assets()

    # ── Auth Kraken (nonce + HMAC-SHA512, spec officielle Kraken) ──────────────

    def _kraken_sign(self, urlpath, data):
        postdata = urlencode(data)
        encoded = (str(data['nonce']) + postdata).encode()
        message = urlpath.encode() + hashlib.sha256(encoded).digest()
        mac = hmac.new(base64.b64decode(self.api_secret), message, hashlib.sha512)
        return base64.b64encode(mac.digest()).decode()

    def _kraken_private(self, endpoint: str, data: dict = None):
        """POST authentifié vers /0/private/<endpoint>. Retourne le 'result' Kraken ou None."""
        data = dict(data or {})
        data['nonce'] = str(int(time.time() * 1000))
        urlpath = f"/0/private/{endpoint}"
        headers = {'API-Key': self.api_key, 'API-Sign': self._kraken_sign(urlpath, data)}
        try:
            resp = self.session.post(f"{KRAKEN_BASE_URL}{urlpath}", data=data, headers=headers, timeout=15)
            result = resp.json()
            if result.get('error'):
                logger.error(f"API error: {result['error']}")
                return None
            return result.get('result')
        except Exception as e:
            logger.error(f"Signed request error: {e}")
            return None

    def _kraken_ticker_raw(self, altname):
        if not altname:
            return None
        try:
            resp = self.session.get(f"{KRAKEN_BASE_URL}/0/public/Ticker", params={'pair': altname}, timeout=10)
            data = resp.json()
            if data.get('error'):
                return None
            return next(iter(data.get('result', {}).values()), None)
        except Exception as e:
            logger.error(f"API error: {e}")
            return None

    # ── Shim de compatibilité Binance → Kraken (endpoints publics) ─────────────
    # Traduit les anciens appels `public_get(f"{TRADING_API}/api/v3/...")` vers
    # les endpoints Kraken réels, en reconstruisant un JSON au format Binance —
    # aucun site d'appel (SurgeDetector/SpyPositionManager/MarketSpy) à modifier.

    def public_get(self, url, params=None):
        params = params or {}
        try:
            if url.endswith('/api/v3/klines'):
                return self._kraken_klines(params)
            if url.endswith('/api/v3/ticker/price'):
                return self._kraken_ticker_price(params)
            if url.endswith('/api/v3/ticker/bookTicker'):
                return self._kraken_ticker_book(params)
            if url.endswith('/api/v3/ticker/24hr'):
                return self._kraken_ticker_24hr(params)
            if url.endswith('/api/v3/exchangeInfo'):
                return self._kraken_exchange_info(params)
            logger.error(f"API error: endpoint non migré vers Kraken ({url})")
            return None
        except Exception as e:
            logger.error(f"API error: {e}")
            return None

    def _kraken_klines(self, params):
        symbol = params.get('symbol', '')
        limit = int(params.get('limit', 30))
        altname = binance_symbol_to_kraken_altname(symbol)
        if not altname:
            return self._invalid_symbol_error(symbol)
        try:
            resp = self.session.get(f"{KRAKEN_BASE_URL}/0/public/OHLC",
                                     params={'pair': altname, 'interval': 1}, timeout=10)
            data = resp.json()
            if data.get('error'):
                return None
            result = data.get('result', {})
            rows_key = next((k for k in result if k != 'last'), None)
            rows = result.get(rows_key, [])[-limit:] if rows_key else []
            klines = []
            for t, o, h, l, c, vwap, vol, count in rows:
                open_time = int(t) * 1000
                quote_vol = float(vol) * float(vwap)
                # taker_buy_*: Kraken ne fournit pas la ventilation acheteur/vendeur
                # par bougie → approximé à 50% (valeur neutre, cf. limitation connue
                # déjà documentée pour trading_bot.py lors de cette même migration).
                klines.append([
                    open_time, o, h, l, c, vol,
                    open_time + 59_999, f"{quote_vol}", int(count),
                    f"{float(vol) / 2}", f"{quote_vol / 2}", "0",
                ])
            return klines
        except Exception as e:
            logger.error(f"API error: {e}")
            return None

    def _kraken_ticker_price(self, params):
        symbol = params.get('symbol', '')
        altname = binance_symbol_to_kraken_altname(symbol)
        if not altname:
            return self._invalid_symbol_error(symbol)
        info = self._kraken_ticker_raw(altname)
        if not info:
            return None
        return {'symbol': symbol, 'price': info['c'][0]}

    def _kraken_ticker_book(self, params):
        symbol = params.get('symbol', '')
        altname = binance_symbol_to_kraken_altname(symbol)
        if not altname:
            return self._invalid_symbol_error(symbol)
        info = self._kraken_ticker_raw(altname)
        if not info:
            return None
        return {'symbol': symbol, 'bidPrice': info['b'][0], 'askPrice': info['a'][0]}

    def _kraken_ticker_to_binance24hr(self, symbol, info):
        last = float(info['c'][0])
        open_ = float(info['o'])
        pct = ((last - open_) / open_ * 100) if open_ else 0.0
        return {
            'symbol': symbol,
            'lastPrice': info['c'][0],
            'openPrice': info['o'],
            'priceChangePercent': f"{pct:.3f}",
            'volume': info['v'][1],
            'quoteVolume': f"{float(info['v'][1]) * last}",
            'bidPrice': info['b'][0],
            'askPrice': info['a'][0],
        }

    def _kraken_ticker_24hr(self, params):
        _load_kraken_pairs()
        symbol = params.get('symbol')
        if symbol:
            altname = binance_symbol_to_kraken_altname(symbol)
            if not altname:
                return self._invalid_symbol_error(symbol)
            info = self._kraken_ticker_raw(altname)
            if not info:
                return None
            return self._kraken_ticker_to_binance24hr(symbol, info)
        # Sans symbole → marché complet (comme Binance /api/v3/ticker/24hr sans paramètre)
        try:
            resp = self.session.get(f"{KRAKEN_BASE_URL}/0/public/Ticker", timeout=15)
            data = resp.json()
            if data.get('error'):
                return None
            out = []
            for internal_key, info in data.get('result', {}).items():
                altname = _kraken_internal_to_altname.get(internal_key)
                pair_info = _kraken_pairs_cache.get(altname) if altname else None
                wsname = pair_info.get('wsname') if pair_info else None
                if not wsname or not wsname.endswith(f"/{_KRAKEN_QUOTE}"):
                    continue
                bin_symbol = kraken_altname_to_binance_symbol(altname)
                if not bin_symbol:
                    continue
                out.append(self._kraken_ticker_to_binance24hr(bin_symbol, info))
            return out
        except Exception as e:
            logger.error(f"API error: {e}")
            return None

    def _kraken_exchange_info(self, params):
        symbol = params.get('symbol', '')
        altname = binance_symbol_to_kraken_altname(symbol)
        if not altname:
            return None
        _load_kraken_pairs()
        info = _kraken_pairs_cache.get(altname)
        if not info:
            return None
        step = 10 ** (-info.get('lot_decimals', 8))
        return {'symbols': [{'symbol': symbol, 'filters': [{'filterType': 'LOT_SIZE', 'stepSize': f"{step}"}]}]}

    def _invalid_symbol_error(self, symbol):
        """Reproduit l'erreur Binance -1121 pour réutiliser l'auto-blacklist existante."""
        if symbol and symbol not in SpyApiClient._invalid_symbols:
            SpyApiClient._invalid_symbols.add(symbol)
            _save_invalid_symbols_cache(SpyApiClient._invalid_symbols)
            logger.warning(f"⛔ {symbol} ajouté à la blacklist invalide — paire absente du catalogue Kraken (plus jamais tenté)")
        return {'code': -1121, 'msg': 'symbole absent du catalogue Kraken'}

    # ── Comptes / ordres (privé, authentifié) ───────────────────────────────────

    def get_balance(self, asset="USDT"):
        result = self._kraken_private('Balance')
        if not result:
            return 0
        for code, qty in result.items():
            if kraken_asset_to_binance(code) == asset:
                return float(qty)
        return 0

    def market_buy(self, symbol, usdt_amount):
        altname = binance_symbol_to_kraken_altname(symbol)
        if not altname:
            return self._invalid_symbol_error(symbol)
        info = self._kraken_ticker_raw(altname)
        price = float(info['c'][0]) if info else 0
        if not price:
            return None
        volume = round(usdt_amount / price, 8)
        if TESTNET_MODE:
            # Kraken n'a pas de testnet spot public → dry-run: ordre simulé, jamais envoyé
            return {'orderId': f"dryrun-{int(time.time() * 1000)}", 'status': 'FILLED',
                    'executedQty': f"{volume}", 'cummulativeQuoteQty': f"{volume * price}",
                    'transactTime': int(time.time() * 1000),
                    'fills': [{'price': f"{price}", 'qty': f"{volume}", 'commission': '0', 'commissionAsset': ''}]}
        result = self._kraken_private('AddOrder', {
            'pair': altname, 'type': 'buy', 'ordertype': 'market', 'volume': f"{volume}",
        })
        if not result:
            return None
        return {'orderId': result.get('txid', [None])[0], 'status': 'FILLED',
                'executedQty': f"{volume}", 'cummulativeQuoteQty': f"{volume * price}",
                'transactTime': int(time.time() * 1000),
                'fills': [{'price': f"{price}", 'qty': f"{volume}", 'commission': '0', 'commissionAsset': ''}]}

    def market_sell(self, symbol, quantity, step_size=None):
        """Vente market avec quantité exacte (dry-run si TESTNET_MODE).
        🔧 FIX -2010 (hérité Binance): le solde réel peut être < quantité stockée
        après frais → on vérifie le vrai solde avant de vendre.
        """
        # Extraire le nom de l'asset (AIXBTUSDC → AIXBT, BTCUSDC → BTC)
        for quote in ('USDC', 'USDT', 'BUSD', 'USD', 'BTC', 'ETH', 'BNB'):
            if symbol.endswith(quote):
                asset = symbol[:-len(quote)]
                break
        else:
            asset = symbol

        try:
            real_balance = self.get_balance(asset)
            if real_balance > 0 and real_balance < quantity:
                logger.debug(f"market_sell: {asset} solde réel {real_balance} < qty stockée {quantity} → ajustement (frais)")
                quantity = real_balance
        except Exception:
            pass  # Fallback sur la quantité stockée

        # 🔧 FIX LOT_SIZE: si step_size absent (ex: après restart cache vide), le récupérer
        if not step_size:
            try:
                step_size = self.get_symbol_step_size(symbol)
            except Exception:
                pass

        if step_size and step_size > 0:
            precision = max(0, -int(round(np.log10(step_size))))
            quantity = round(quantity - (quantity % step_size), precision)

        altname = binance_symbol_to_kraken_altname(symbol)
        if not altname:
            return self._invalid_symbol_error(symbol)
        info = self._kraken_ticker_raw(altname)
        price = info['c'][0] if info else "0"

        if TESTNET_MODE:
            return {'orderId': f"dryrun-{int(time.time() * 1000)}", 'status': 'FILLED',
                    'executedQty': f"{quantity}", 'cummulativeQuoteQty': f"{quantity * float(price)}",
                    'transactTime': int(time.time() * 1000),
                    'fills': [{'price': f"{price}", 'qty': f"{quantity}", 'commission': '0', 'commissionAsset': ''}]}

        result = self._kraken_private('AddOrder', {
            'pair': altname, 'type': 'sell', 'ordertype': 'market', 'volume': f"{quantity}",
        })
        if not result:
            return None
        return {'orderId': result.get('txid', [None])[0], 'status': 'FILLED',
                'executedQty': f"{quantity}", 'cummulativeQuoteQty': f"{quantity * float(price)}",
                'transactTime': int(time.time() * 1000),
                'fills': [{'price': f"{price}", 'qty': f"{quantity}", 'commission': '0', 'commissionAsset': ''}]}

    def get_symbol_step_size(self, symbol):
        """Récupère step_size du symbole pour la précision de vente"""
        data = self._kraken_exchange_info({"symbol": symbol})
        if data and 'symbols' in data and len(data['symbols']) > 0:
            sym_info = data['symbols'][0]
            for f in sym_info.get('filters', []):
                if f['filterType'] == 'LOT_SIZE':
                    return float(f['stepSize'])
        return None


# ═══════════════════════════════════════════════════════════════════════════════
# SURGE DETECTOR - Le cœur du système
# ═══════════════════════════════════════════════════════════════════════════════

# ═══════════════════════════════════════════════════════════════════════════════
# WEBSOCKET MINITICKER — cache prix temps réel (parallèle au polling REST)
# ═══════════════════════════════════════════════════════════════════════════════

WS_MINITICKER_URL  = "wss://ws.kraken.com/v2"
WS_CACHE_MAX_AGE   = 3.0   # secondes — au-delà, fallback REST
WS_RECONNECT_DELAY = 5.0   # secondes entre reconnexions
WS_SUBSCRIBE_CHUNK = 200   # Kraken v2: abonnement par lots

class MiniTickerWSClient:
    """
    Client WebSocket channel "ticker" Kraken v2 (migration Binance→Kraken 07/08/2026).

    Contrairement à Binance (`!miniTicker@arr` qui pousse TOUTES les paires sans
    abonnement explicite), Kraken v2 exige de s'abonner à une liste de symboles.
    On s'abonne ici à TOUTES les paires /USD connues au démarrage (équivalent le
    plus proche du comportement Binance) — un nouveau listing Kraken ne sera visible
    en WS qu'après un redémarrage du process (le fallback REST couvre l'intervalle).

    On stocke {symbol façon Binance → {price, volume_24h, price_change_24h, ts}}.
    Le spy lit ce cache au lieu d'appeler GET /0/public/Ticker (économise ~200-400ms/scan).

    Fallback automatique : si le cache est vieux (> WS_CACHE_MAX_AGE), run_scan
    bascule sur le REST comme avant.
    """

    def __init__(self):
        self._cache: dict = {}           # symbol → dict
        self._lock = threading.Lock()
        self._last_update: float = 0.0
        self._is_connected: bool = False
        self._thread: threading.Thread | None = None
        self._stop_event = threading.Event()
        self._msg_count: int = 0

    # ── API publique ──────────────────────────────────────────────────────────────────

    def start(self):
        """Lance le thread WebSocket (daemon — s'arrête avec le process)."""
        if not _WS_AVAILABLE:
            logger.warning("   ⚠️ [WS] websockets non installé — mode REST uniquement")
            return
        self._stop_event.clear()
        self._thread = threading.Thread(target=self._run_loop, daemon=True, name="MiniTickerWS")
        self._thread.start()
        logger.info("   🔌 [WS] MiniTicker thread démarré")

    def stop(self):
        self._stop_event.set()

    def is_fresh(self) -> bool:
        """Retourne True si le cache a été mis à jour il y a < WS_CACHE_MAX_AGE s."""
        return self._is_connected and (time.time() - self._last_update) < WS_CACHE_MAX_AGE

    def get_tickers_as_list(self) -> list | None:
        """
        Retourne le cache sous forme de liste de dicts compatibles /api/v3/ticker/24hr.
        Retourne None si le cache est vide ou trop vieux.
        """
        if not self.is_fresh():
            return None
        with self._lock:
            return list(self._cache.values())

    def status_line(self) -> str:
        age = time.time() - self._last_update if self._last_update else 999
        return (f"WS {'\u2705' if self._is_connected else '\u274c'} | "
                f"msgs={self._msg_count} | age={age:.1f}s | "
                f"syms={len(self._cache)}")

    # ── Boucle interne ──────────────────────────────────────────────────────────────────

    def _run_loop(self):
        """Exécute l'event loop asyncio dans le thread dédié."""
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        try:
            loop.run_until_complete(self._ws_main())
        finally:
            loop.close()

    def _usd_wsnames(self) -> list:
        """Toutes les paires /USD du catalogue Kraken (chargé si besoin)."""
        _load_kraken_pairs()
        return [ws for ws in _kraken_wsname_to_altname if ws.endswith(f"/{_KRAKEN_QUOTE}")]

    async def _ws_main(self):
        """Boucle principale WebSocket avec reconnexion automatique."""
        while not self._stop_event.is_set():
            try:
                async with _websockets_lib.connect(
                    WS_MINITICKER_URL,
                    ping_interval=20,
                    ping_timeout=10,
                    close_timeout=5,
                ) as ws:
                    symbols = self._usd_wsnames()
                    for i in range(0, len(symbols), WS_SUBSCRIBE_CHUNK):
                        chunk = symbols[i:i + WS_SUBSCRIBE_CHUNK]
                        await ws.send(json.dumps({
                            "method": "subscribe",
                            "params": {"channel": "ticker", "symbol": chunk},
                        }))
                    self._is_connected = True
                    logger.info(f"   ✅ [WS] Ticker Kraken connecté → streaming prix temps réel ({len(symbols)} paires /USD)")
                    async for raw in ws:
                        if self._stop_event.is_set():
                            break
                        try:
                            self._process(raw)
                        except Exception:
                            pass
            except Exception as e:
                self._is_connected = False
                if not self._stop_event.is_set():
                    logger.warning(f"   ⚠️ [WS] Déconnecté ({e}) — reconnexion dans {WS_RECONNECT_DELAY:.0f}s")
                    await asyncio.sleep(WS_RECONNECT_DELAY)
        self._is_connected = False

    def _process(self, raw: str):
        """Parse un message du channel 'ticker' Kraken v2 et met à jour le cache."""
        msg = json.loads(raw)
        if not isinstance(msg, dict) or msg.get('channel') != 'ticker':
            return
        items = msg.get('data')
        if not isinstance(items, list):
            return
        now = time.time()
        new_entries = {}
        for item in items:
            wsname = item.get('symbol', '')
            if '/' not in wsname:
                continue
            base, quote = wsname.split('/', 1)
            if quote != _KRAKEN_QUOTE:
                continue
            base_bin = _KRAKEN_BASE_ALIAS_REV.get(base, base)
            s = f"{base_bin}{quote}"
            try:
                close = float(item['last'])
                q_vol = float(item['volume']) * float(item.get('vwap') or item['last'])
                pct = float(item.get('change_pct', 0.0))
            except (KeyError, ValueError, TypeError):
                continue
            new_entries[s] = {
                'symbol':             s,
                'lastPrice':          str(close),
                'quoteVolume':        str(q_vol),
                'priceChangePercent': str(round(pct, 3)),
            }
        if new_entries:
            with self._lock:
                self._cache.update(new_entries)
                self._last_update = now
            self._msg_count += 1


# ═══════════════════════════════════════════════════════════════════════════════

class SurgeDetector:
    """
    Détecte les hausses soudaines en comparant les snapshots de prix.
    
    Principe:
      - Chaque scan stocke le prix de TOUTES les paires
      - On compare scan N vs scan N-1 et N-2
      - Si hausse > seuil en si peu de temps → SURGE détecté
      - Confirmation rapide avec klines 1m (volume + direction)
    """
    
    def __init__(self):
        self.price_history = defaultdict(list)  # symbol → [(timestamp, price)]
        self.max_history = 500                   # 🔧 20/04: 200→500 (500 × 7s = ~58 min) — couvre fenêtre TREND_MOMENTUM 15min + marge
        self.cooldown = {}                       # symbol → timestamp
        self.cooldown_seconds = 240              # 4 min cooldown après détection
        self.cooldown_win = 300                  # 🔧 FIX: 5 min cooldown après exit gagnant (était 120s=2min, trop court pour coins pumpés)
        self.last_exit_win = {}                  # 🆕 symbol → True si dernier exit était gagnant
        # 🔧 OPT 17/03: Blocage progressif par coin après pertes répétées
        # 🔧 OPT 18/03: Persistance sur disque — survit aux redémarrages
        self.coin_consec_losses = defaultdict(int)
        self.coin_loss_blocked_until = {}
        self.coin_exit_price = {}           # 🆕 22/04: prix de sortie par coin → détection reprise tendance
        self.coin_block_no_early = set()    # 🆕 22/04: coins à ne PAS débloquer tôt (INSTANT_REVERSAL)
        self._load_loss_state()
        # 🆕 FIX 11/04: TYPE 4 — Long-trend snapshots (rebuilt en mémoire à chaque démarrage)
        self._lt_snapshots = defaultdict(list)  # symbol → [price, ...] (snapshots toutes les 6min)
        self._lt_last_ts = {}                   # symbol → timestamp dernier snapshot
        self._lt_trades_hour = []               # timestamps des LONG_TREND signalés (fenêtre 1h)

        # Sensibilité de détection (peut être ajustée dynamiquement côté testnet)
        self.surge_min_price_change = SURGE_MIN_PRICE_CHANGE
        self.surge_min_price_change_2 = SURGE_MIN_PRICE_CHANGE_2
        self.surge_sensitivity_mode = 'BASE'

    def set_scan_thresholds(self, min_change_1, min_change_2, mode='BASE'):
        """Met à jour les seuils de détection flash/breakout. Retourne True si changement."""
        changed = (
            abs(self.surge_min_price_change - float(min_change_1)) > 1e-9 or
            abs(self.surge_min_price_change_2 - float(min_change_2)) > 1e-9 or
            self.surge_sensitivity_mode != mode
        )
        self.surge_min_price_change = float(min_change_1)
        self.surge_min_price_change_2 = float(min_change_2)
        self.surge_sensitivity_mode = mode
        return changed
    
    def update_prices(self, tickers):
        """
        Met à jour les prix depuis les tickers 24h.
        Retourne la liste des surges détectés.
        """
        now = time.time()
        surges = []
        
        for ticker in tickers:
            symbol = ticker.get('symbol', '')
            
            try:
                price = float(ticker.get('lastPrice', 0))
                volume_24h = float(ticker.get('quoteVolume', 0))
                price_change_24h = float(ticker.get('priceChangePercent', 0))
            except (ValueError, TypeError):
                continue
            
            if price <= 0:
                continue
            
            # 🆕 22/04: Mémoriser le dernier prix pour déblockage anticipé (coin_exit_price)
            if not hasattr(self, '_last_price'):
                self._last_price = {}
            self._last_price[symbol] = price

            # Stocker le snapshot
            history = self.price_history[symbol]
            history.append((now, price))
            
            # Garder seulement les N derniers
            if len(history) > self.max_history:
                self.price_history[symbol] = history[-self.max_history:]
                history = self.price_history[symbol]

            # 🆕 FIX 11/04: Accumulation snapshots LONG_TREND (avant cooldown/bloc pour ne rien rater)
            _lt_last = self._lt_last_ts.get(symbol, 0)
            if now - _lt_last >= LONG_TREND_SNAPSHOT_INTERVAL:
                self._lt_snapshots[symbol].append(price)
                if len(self._lt_snapshots[symbol]) > LONG_TREND_MAX_SNAPSHOTS:
                    self._lt_snapshots[symbol].pop(0)
                self._lt_last_ts[symbol] = now

            # Besoin d'au moins 2 snapshots pour comparer
            if len(history) < 2:
                continue
            
            # Vérifier cooldown (adaptatif: plus court après un exit gagnant)
            if symbol in self.cooldown:
                cd_time = self.cooldown_win if self.last_exit_win.get(symbol, False) else self.cooldown_seconds
                if now - self.cooldown[symbol] < cd_time:
                    continue
            # 🔧 OPT 17/03: Blocage progressif après pertes répétées sur ce coin
            if now < self.coin_loss_blocked_until.get(symbol, 0):
                # 🆕 22/04: Déblocage anticipé si le coin continue à monter fortement depuis la sortie
                # Logique : si le prix a monté ≥ +2% depuis l'exit ET que le blocage est permis
                # (pas INSTANT_REVERSAL), lever le blocage — c'était une fausse sortie sur tendance forte
                _exit_px = self.coin_exit_price.get(symbol, 0)
                _no_early = symbol in self.coin_block_no_early
                if not _no_early and _exit_px > 0 and price > 0:
                    _rise_since_exit = ((price - _exit_px) / _exit_px) * 100
                    if _rise_since_exit >= 2.0:
                        # Le coin a continué à monter → c'était une sortie prématurée
                        remaining_min = (self.coin_loss_blocked_until[symbol] - now) / 60
                        logger.info(f"   🔓 {symbol}: hausse de +{_rise_since_exit:.1f}% depuis sortie "
                                    f"→ déblocage anticipé ({remaining_min:.0f}min restantes)")
                        del self.coin_loss_blocked_until[symbol]
                        # Ne pas réinitialiser consec_losses : le prochain gain le fera
                        # Laisser le scan continuer normalement ci-dessous
                    else:
                        continue
                else:
                    continue
            
            # ═══ DÉTECTION DE SURGE ═══
            
            # Variation vs scan précédent (~12s)
            prev_price = history[-2][1]
            change_1 = ((price - prev_price) / prev_price) * 100
            
            # Variation vs 2 scans (~24s)
            change_2 = 0
            if len(history) >= 3:
                prev2_price = history[-3][1]
                change_2 = ((price - prev2_price) / prev2_price) * 100
            
            # Variation vs 5 scans (~1 min)
            change_5 = 0
            if len(history) >= 6:
                prev5_price = history[-6][1]
                change_5 = ((price - prev5_price) / prev5_price) * 100
            
            # ═══ DÉTECTION SIMPLIFIÉE — 2 types uniquement (REFONTE 18/03) ═══
            # Objectif: détecter à la seconde, sans latence inutile
            is_surge = False
            surge_type = ""
            surge_strength = 0

            # TYPE 1 — FLASH: hausse rapide ≥ 1.5% en un seul scan (~7s)
            # Signature des vrais pumps (ANKR: +28% explosé en 1 bougie 1h)
            if change_1 >= self.surge_min_price_change:
                is_surge = True
                surge_type = "FLASH_SURGE"
                surge_strength = change_1

            # TYPE 2 — BREAKOUT: hausse progressive mais significative sur 2 scans
            # Capte les breakouts de range type ENJ (montée sur 2 scans consécutifs)
            elif change_2 >= self.surge_min_price_change_2 and change_1 >= 0.5:
                is_surge = True
                surge_type = "BREAKOUT_SURGE"
                surge_strength = change_2

            # TYPE 3 — MOMENTUM_SURGE : DÉSACTIVÉ (02/05/2026)
            # Stats: 37 trades, WR=51%, 38% HARD_SL → entrée systématiquement tardive
            # Réactiver si filtre d'entrée sur pullback est implémenté
            # if not is_surge and len(history) >= 21:
            #     ... (code original conservé — voir git ou backup)
            pass  # MOMENTUM_SURGE désactivé

            # TYPE 5 — TREND_MOMENTUM_SURGE : DÉSACTIVÉ (02/05/2026)
            # Stats: 78 trades, WR=50%, médiane=+0.01% → bruit statistique
            # Réactiver si seuils ou timing d'entrée sont améliorés
            # if not is_surge and len(history) >= 86:
            #     ... (code original conservé — voir git ou backup)
            pass  # TREND_MOMENTUM_SURGE désactivé

            # TYPE 4 — LONG_TREND_SURGE : ACTIF pour coins framework BUY_NOW (fib≥5)
            # Réactivé sélectivement 20/05: uniquement si le framework 7-couches valide
            # le coin comme BUY_NOW avec fiabilité ≥ 5/7. Seuil abaissé à 2% (vs 10%)
            # pour capter les tendances lentes que le flash surge ne détecte pas.
            _fw_entry = _AI_OPP_SCORES.get(symbol, {})
            _fw_buynow_active = (_fw_entry.get('action') == 'BUY_NOW' and
                                  _fw_entry.get('fiability', 0) >= 5)
            if not is_surge and _fw_buynow_active:
                _lt_snaps = self._lt_snapshots[symbol]
                if len(_lt_snaps) >= LONG_TREND_MIN_SNAPSHOTS:
                    _lt_oldest = _lt_snaps[0]
                    if _lt_oldest > 0:
                        _lt_rise = (price - _lt_oldest) / _lt_oldest * 100
                        # 🆕 20/05: seuil abaissé à 2% pour coins framework BUY_NOW (vs 10% général)
                        # Capture les tendances lentes validées par le framework 7-couches
                        _lt_min_rise = 2.0  # framework validé → seuil bas
                        if _lt_rise >= _lt_min_rise and change_1 >= 0.05:
                            _lt_pos = sum(1 for _i in range(1, len(_lt_snaps)) if _lt_snaps[_i] > _lt_snaps[_i-1])
                            _lt_monotone = _lt_pos >= max(1, len(_lt_snaps) - 1) * 0.6
                            if _lt_monotone:
                                self._lt_trades_hour = [_t for _t in self._lt_trades_hour if now - _t < 3600]
                                if len(self._lt_trades_hour) < LONG_TREND_MAX_PER_HOUR:
                                    _lt_cd_key = f"_lt_{symbol}"
                                    if now - self.cooldown.get(_lt_cd_key, 0) >= LONG_TREND_COOLDOWN:
                                        is_surge = True
                                        surge_type = "LONG_TREND_SURGE"
                                        surge_strength = round(_lt_rise, 2)
                                        self.cooldown[_lt_cd_key] = now
                                        self._lt_trades_hour.append(now)
                                        logger.info(f"   📈 [FW_LONG_TREND] {symbol}: hausse={_lt_rise:.1f}% sur {len(_lt_snaps)} snapshots (fib={_fw_entry.get('fiability')}/7 score={_fw_entry.get('score',0):.0f})")

            if is_surge:
                # 🔧 FIX 21/03: Pré-filtre already_pumped_24h AVANT l'append
                # Évite de lancer confirm_surge (API klines) sur un coin déjà trop pumpé
                # La confirmation klines fait le même rejet mais ça coûte 1 appel API inutile
                _pump_24h = round(price_change_24h, 2)
                # 🆕 16/04: FLASH extreme (≥3%/scan) → seuil étendu à 120% — breakout violent sur coin pumpé peut être légitime
                _is_extreme_pre = (surge_type == 'FLASH_SURGE' and surge_strength >= 3.0)
                _max_pump_pre = (SURGE_MAX_ALREADY_PUMPED_TRENDING if surge_type not in ('FLASH_SURGE', 'BREAKOUT_SURGE')
                                 else (SURGE_MAX_ALREADY_PUMPED_EXTREME_FLASH if _is_extreme_pre else SURGE_MAX_ALREADY_PUMPED))
                # 🔧 FIX: Re-entry post-gain — seuil pump réduit à 35% si on vient de gagner sur ce coin
                # (évite de racheter un coin épuisé à un prix plus haut après notre exit gagnant)
                if self.last_exit_win.get(symbol, False):
                    _max_pump_pre = min(_max_pump_pre, 35.0)
                if _pump_24h > _max_pump_pre:
                    logger.debug(f"   ⏭️ {symbol}: pre-filtre already_pumped_24h({_pump_24h}% > {_max_pump_pre}%"
                                 f"{' [re-entry post-gain]' if self.last_exit_win.get(symbol, False) else ''}"
                                 f") — skip sans appel API klines")
                # 🔧 FIX 31/03: Pré-filtre DOWNTREND — coin en chute libre sur 24h
                # A2ZUSDT: -25%/24h mais micro-rebonds de +1% déclenchent des achats dans la baisse
                # Un coin à -15%+ est structurellement en chute, les surges sont des dead cat bounces
                # Exception: FLASH ≥ 2.0% très fort = possible vrai renversement
                elif _pump_24h < SURGE_MAX_DECLINE_24H and not (surge_type == 'FLASH_SURGE' and surge_strength >= 2.0):
                    logger.info(f"   ⏭️ {symbol}: pre-filtre downtrend_24h({_pump_24h}% < {SURGE_MAX_DECLINE_24H}%)"
                                f" — micro-rebond dans chute, skip")
                # 🆕 FIX 11/04: Pré-filtre surge faible en déclin modéré
                # Exemples: FF(-6.7%/24h, surge=1.1%) → bruit; ENJ(-8%, surge=1.2%) → faux signal
                # Données réelles: 5 trades perdants sur 2 jours auraient été bloqués, 0 gagnants manqués
                elif surge_type == 'FLASH_SURGE' and surge_strength < 1.5 and _pump_24h < -3.0:
                    logger.info(f"   ⏭️ {symbol}: pre-filtre surge_faible_déclin({surge_strength:.1f}%<1.5%, {_pump_24h:.1f}%/24h) — impulsion bruit en tendance négative")
                # 🆕 FIX 14/04: Pré-filtre pump excessif 24h pour NON-FLASH surges
                # Un coin déjà +20% sur 24h qui génère un TRENDING/BUILDING/SLOW_PUMP = fin de cycle
                # Les FLASH_SURGE extrêmes (≥3%) peuvent être de vrais breakouts même sur coins pumpés
                # Mais les surges de tendance sur coins déjà sur-tendance = achat au sommet (GIGGLE, 币安人生)
                elif _pump_24h > 20.0 and surge_type not in ('FLASH_SURGE',) and surge_strength < 3.0:
                    logger.info(f"   ⏭️ {symbol}: pre-filtre pump_excessif_24h({_pump_24h:.1f}%>20%) sur {surge_type} — risque achat en fin de cycle")
                else:
                    surges.append({
                        'symbol': symbol,
                        'price': price,
                        'change_1scan': round(change_1, 3),
                        'change_2scan': round(change_2, 3),
                        'change_5scan': round(change_5, 3),
                        'volume_24h': volume_24h,
