                logger.info(f"   ➕ Spy auto: {', '.join(sorted(added)[:20])}" +
                            (f" +{len(added)-20} autres" if len(added) > 20 else ""))
            if removed:
                logger.info(f"   ➖ Spy retiré: {', '.join(sorted(removed))}")

        except Exception as e:
            logger.error(f"   ⚠️ Erreur _auto_update_watchlist: {e}")

    def _load_bot_positions(self):
        try:
            if os.path.exists(POSITIONS_FILE):
                with open(POSITIONS_FILE, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    return {k for k, v in data.items() if isinstance(v, dict) and 'entry_price' in v}
        except Exception:
            pass
        return set()
    
    # ─── SCAN PRINCIPAL ────────────────────────────────────────────────────
    
    def run_scan(self):
        """
        Un cycle de scan ultra-rapide:
        1. Fetch tous les tickers (1 appel API)
        2. Détecter les surges
        3. Confirmer avec klines 1m
        4. Acheter si confirmé
        5. Vérifier/vendre les positions existantes
        """
        self.scan_count += 1
        scan_start = time.time()
        self.current_phase = 'SCANNING'
        
        # Reset compteur horaire
        if time.time() - self.hour_start >= 3600:
            self.trades_this_hour = 0
            self.hour_start = time.time()
        
        # ═══ FETCH TICKERS — WS cache (1s) ou REST fallback (7s) ═══
        self.current_phase = 'FETCHING_TICKERS'
        _ws_tickers = self.ws_ticker.get_tickers_as_list()
        if _ws_tickers is not None:
            tickers = _ws_tickers
            self._ws_used_count += 1
            # Log WS status toutes les 50 scans
            if self.scan_count % 50 == 0:
                logger.info(f"   📡 [WS] {self.ws_ticker.status_line()} | "
                            f"WS={self._ws_used_count} REST={self._rest_used_count}")
        else:
            tickers = self.client.public_get(f"{SCAN_API}/api/v3/ticker/24hr")
            self._rest_used_count += 1
            if self._rest_used_count <= 3 or self._rest_used_count % 20 == 0:
                logger.info(f"   ♻️ [WS] Cache stale → fallback REST (REST={self._rest_used_count})")
        if not tickers:
            self.current_phase = 'WAITING'
            self._write_status()
            return
        
        # Filtrer éligibles
        eligible = []
        tickers_dict = {}
        
        for t in tickers:
            sym = t.get('symbol', '')
            try:
                tickers_dict[sym] = float(t['lastPrice'])
            except (ValueError, KeyError):
                continue
            
            if not sym.endswith('USD'):
                continue
            if EXCLUDE_STABLECOINS and sym in STABLECOINS:
                continue
            try:
                price = float(t.get('lastPrice', 0))
                vol = float(t.get('quoteVolume', 0))
            except (ValueError, TypeError):
                continue
            # 🆕 FIX 25/03: Les coins déjà dans la watchlist du bot principal
            # bénéficient de seuils assouplis (micro-caps suivis intentionnellement)
            in_watchlist = sym in self.watchlist
            is_priority  = sym in PRIORITY_SCAN_USD
            effective_min_price = 0.00005 if (in_watchlist or is_priority) else MIN_PRICE
            # 🔧 FIX 01/05: Les coins auto-ajoutés avec MIN_AUTO_VOLUME=500K doivent être scannés
            # avec le même seuil (500K), pas avec MIN_VOLUME_USDT=800K qui les bloquerait.
            # Ex: ZBTUSD(796K), RAYUSD(654K), TRBUSD(564K), TONUSD(727K) → zone morte sans ce fix.
            # 🔧 FIX 19/05: PRIORITY_SCAN_USD → volume minimum = 0 (toujours éligible).
            # ALGOUSD: testnet volume souvent <500K → manque les vraies hausses prod.
            # L'ENTRY_FILTER (5 règles) reste actif pour bloquer les signaux faibles.
            if is_priority:
                effective_min_vol = 0
            elif in_watchlist:
                effective_min_vol = WATCHLIST_MIN_VOLUME   # 500K
            else:
                effective_min_vol = MIN_VOLUME_USDT        # 800K
            # 🔧 FIX 11/04: Bypass MAX_PRICE pour les coins watchlist (ex: BTCUSD à 84k, ETHUSD)
            # BTC/ETH sont dans watchlist intentionnellement — MAX_PRICE les bloquerait sinon
            effective_max_price = float('inf') if (in_watchlist or is_priority) else MAX_PRICE
            if price < effective_min_price or price > effective_max_price or vol < effective_min_vol:
                continue
            if sym in self.positions.positions:
                continue
            
            eligible.append(t)
        
        # ═══ DÉTECTER LES SURGES ═══
        self.current_phase = 'DETECTING_SURGES'

        # 🧪 OPT TESTNET 06/08: sensibilité adaptative en phase de marché "plat"
        # Sans surges pendant longtemps, assouplir légèrement la détection brute.
        # Les garde-fous de confirm_surge/open_position restent inchangés.
        if TESTNET_MODE:
            if self._no_surge_scans >= 120:  # ~14 min à 7s/scan
                _m1, _m2, _mode = 0.85, 0.95, 'RELAXED_2'
            elif self._no_surge_scans >= 60:  # ~7 min
                _m1, _m2, _mode = 0.90, 1.00, 'RELAXED_1'
            else:
                _m1, _m2, _mode = SURGE_MIN_PRICE_CHANGE, SURGE_MIN_PRICE_CHANGE_2, 'BASE'

            if self.detector.set_scan_thresholds(_m1, _m2, _mode):
                logger.info(
                    f"   🧪 [TESTNET] Sensibilité {_mode}: FLASH≥{_m1:.2f}% | BREAKOUT≥{_m2:.2f}%/2 scans "
                    f"(no_surge_scans={self._no_surge_scans})"
                )

        surges = self.detector.update_prices(eligible)
        if surges:
            self._no_surge_scans = 0
        else:
            self._no_surge_scans += 1
        self.last_eligible_count = len(eligible)

        # ═══ CHECK POSITIONS (chaque scan!) ═══
        self.current_phase = 'CHECKING_POSITIONS'
        self.positions.check_positions(tickers_dict)
        
        # Enregistrer durée du scan
        self.last_scan_duration = time.time() - scan_start
        self.last_scan_time = time.time()
        self.scan_times.append(self.last_scan_duration)
        if len(self.scan_times) > 100:
            self.scan_times = self.scan_times[-100:]
        
        # 🧠 Vérifier le follow-through des surges précédents (60s après détection)
        if self.behavior and self._pending_ft_checks:
            now_ft = time.time()
            to_remove = []
            for ft_sym, ft_data in self._pending_ft_checks.items():
                elapsed = now_ft - ft_data['timestamp']
                if elapsed >= 60:  # 60s écoulées depuis le surge
                    check_price = tickers_dict.get(ft_sym)
                    if check_price:
                        self.behavior.feed_surge_observation(
                            ft_sym, ft_data['price'], check_price, elapsed
                        )
                    to_remove.append(ft_sym)
            for sym in to_remove:
                del self._pending_ft_checks[sym]

        if not surges:
            # Nettoyer les surges de plus de 4 heures
            self.last_surges = [s for s in self.last_surges if (time.time() - s.get('timestamp', 0)) < 14400]
            self.current_phase = 'WAITING'
            self._write_status()
            # 🆕 FIX: Refresh contexte macro même sans surge (toutes les 5 scans ~35s)
            # Avant: contexte jamais mis à jour si pas de surge → restart = NEUTRAL indéfini
            # Maintenant: detect_regime() utilise son cache 30s — pas d'API call supplémentaire
            if self.scan_count % 5 == 1:
                try:
                    self._market_ctx = get_btc_market_context()
                    self.positions._market_ctx = self._market_ctx
                    ctx = self._market_ctx
                    if ctx.get('regime') in ('BEAR', 'CORRECTION'):
                        logger.info(f"   🌡️ Régime: {ctx['regime']} (hors surge)")
                except Exception as e:
                    logger.debug(f"Contexte marché indisponible: {e}")
            if self.scan_count % 10 == 0:
                bh_line = f" | {self.behavior.get_status_line()}" if self.behavior else ''
                pnl_emoji = '📈' if self.session_pnl >= 0 else '📉'
                pnl_str = f" | {pnl_emoji} {self.session_pnl:+.1f}$ ({self.session_wins}W/{self.session_losses}L)" if (self.session_wins + self.session_losses) > 0 else ''
                logger.info(f"   💓 #{self.scan_count} | {len(eligible)} paires | "
                          f"Pos spy: {self.positions.count} | "
                          f"Surges total: {self.surges_detected}{pnl_str}{bh_line}")
            # 🎯 FRAMEWORK_BUY — vérifier toutes les 5 scans (~35s) même sans surge
            if self.scan_count % 5 == 0:
                _bot_pos_fw = self._load_bot_positions()
                self._check_framework_entries(tickers_dict, self._market_ctx, _bot_pos_fw)
            return

        # ═══ TRAITER LES SURGES ═══
        self.current_phase = 'PROCESSING_SURGES'
        self.surges_detected += len(surges)

        # 📡 Priorisation signaux macro (testnet): reclasser les surges par potentiel "explosif"
        signal_map = {}
        self._signal_snapshot = None
        self._signal_top_candidates = []
        if TESTNET_MODE and TESTNET_SIGNAL_PRIORITY and _SIGNALS_AVAILABLE:
            try:
                self._signal_snapshot = get_signal_snapshot()
                if self._signal_snapshot:
                    signal_map = self._signal_snapshot.get('symbol_signals', {}) or {}
                    _exp = self._signal_snapshot.get('explosive', {}) or {}
                    self._signal_top_candidates = _exp.get('top_candidates', []) or []
            except Exception:
                signal_map = {}

        for _s in surges:
            _sig = signal_map.get(_s['symbol'], {})
            _expl = float(_sig.get('explosive_score', 0.0) or 0.0)
            _setup = _sig.get('setup', '')
            _trap = _sig.get('trap_risk', 'LOW')

            _bonus = 0.0
            if _expl >= 7.5:
                _bonus += 0.90
            elif _expl >= 6.0:
                _bonus += 0.55
            elif _expl >= 4.5:
                _bonus += 0.20

            if _setup == 'SETUP_FORT':
                _bonus += 0.25
            elif _setup == 'SETUP_MOYEN':
                _bonus += 0.10

            if _trap == 'HIGH':
                _bonus -= 0.45
            elif _trap == 'MEDIUM':
                _bonus -= 0.20

            _s['_signal_explosive_score'] = _expl
            _s['_signal_setup'] = _setup
            _s['_signal_trap_risk'] = _trap
            _s['_priority_score'] = _s['surge_strength'] + _bonus

        surges.sort(key=lambda x: (x.get('_priority_score', x['surge_strength']), x['surge_strength']), reverse=True)

        if signal_map and surges:
            _top = surges[:3]
            _line = ', '.join([
                f"{s['symbol']} P={s.get('_priority_score', s['surge_strength']):.2f}"
                f"(surge={s['surge_strength']:.2f} sig={s.get('_signal_explosive_score', 0):.2f})"
                for s in _top
            ])
            logger.info(f"   📡 Priorisation signal (testnet): {_line}")

        # Ajouter les nouveaux surges avec timestamp
        now = time.time()
        for s in surges:
            self.last_surges.append({
                'symbol': s['symbol'],
                'strength': s['surge_strength'],
                'type': s['surge_type'],
                'timestamp': now
            })
        # Garder max 200 surges, les plus récents, de moins de 4 heures
        self.last_surges = [s for s in self.last_surges if (now - s.get('timestamp', 0)) < 14400]
        self.last_surges = self.last_surges[-200:]
        
        # 🆕 Refresh contexte macro toutes les 5 scans (~50s) pour adapter les seuils
        if self.scan_count % 5 == 1:
            try:
                self._market_ctx = get_btc_market_context()
                self.positions._market_ctx = self._market_ctx  # Partagé avec position manager
                ctx = self._market_ctx
                if ctx['is_freefall']:
                    logger.info(f"   🔴 MARCHÉ EN CHUTE LIBRE: BTC mom3h={ctx['btc_mom_3h']:.1f}% | "
                               f"Alts haussiers={ctx['bullish_pct']:.0f}% → seuils durcis")
                elif ctx['is_recovery_window']:
                    logger.info(f"   🟢 FENÊTRE DE REBOND: BTC mom5h={ctx['btc_mom_5h']:+.2f}% "
                               f"mom3h={ctx['btc_mom_3h']:+.2f}% → seuils assouplis")
            except Exception as e:
                logger.debug(f"Contexte marché indisponible: {e}")
        # 🔥 Refresh hotlist + scores IA — même cadence 40 scans (~5min)
        if self.scan_count % 40 == 3:
            _refresh_hotlist()
            _refresh_ai_opp_scores()

        market_ctx = self._market_ctx
        bot_positions = self._load_bot_positions()

        # 🧠 Régime comportemental — qualifier avant de trader
        behavior_regime = None
        if self.behavior:
            bh = self.behavior.get_regime()
            behavior_regime = bh['regime']

        for surge in surges:
            symbol = surge['symbol']
            surge_type = surge['surge_type']
            surge_strength = surge['surge_strength']
            sig_explosive = float(surge.get('_signal_explosive_score', 0.0) or 0.0)
            sig_setup = surge.get('_signal_setup', '')
            sig_trap = surge.get('_signal_trap_risk', 'LOW')
            sig_boosted = TESTNET_MODE and TESTNET_SIGNAL_PRIORITY and sig_explosive >= TESTNET_SIGNAL_MIN_EXPLOSIVE

            # 🔧 FIX 29/04: BREAKOUT_SURGE — confirmation différée d'un scan (~7s)
            # FLASH = violent, achat immédiat justifié. BREAKOUT (2 scans ~14s) = plus lent → vérifier que le prix tient.
            # Si le prix retrace > 0.3% en 7s → c'était un wick de fin de tendance (ex: ORCA +1.29% puis -0.87%)
            if surge_type == 'BREAKOUT_SURGE':
                pending = self._pending_breakouts.get(symbol)
                if pending is None:
                    # Premier passage → stocker et attendre le prochain scan
                    self._pending_breakouts[symbol] = {'price': surge['price'], 'ts': time.time(), 'surge': surge}
                    logger.info(f"\n   ⏳ BREAKOUT PENDING: {symbol} +{surge_strength:.2f}% @ {surge['price']:.6f} — confirmation dans 7s")
                    continue
                else:
                    # Deuxième passage → vérifier que le prix tient
                    del self._pending_breakouts[symbol]
                    age = time.time() - pending['ts']
                    detect_price = pending['price']
                    current_price = surge['price']
                    retrace = (detect_price - current_price) / detect_price * 100
                    if retrace > 0.3 or age > 20:
                        logger.info(f"\n   ❌ BREAKOUT WICK {symbol}: prix retombé {retrace:.2f}% en {age:.0f}s → wick évité")
                        self.detector.set_cooldown(symbol)
                        continue
                    # Prix tenu → procéder avec le surge original (meilleur prix d'entrée)
                    logger.info(f"\n   ✅ BREAKOUT CONFIRMÉ {symbol}: prix tenu ({retrace:+.2f}% en {age:.0f}s) → achat")
                    surge = pending['surge']  # Utiliser le surge original (prix de détection)
                    surge_type = surge['surge_type']
                    surge_strength = surge['surge_strength']

            # 🧠 Enregistrer le surge pour le follow-through check (60s plus tard)
            if self.behavior:
                self._pending_ft_checks[symbol] = {
                    'price': surge['price'],
                    'timestamp': time.time(),
                }

            logger.info(f"\n   ⚡ SURGE: {symbol} | {surge_type} | "
                       f"+{surge_strength:.2f}% | "
                       f"Δ1={surge['change_1scan']:+.2f}% "
                       f"Δ2={surge['change_2scan']:+.2f}% "
                       f"Δ5={surge['change_5scan']:+.2f}%")
            if TESTNET_MODE and TESTNET_SIGNAL_PRIORITY and (sig_explosive > 0 or sig_setup):
                logger.info(f"      📡 Signal: explosive={sig_explosive:.2f}/10 | setup={sig_setup or 'N/A'} | trap={sig_trap}")
            # 🔥 Hotlist: signaler si ce coin est en surveillance renforcée
            _hl_entry = _HOTLIST.get(symbol)
            _is_hotlist = bool(_hl_entry and _hl_entry.get('expires_at', '') > datetime.utcnow().isoformat())
            if _is_hotlist:
                logger.info(f"      🔥 [HOTLIST] {symbol}: {_hl_entry['reason']} → ENTRY_FILTER assoupli ≤2/5")
            # 🧠 AI_OPP: signaler si ce coin est dans le TOP IA
            _aio = _AI_OPP_SCORES.get(symbol, {})
            if _aio and _aio.get('score', 0) >= 55:
                _aio_icon = '🚀' if _aio['score'] >= 75 else '🧠'
                logger.info(
                    f"      {_aio_icon} [AI_OPP] {symbol}: score={_aio['score']:.0f}/100 "
                    f"rank=#{_aio['rank']} gain_pot=+{_aio['gain_potential']:.1f}% "
                    f"dir={_aio.get('direction','?')} → ENTRY_FILTER assoupli ≤2/5"
                )

            # 🔧 FIX 02/03: Le SPY est INDÉPENDANT — acheter même si le symbole
            # est dans la watchlist. Seul bloquer si le BOT a déjà une position ouverte.
            # Avant: skip si watchlist → 80% des surges ignorés (64 symboles = quasi tout)
            # 🔧 FIX 05/05: Blacklist globale — bloque tous les surge_types
            # 🔥 Exception: si coin trending CoinGecko/Binance (hotlist) + ban temporaire → override
            if symbol in SPY_SYMBOL_BLACKLIST:
                _bl_hl = _HOTLIST.get(symbol)
                _bl_hl_ok = bool(
                    _bl_hl and _bl_hl.get('expires_at', '') > datetime.utcnow().isoformat()
                    and symbol not in _BL_PERMANENT
                )
                if not _bl_hl_ok:
                    logger.info(f"      → ⛔ {symbol}: blacklisté SPY global (pertes chroniques)")
                    self.detector.set_cooldown(symbol)
                    continue
                logger.info(f"      → 🔥 [HOTLIST-OVERRIDE] {symbol}: {_bl_hl['reason']} → ban temp ignoré, signal marché actif")

            if symbol in bot_positions:
                logger.info(f"      → Skip (bot position active)")
                self.detector.set_cooldown(symbol)
                continue
            if self.trades_this_hour >= SPY_MAX_TRADES_PER_HOUR:
                logger.info(f"      → Skip (max trades/h)")
                continue

            # 🧠 FILTRE COMPORTEMENTAL — basé sur le comportement humain des participants
            if behavior_regime == BehaviorRegime.PANIQUE if _BEHAVIOR_AVAILABLE else False:
                # En PANIQUE: observation seule, on loggue mais on ne trade pas
                # Exception: FLASH extrême (≥ 3%) qui prouve un vrai événement
                if not (surge_type == 'FLASH_SURGE' and surge_strength >= 3.0):
                    # TESTNET: si setup macro explosif, on autorise l'essai pour apprendre
                    if sig_boosted and sig_setup in ('SETUP_FORT', 'SETUP_MOYEN'):
                        logger.info("      📡 TESTNET BOOST: PANIQUE bypass (setup macro explosif)")
                    else:
                        logger.info(f"      → 🧠 PANIQUE: observation seule "
                                   f"({self.behavior.get_status_line()})")
                        self._log_opportunity(surge, {}, executed=False,
                                            reason=f'behavior_panique(FTR={self.behavior.metrics["ftr"]:.0%})')
                        self.detector.set_cooldown(symbol)
                        continue
            elif behavior_regime == BehaviorRegime.SPECULATION if _BEHAVIOR_AVAILABLE else False:
                # En SPECULATION: exiger un surge plus fort que d'habitude
                min_strength = self.behavior.get_min_surge_strength()
                if sig_boosted:
                    min_strength = max(0.6, min_strength - 0.2)
                if surge_strength < min_strength:
                    logger.info(f"      → 🧠 SPECULATION: surge {surge_strength:.2f}% < {min_strength:.1f}% requis")
                    self._log_opportunity(surge, {}, executed=False,
                                        reason=f'behavior_speculation({surge_strength:.2f}%<{min_strength:.1f}%)')
                    self.detector.set_cooldown(symbol)
                    continue

            # 🆕 OPT 28/05: CORRECTION → FLASH_SURGE doit être ≥ 1.5% (vs 1.0%)
            # En correction, les spikes faibles (≤ 1.4%) sont quasi-systématiquement des dead-cat bounces.
            # (4 INSTANT_REVERSAL tous perdants en CORRECTION sur 5 jours = -35 USDT)
            # Exception: signal macro boosté (explosive_score ≥5) = événement exogène validé
            if (market_ctx.get('regime') == 'CORRECTION'
                    and surge_type == 'FLASH_SURGE'
                    and surge_strength < 1.5
                    and not sig_boosted):
                logger.info(f"      → Skip FLASH+CORRECTION: {surge_strength:.2f}% < 1.5% requis (dead-cat filter)")
                self.detector.set_cooldown(symbol)
                self._log_opportunity(surge, {}, executed=False,
                                      reason=f'flash_correction_weak({surge_strength:.2f}%<1.5%)')
                continue

            # 🆕 Filtre FREEFALL: en chute libre, seuls les FLASH très forts survivent
            # Base: backtest montre que tous les CREUX_REBOUND pendant la chute initiale
            # ont fini en STOP_LOSS. Les pumps s'inversent trop vite en bear actif.
            if market_ctx.get('is_freefall'):
                _freefall_min = 1.2 if sig_boosted else 1.5
                if not (surge_type == 'FLASH_SURGE' and surge_strength >= _freefall_min):
                    logger.info(f"      → Skip FREEFALL: BTC mom3h={market_ctx['btc_mom_3h']:.1f}% "
                               f"alts={market_ctx['bullish_pct']:.0f}% — "
                               f"{surge_type} {surge_strength:.2f}% insuffisant (requis: FLASH >= {_freefall_min:.1f}%)")
                    continue

            # 🔧 FIX 14/03: Filtre TRENDING_SURGE trop faible — exception Δ5/Δ2
            # BANANAS31: surge_strength=1.19% rejeté alors que Δ5=1.2% et Δ2=0.5% confirmaient la tendance
            _trend_min = 1.1 if sig_boosted else 1.3
            if surge_type == 'TRENDING_SURGE' and surge_strength < _trend_min:
                _c5 = surge.get('change_5scan', 0)
                _c2 = surge.get('change_2scan', 0)
                _hard_floor = 0.95 if sig_boosted else 1.1
                _need_c5 = 0.7 if sig_boosted else 0.8
                _need_c2 = 0.25 if sig_boosted else 0.3
                if surge_strength < _hard_floor or _c5 < _need_c5 or _c2 < _need_c2:
                    logger.info(f"      → Skip TRENDING trop faible: {surge_strength:.2f}% < {_trend_min:.1f}% "
                               f"(Δ5={_c5:.2f}% Δ2={_c2:.2f}% — exception: Δ5≥0.8 ET Δ2≥0.3 nécessaires)")
                    self.detector.set_cooldown(symbol)
                    self._log_opportunity(surge, {}, executed=False, reason=f'trending_weak({surge_strength:.2f}%<{_trend_min:.1f}%)')
                    continue

            # ✂️ FIX 15/03: Filtre ACCELERATING_SURGE trop faible (CFX -5.53€ avec 0.774%)
            # change_2 sert de surge_strength pour ce type → minimum 1.0% requis
            _acc_min = 0.65 if sig_boosted else 0.8
            if surge_type == 'ACCELERATING_SURGE' and surge_strength < _acc_min:
                logger.info(f"      → Skip ACCELERATING trop faible: {surge_strength:.2f}% < {_acc_min:.2f}% requis")
                self.detector.set_cooldown(symbol)
                self._log_opportunity(surge, {}, executed=False, reason=f'accelerating_weak({surge_strength:.2f}%<{_acc_min:.2f}%)')
                continue

            # 🔧 FIX 31/03 v2: Filtre SURGE ÉPUISÉ — le mouvement est CLAIREMENT terminé
            # Seulement les cas évidents: Δ1 minuscule (<0.7%) ET Δ5 beaucoup plus grand (×2.5)
            # KERNEL était bloqué à tort (Δ1=1.03% est encore un surge actif).
            # Ne cible que: Δ1=0.3-0.6% avec Δ5=2%+ → micro-rebond résiduel d'un vieux pump.
            _delta5 = surge.get('change_5scan', surge_strength)
            if (surge_type == 'FLASH_SURGE' and surge_strength < 0.7
                    and _delta5 >= surge_strength * 2.5):
                logger.info(f"      → Skip surge épuisé: Δ1={surge_strength:.2f}% mais Δ5={_delta5:.2f}% → mouvement fini")
                self.detector.set_cooldown(symbol)
                self._log_opportunity(surge, {}, executed=False, reason=f'surge_exhausted(Δ1={surge_strength:.2f}%,Δ5={_delta5:.2f}%)')
                continue

            # 🔧 FIX 04/06: Filtre Δ5/Δ1 relatif — achat trop tardif
            # Si Δ5 ≥ Δ1×1.5 ET Δ5 ≥ 1.5% : le move a démarré ≥2 scans avant la détection
            # → momentum décélère → on achète le sommet local. Analyse: WR near-zero-slip=32%
            # vs WR négatif-slip=60% → le retard à l'exécution est protecteur, la détection tardive ne l'est pas.
            # Exception: si surge_strength ≥ 2.0% (accélération forte encore active)
            if (surge_type == 'FLASH_SURGE'
                    and _delta5 >= surge_strength * 1.5
                    and _delta5 >= 1.5
                    and surge_strength < 2.0):
                logger.info(
                    f"      → Skip achat tardif: Δ1={surge_strength:.2f}% Δ5={_delta5:.2f}% "
                    f"(ratio={_delta5/surge_strength:.1f}x≥1.5) → momentum décélère → SKIP"
                )
                self.detector.set_cooldown(symbol)
                self._log_opportunity(surge, {}, executed=False,
                    reason=f'late_entry_ratio(Δ1={surge_strength:.2f}%,Δ5={_delta5:.2f}%,x{_delta5/surge_strength:.1f})')
                continue

            # 🔧 FIX 06/05: Filtre Δ5 absolu trop fort — le coin a déjà pumpé avant détection
            # Si Δ5 ≥ 2.5% : le mouvement a démarré il y a 35s (5 scans×7s)
            # Achat au sommet garanti → INSTANT_REVERSAL (ex: STXUSDC Δ5=3.78% → -3.57%)
            # Exception: surge actuel ≥ 2.0% (accélération encore active)
            if surge_type == 'FLASH_SURGE' and _delta5 >= 2.5 and surge_strength < 2.0:
                logger.info(f"      → Skip Δ5 trop fort: Δ1={surge_strength:.2f}% mais Δ5={_delta5:.2f}% → achat au sommet → SKIP")
                self.detector.set_cooldown(symbol)
                self._log_opportunity(surge, {}, executed=False, reason=f'delta5_stale(Δ1={surge_strength:.2f}%,Δ5={_delta5:.2f}%)')
                continue

            # 🔧 10/04: Blacklist FLASH_SURGE — coins dont les pumps sont trop courts pour le polling
            if surge_type == 'FLASH_SURGE' and symbol in FLASH_SURGE_BLACKLIST:
                logger.info(f"      → ⛔ {symbol}: blacklisté FLASH_SURGE (pump <10s, attend WebSocket)")
                self.detector.set_cooldown(symbol)
                continue

            # 🔧 15/04: Fast spikers — seuil d'entrée relevé (alternative à la blacklist complète)
            # On n'entre que si: surge_strength ≥ seuil OU Δ5 ≥ 2.0% (continuation confirmée)
            if surge_type == 'FLASH_SURGE' and symbol in FLASH_SURGE_STRICT:
                _strict_min = FLASH_SURGE_STRICT[symbol]
                _delta5_strict = surge.get('change_5scan', 0)
                if surge_strength < _strict_min and _delta5_strict < 2.0:
                    logger.info(f"      → ⏭️ {symbol}: surge {surge_strength:.2f}% < {_strict_min:.1f}% requis (fast spiker, Δ5={_delta5_strict:.2f}% < 2.0%)")
                    self.detector.set_cooldown(symbol)
                    continue

            # 🔧 FIX 09/04: Vérifier le circuit breaker AVANT confirm_surge
            # Évite les appels API inutiles pour les coins bloqués type TRUUSDT
            # 🔧 CB-FIX: Passer surge_strength pour bypass si surge ≥ 2.5%
            _cb_blocked, _cb_reason = self.positions._is_coin_blocked(symbol, surge_strength=surge_strength)
            if _cb_blocked:
                self.detector.set_cooldown(symbol)  # 4 min pour éviter les rescans immédiats
                # Logger les surges significatifs bloqués (≥1%) pour diagnostic
                if surge_strength >= 1.0:
                    logger.info(f"      🔒 {symbol}: surge {surge_strength:.2f}% ignoré — CB actif ({_cb_reason})")
                continue

            # ═══ CONFIRMER ═══
            is_strong = surge_type == 'FLASH_SURGE' and surge_strength >= 1.2
            mkt_hint = ''
            if market_ctx.get('is_recovery_window'):
                mkt_hint = ' [REBOND]'
            elif market_ctx.get('is_freefall'):
                mkt_hint = ' [FREEFALL]'
            logger.info(f"      🔍 Confirmation klines 1m{mkt_hint}..."
                       f"{' (mode FLASH adaptatif)' if is_strong else ''}")
            confirmed, details = self.detector.confirm_surge(self.client, symbol, surge, market_ctx)
            
            if confirmed:
                self.surges_confirmed += 1
                buy_pct = round(details.get('buy_ratio', 0.5) * 100)
                buy_icon = '🟢' if details.get('strong_buy_pressure') else ('🟡' if buy_pct >= 50 else '🔴')
                logger.info(f"      ✅ CONFIRMÉ! Vol={details['vol_ratio']:.1f}x | "
                          f"BuyVol={buy_icon}{buy_pct}% (spike {details.get('buy_vol_spike', 1.0):.1f}x) | "
                          f"Green={details['green_count_3']}/3 | "
                          f"Mom={details['mom_3m']:+.2f}%")

                # 📡 Tracer les signaux macro utilisés pour la décision (analyse a posteriori)
                if TESTNET_MODE and TESTNET_SIGNAL_PRIORITY:
                    details['signal_explosive_score'] = sig_explosive
                    details['signal_setup'] = sig_setup
                    details['signal_trap_risk'] = sig_trap
                
                # 🤖 ML SIGNAL CLASSIFIER — filtre TESTNET uniquement
                # Fetch 120 klines 1m, compute features, prédire si le signal est rentable
                ml_blocked = False
                ml_result = None
                if self.ml_classifier:
                    try:
                        ml_start = time.time()
                        # Fetch 120 klines 1m pour le feature engineering
                        raw_klines = self.client.public_get(
                            f"{SCAN_API}/api/v3/klines",
                            {"symbol": symbol, "interval": "1m", "limit": 120}
                        )
                        if raw_klines and len(raw_klines) >= 30:
                            # Construire DataFrame au format attendu par feature_engineering
                            kl_data = []
                            for k in raw_klines:
                                kl_data.append({
                                    'open_time': int(k[0]),
                                    'open': float(k[1]),
                                    'high': float(k[2]),
                                    'low': float(k[3]),
                                    'close': float(k[4]),
                                    'volume': float(k[5]),
                                    'quote_volume': float(k[7]),
                                    'num_trades': int(k[8]),
                                    'taker_buy_quote_vol': float(k[10]),
                                })
                            klines_df = pd.DataFrame(kl_data)
                            timestamp_ms = int(raw_klines[-1][0])

                            # Compute features
                            features = compute_features_at_timestamp(klines_df, timestamp_ms, lookback_minutes=120)
                            if features:
                                features['surge_strength'] = surge_strength
                                features['surge_type'] = surge_type

                                # Predict
                                ml_result = self.ml_classifier.predict(features, klines_df, timestamp_ms)
                                ml_prob = ml_result['probability']
                                ml_signal = ml_result['signal']
                                ml_conf = ml_result['confidence']
                                ml_model = ml_result['model_type']
                                ml_elapsed = (time.time() - ml_start) * 1000

                                if ml_signal == 'SKIP':
                                    ml_blocked = True
                                    logger.info(f"      🤖 ML SKIP: prob={ml_prob:.3f} < seuil {ml_result['threshold']:.2f} "
                                              f"| conf={ml_conf:.0f}% | {ml_model} | {ml_elapsed:.0f}ms")
                                else:
                                    logger.info(f"      🤖 ML BUY: prob={ml_prob:.3f} ≥ seuil {ml_result['threshold']:.2f} "
                                              f"| conf={ml_conf:.0f}% | {ml_model} | {ml_elapsed:.0f}ms")
                            else:
                                logger.debug(f"      🤖 ML: features insuffisantes (klines<30min) → passthrough")
                        else:
                            logger.debug(f"      🤖 ML: klines insuffisantes ({len(raw_klines) if raw_klines else 0}) → passthrough")
                    except Exception as e:
                        logger.warning(f"      🤖 ML erreur: {e} → passthrough (trade autorisé)")

                if ml_blocked:
                    self.detector.set_cooldown(symbol)
                    _ml_reason = f"ml_skip(prob={ml_result['probability']:.3f}<{ml_result['threshold']:.2f})"
                    self._log_opportunity(surge, details, executed=False, reason=_ml_reason)
                    continue

                # 🆕 FIX 27/02: Injecter le symbole dans la watchlist du bot
                self._inject_to_watchlist(symbol, surge, details)
                
                if self.dry_run:
                    logger.info(f"      🏜️ DRY-RUN: Achat simulé {symbol}")
                    self.detector.set_cooldown(symbol)
                    self._log_opportunity(surge, details, executed=False, reason="dry-run")
                else:
                    success = self.positions.open_position(symbol, surge, details,
                                                            cached_regime=market_ctx.get('regime', 'NEUTRAL'))
                    if success:
                        self.trades_executed += 1
                        self.trades_this_hour += 1
                        self.detector.set_cooldown(symbol)
                        self._log_opportunity(surge, details, executed=True)
                    else:
                        # 🔧 FIX 09/04: Toujours mettre un cooldown pour éviter les tentatives immédiates
                        self.detector.set_cooldown(symbol)
                        self._log_opportunity(surge, details, executed=False, reason="order_failed")
            else:
                logger.info(f"      ❌ Rejeté: {', '.join(details.get('rejection_reasons', []))}")
                self.detector.set_cooldown(symbol)
                self._log_opportunity(surge, details, executed=False,
                                    reason=', '.join(details.get('rejection_reasons', [])))

        # 🎯 FRAMEWORK_BUY — vérifier les signaux BUY_NOW après les surges
        self._check_framework_entries(tickers_dict, market_ctx, bot_positions)

        self.current_phase = 'WAITING'
        self._write_status()

    def _check_framework_entries(self, tickers_dict, market_ctx, bot_positions):
        """
        🎯 FRAMEWORK_BUY (TESTNET uniquement) — Entrées directes sur signaux BUY_NOW
        du framework (framework_tracking.json). Indépendant des flash surges.
        Objectif : mesurer l'efficacité du framework vs le pump-catcher classique.
        Les trades sont taggés 'FRAMEWORK_BUY' dans les logs/historique.
        """
        if not TESTNET_MODE:
            return

        # 🔧 FIX 04/06: FRAMEWORK_BUY désactivé en régime BEAR
        # 🔧 OPT 06/08: également désactivé en CORRECTION (testnet)
        # Analyse: les signaux framework sous stress de marché génèrent trop
        # d'entrées faibles, souvent soldées en EARLY_SL/EMA7_DOWNTREND.
        _macro_regime_fw = market_ctx.get('regime', 'NEUTRAL') if market_ctx else 'NEUTRAL'
        if _macro_regime_fw in ('BEAR', 'CORRECTION'):
            logger.debug(f"   [FRAMEWORK] Désactivé en régime {_macro_regime_fw} (testnet hardening) → skip")
            return

        _fw_file = os.path.join(SCRIPT_DIR, 'framework_tracking.json')
        try:
            with open(_fw_file, 'r', encoding='utf-8') as _ff:
                _fw_data = json.load(_ff)
        except (FileNotFoundError, json.JSONDecodeError):
            return

        _snapshots = _fw_data if isinstance(_fw_data, list) else []
        if not _snapshots:
            return

        # Snapshot le plus récent — doit dater de moins de 4 minutes
        _latest = _snapshots[-1]
        try:
            _ts_str = _latest['ts'].split('+')[0].replace('Z', '')
            _snap_age = (datetime.utcnow() - datetime.fromisoformat(_ts_str)).total_seconds()
        except Exception:
            return
        if _snap_age > 240:
            return

        _fw_min_score = 88  # 🔧 OPT 06/08: 85→88 pour réduire les faux positifs framework
        _buynow_coins = [
            c for c in _latest.get('coins', [])
            if c.get('act') == 'BUY_NOW' and c.get('score', 0) >= _fw_min_score
        ]
        if not _buynow_coins:
            return

        logger.info(f"   🎯 [FRAMEWORK] {len(_buynow_coins)} signal(s) BUY_NOW (score≥{_fw_min_score}): "
                    f"{', '.join(c['sym'] for c in _buynow_coins)}")

        _now_ts = time.time()
        for _coin in _buynow_coins:
            symbol   = _coin['sym']
            fw_score = _coin.get('score', 0)
            fw_fib   = _coin.get('fib', 0)

            # Classifier l'entrée: full (score≥80) ou early (80-84 avec fib≥6)
            is_early_entry = (fw_score < 80)  # 🆕 OPT 28/05: 70→80
            if is_early_entry and fw_fib < 6:  # 🆕 OPT 28/05: fib 5→6
                logger.debug(f"      [FRAMEWORK_EARLY] {symbol}: score={fw_score:.0f}, fib={fw_fib}/7 < 6 → SKIP (fiabilité insuffisante)")
                continue

            # 🔧 FIX 19/05: Si le signal IA porte sur une paire USDT (ex: LAYERUSDT),
            # tenter la paire USD équivalente (ex: LAYERUSD) — la seule que le spy peut trader sur Kraken.
            if symbol.endswith('USDT'):
                _usd_equiv = symbol[:-4] + 'USD'
                if _usd_equiv in tickers_dict:
                    logger.debug(f"      [FRAMEWORK] {symbol} → USDT converti en {_usd_equiv} (paire USD disponible)")
                    symbol = _usd_equiv
                else:
                    logger.debug(f"      [FRAMEWORK] {symbol}: USDT-only, pas de paire USD → SKIP")
                    continue

            # 🆕 OPT 28/05: Exclure les majors (volatilité trop faible, max_pnl moyen 0.3% → R:R impossible)
            _FW_EXCLUDED_MAJORS = {'BTCUSD', 'ETHUSD', 'BNBUSD', 'XRPUSD',
                                   'BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'XRPUSDT'}
            if symbol in _FW_EXCLUDED_MAJORS:
                logger.debug(f"      [FRAMEWORK] {symbol}: major exclu (trop peu volatil pour FW) → SKIP")
                continue

            if symbol in self.positions.positions:
                continue
            if symbol in (bot_positions or {}):
                continue
            if symbol in SPY_SYMBOL_BLACKLIST:
                continue
            if symbol not in tickers_dict:
                logger.debug(f"      [FRAMEWORK] {symbol}: absent des tickers → SKIP")
                continue

            # Cooldown 5 minutes entre deux tentatives sur le même coin
            if _now_ts - self._framework_cooldowns.get(symbol, 0) < 300:
                continue

            # Limite horaire
            if self.trades_this_hour >= SPY_MAX_TRADES_PER_HOUR:
                logger.info(f"   ⚠️ [FRAMEWORK] limite horaire ({self.trades_this_hour}/{SPY_MAX_TRADES_PER_HOUR}) → STOP")
                self._framework_cooldowns[symbol] = _now_ts
                break

            current_price = tickers_dict[symbol]
            _entry_tag = 'FRAMEWORK_EARLY' if is_early_entry else 'FRAMEWORK_BUY'
            _size_note = ' (~65% pos, early)' if is_early_entry else ''
            logger.info(f"\n   🎯 [{_entry_tag}] {symbol}: score={fw_score:.0f}/100 "
                        f"@ {current_price:.6f} USD{_size_note} → vérification ML...")

            # Surge synthétique — bypass le surge detector
            _fw_surge = {
                'symbol':         symbol,
                'surge_type':     _entry_tag,  # 'FRAMEWORK_BUY' ou 'FRAMEWORK_EARLY'
                'surge_strength': 2.0,    # > 1.5 → passe le check BEAR
                'price':          current_price,
                'change_1scan':   0.0,
                'change_2scan':   0.5,    # Δ2 > 0 → évite ENTRY_PEAK_BUY
                'change_5scan':   fw_score / 100,
            }

            # Récupération des klines 1m — servent à la fois au filtre de tendance
            # EMA7/EMA25 (ci-dessous) et au ML Signal Classifier (plus bas).
            _kdf = None
            _raw_klines = None
            try:
                _raw_klines = self.client.public_get(
                    f"{SCAN_API}/api/v3/klines",
                    {"symbol": symbol, "interval": "1m", "limit": 120}
                )
                if _raw_klines and len(_raw_klines) >= 30:
                    _kl_data = [
                        {
                            'open_time': int(k[0]),   'open': float(k[1]),
                            'high': float(k[2]),       'low': float(k[3]),
                            'close': float(k[4]),      'volume': float(k[5]),
                            'quote_volume': float(k[7]), 'num_trades': int(k[8]),
                            'taker_buy_quote_vol': float(k[10]),
                        }
                        for k in _raw_klines
                    ]
                    _kdf = pd.DataFrame(_kl_data)
            except Exception as _kl_e:
                logger.debug(f"      [FRAMEWORK] Erreur fetch klines: {_kl_e} → skip")

            # 🆕 FIX 02/07: Confirmation de tendance EMA7/EMA25 AVANT achat.
            # FRAMEWORK_BUY bypassait jusqu'ici TOUTES les vérifications de tendance
            # (ENTRY_FILTER, EMA_bearish) utilisées par FLASH_SURGE via un _fw_confirm
            # synthétique toujours 'confirmed=True'.
            # Analyse exec_logs mai-juin (93 trades): WR=26.9%, avg=-0.163%/trade,
            # 38/93 sorties (41%) en EMA7_DOWNTREND ou EARLY_SL → le bot achetait sur
            # le score (jusqu'à 4min d'âge) du framework SANS vérifier si le prix
            # était déjà en train de retourner à la baisse au moment de l'achat.
            trend_blocked = False
            if _kdf is not None and len(_kdf) >= 8:
                _fw_closes = _kdf['close'].tolist()

                def _fw_ema(vals, period):
                    if len(vals) < period:
                        return vals[-1] if vals else 0.0
