                    if _is_framework:
                        # Cap absolu : 4h (240min) — éviter blocage capital indéfini
                        if hold_minutes >= 240:
                            sell_reason = f"FW_MAX_ABSOLUTE (4h, {pnl_pct:+.2f}%)"
                            logger.info(f"   ⏰ FW_MAX_ABSOLUTE {symbol}: 4h atteintes → sortie forcée ({pnl_pct:+.2f}%)")
                        # Stagnation prolongée : >120min, pnl encore faible (<1%) et EMA7 en pente nulle → capitalisation non matérialisée
                        elif hold_minutes >= 120 and pnl_pct < 1.0 and pos.get('_ema7_bd_count', 0) >= 2:
                            sell_reason = f"FW_STAGNATION_EXT (hold={hold_minutes:.0f}min, pnl={pnl_pct:+.2f}%, bd={pos.get('_ema7_bd_count',0)})"
                            logger.info(f"   🔄 FW_STAGNATION_EXT {symbol}: >2h, pnl<+1%, EMA7 faiblissant → sortie ({pnl_pct:+.2f}%)")
                        else:
                            # Laisser courir — log toutes les 15min pour visibilité
                            _fw_ext_prev = pos.get('_fw_ext_last_log_min', 0)
                            if hold_minutes - _fw_ext_prev >= 15:
                                pos['_fw_ext_last_log_min'] = hold_minutes
                                logger.info(f"   🚀 [FW_EXTEND] {symbol}: {hold_minutes:.0f}min | pnl={pnl_pct:+.2f}% | EMA7 haussier → tendance intacte, maintien")
                    # (standard) : EMA7 haussier et trade positif → maintenir, EMA7_DOWNTREND gère la sortie
                else:
                    sell_reason = f"MAX_HOLD_{_max_hold_eff:.0f}min ({'FW' if _is_framework else 'STD'}, {pnl_pct:+.2f}%)"
            
            # ═══ STAGNATION / MOMENTUM_EXIT / REVERSAL / VOLUME_ROUGE : SUPPRIMÉS (02/05/2026) ═══
            # Remplacés par : HARD_SL + EMA7_DOWNTREND + Trailing + MAX_HOLD 12min
            # Raisonnable: 1333 trades → ces règles avaient trop de cas-limites et neutralisations
            pass  # règles supprimées

            if sell_reason:
                to_sell.append((symbol, sell_reason, price, pnl_pct, pos.get('max_pnl', 0.0), hold_minutes))
            else:
                ts_info = f" TS={pos['trailing_stop']:.6f}" if pos.get('trailing_stop') else ""
                _slp = pos.get('_ema7_slope')
                _ema_tag = (
                    f" EMA7↑{_slp:+.2f}%" if _ema7_rising
                    else (f" EMA7↓{_slp:+.2f}%" if _slp is not None and _slp < 0 else "")
                )
                emoji = "🟢" if pnl_pct > 0 else "🔴"
                if self.positions and len(self.positions) <= 3:
                    logger.info(f"   {emoji} {symbol}: {pnl_pct:+.2f}% | "
                              f"Max: {pos['max_pnl']:+.2f}% | "
                              f"Hold: {hold_minutes:.1f}min{_ema_tag}{ts_info}")
        
        for symbol, reason, price, pnl, max_pnl, hold_min in to_sell:
            self._sell(symbol, reason, price, pnl)
            # 🆕 FIX 14/02: Cooldown adaptatif — plus court après exit gagnant
            # pour permettre la re-entry rapide sur le même symbol
            # 🔧 OPT 17/03: Passer pnl pour blocage progressif par coin
            # 🔧 CB-FIX: Passer max_pnl pour distinguer timing-loss vs reversal
            if hasattr(self, '_parent_detector') and self._parent_detector:
                self._parent_detector.last_exit_win[symbol] = (pnl > 0)
                self._parent_detector.set_cooldown(symbol, pnl=pnl, max_pnl=max_pnl, sell_reason=reason, hold_minutes=hold_min)
        
        self._save_positions()
    
    def _sell(self, symbol, reason, current_price, pnl_pct):
        """Exécute la vente"""
        pos = self.positions.get(symbol)
        if not pos:
            return
        
        quantity = pos['quantity']
        
        # 🔧 FIX: Binance prélève 0.1% de frais sur le token reçu à l'achat.
        # La quantité réelle en wallet est donc légèrement < pos['quantity'].
        # On interroge le vrai solde pour éviter l'erreur -2010 (insufficient balance).
        # Sur testnet, certains assets (FORM, AIXBT...) ne sont pas crédités réellement
        # → real_balance == 0 → vente virtuelle au prix courant.
        asset = symbol
        for quote in ('USDC', 'USDT', 'BUSD', 'USD', 'BTC', 'ETH', 'BNB'):
            if symbol.endswith(quote):
                asset = symbol[:-len(quote)]
                break

        real_balance = 0.0
        balance_ok = False
        try:
            real_balance = self.client.get_balance(asset)
            balance_ok = True
        except Exception as e:
            logger.debug(f"   ℹ️ get_balance {asset} indisponible: {e}")

        hold_time = time.time() - pos.get('entry_time', time.time())

        # 🔧 FIX: step doit être défini ICI avant les chemins de vente (balance=0 inclus)
        # BUG 14/03: step était défini APRÈS la tentative réelle → NameError silencieux
        # → fallback systématique en vente virtuelle → orphelin récurrent sur Binance
        step = self.step_size_cache.get(symbol)

        # 🔧 FIX -2010 PHANTOM: balance == 0 → testnet ne détient pas réellement l'asset
        # 🔧 FIX 07/03: Tenter QUAND MÊME un ordre réel avant de faire une vente virtuelle.
        # L'ancien code faisait une vente virtuelle immédiate → supprimait espion_trades.json
        # mais le crypto RESTAIT sur Binance → orphelin récurrent au prochain reset.
        if balance_ok and real_balance == 0:
            # Tenter l'ordre réel d'abord (quantité stockée)
            logger.warning(f"   ⚠️ {symbol}: balance=0 — tentative ordre réel avant vente virtuelle")
            try:
                _order_test = self.client.market_sell(symbol, quantity, step)
                if _order_test:
                    sell_price = float(_order_test.get('fills', [{}])[0].get('price', current_price))
                    actual_pnl = ((sell_price - pos['entry_price']) / pos['entry_price']) * 100
                    emoji = "✅" if actual_pnl > 0 else "❌"
                    logger.info(f"   {emoji} VENDU (balance-0-fix) {symbol} @ {sell_price} | PnL: {actual_pnl:+.2f}%")
                    self._archive(symbol, pos, sell_price, actual_pnl, reason, hold_time)
                    del self.positions[symbol]
                    self._save_positions()
                    self._remove_from_main(symbol)
                    return
            except Exception as _e:
                logger.debug(f"   ℹ️ Ordre réel échoué ({_e}), fallback vente virtuelle")
            # Fallback: vente virtuelle (testnet asset non crédité réellement)
            # 🔧 FIX 08/05: Réalisme — on vend au BID prod réel (pas current_price=last théorique)
            # Sans ça, en testnet on sortait au prix idéal alors qu'en prod la vente paie le bid
            # (typiquement -0.1% à -0.5% sous le last). Cela faisait diverger testnet vs prod.
            virtual_sell_price = current_price
            try:
                _book = self.client.public_get(
                    f"{SCAN_API}/api/v3/ticker/bookTicker", {"symbol": symbol}
                )
                if _book:
                    _bid = float(_book.get('bidPrice', 0) or 0)
                    if _bid > 0:
                        virtual_sell_price = _bid
                        _slip_bid = (current_price - _bid) / current_price * 100 if current_price > 0 else 0
                        logger.info(f"   📉 Vente virtuelle au bid prod réel "
                                    f"(last={current_price:.6f} → bid={_bid:.6f}, slippage sortie {_slip_bid:.2f}%)")
            except Exception:
                pass  # garde current_price en fallback
            actual_pnl = ((virtual_sell_price - pos['entry_price']) / pos['entry_price']) * 100
            emoji = "✅" if actual_pnl > 0 else "❌"
            logger.warning(f"   ⚠️ VENTE VIRTUELLE {symbol} (balance=0 confirmé): {reason}")
            logger.info(f"   {emoji} CLÔTURE VIRTUELLE {symbol} @ {virtual_sell_price} | "
                       f"PnL: {actual_pnl:+.2f}% | Hold: {hold_time/60:.1f}min")
            self._archive(symbol, pos, virtual_sell_price, actual_pnl, reason + " [VIRTUAL]", hold_time)
            del self.positions[symbol]
            self._save_positions()
            self._remove_from_main(symbol)
            return

        if balance_ok and real_balance > 0 and real_balance < quantity:
            logger.info(f"   ℹ️ Balance réelle {asset}: {real_balance} (stocké: {quantity}) → ajustement frais")
            quantity = real_balance

        # 🔧 FIX NOTIONAL: si la valeur USDT de la balance réelle est < 5 USDT (min notional),
        # la vente échouera avec -1013. On clôture virtuellement pour éviter une boucle infinie.
        MIN_NOTIONAL_USDT = 5.0
        if balance_ok and real_balance > 0 and (real_balance * current_price) < MIN_NOTIONAL_USDT:
            actual_pnl = ((current_price - pos['entry_price']) / pos['entry_price']) * 100
            emoji = "✅" if actual_pnl > 0 else "❌"
            logger.warning(f"   ⚠️ CLÔTURE DUST {symbol}: balance réelle {real_balance} {asset} "
                          f"= {real_balance*current_price:.3f} USDT < {MIN_NOTIONAL_USDT} USDT (min notional)")
            logger.info(f"   {emoji} CLÔTURE DUST {symbol} @ {current_price} | "
                       f"PnL: {actual_pnl:+.2f}% | Hold: {hold_time/60:.1f}min")
            self._archive(symbol, pos, current_price, actual_pnl, reason + " [DUST]", hold_time)
            del self.positions[symbol]
            self._save_positions()
            self._remove_from_main(symbol)
            return

        logger.info(f"   🔔 VENTE {symbol}: {reason}")

        try:
            order = self.client.market_sell(symbol, quantity, step)
            if order:
                sell_price = float(order.get('fills', [{}])[0].get('price', current_price))
                actual_pnl = ((sell_price - pos['entry_price']) / pos['entry_price']) * 100

                # 🔬 Log exécution SELL — mesure friction réelle = PnL théorique vs PnL fill
                if self.exec_logger:
                    try:
                        self.exec_logger.log_sell(
                            symbol=symbol,
                            order_response=order,
                            theoretical_price=current_price,
                            exit_reason=reason,
                            buy_log_id=pos.get('exec_log_id'),
                        )
                    except Exception as _elog_err:
                        logger.debug(f"exec_logger SELL KO: {_elog_err}")

                emoji = "✅" if actual_pnl > 0 else "❌"
                logger.info(f"   {emoji} VENDU {symbol} @ {sell_price} | "
                          f"PnL: {actual_pnl:+.2f}% | Hold: {hold_time/60:.1f}min")

                self._archive(symbol, pos, sell_price, actual_pnl, reason, hold_time)
                del self.positions[symbol]
                self._save_positions()
                self._remove_from_main(symbol)
            else:
                logger.error(f"   ❌ Vente échouée {symbol}")
        except Exception as e:
            # 🔧 FIX NOTIONAL: -1013 = valeur trop petite → clôture dust (évite boucle infinie)
            if '-1013' in str(e) or 'NOTIONAL' in str(e).upper():
                actual_pnl = ((current_price - pos['entry_price']) / pos['entry_price']) * 100
                emoji = "✅" if actual_pnl > 0 else "❌"
                logger.warning(f"   ⚠️ NOTIONAL trop faible {symbol} — clôture dust forcée")
                logger.info(f"   {emoji} CLÔTURE DUST {symbol} @ {current_price} | PnL: {actual_pnl:+.2f}%")
                self._archive(symbol, pos, current_price, actual_pnl, reason + " [DUST-NOTIONAL]", hold_time)
                del self.positions[symbol]
                self._save_positions()
                self._remove_from_main(symbol)
            else:
                logger.error(f"   ❌ Erreur vente {symbol}: {e}")
                traceback.print_exc()
    
    def _archive(self, symbol, pos, sell_price, pnl_pct, reason, hold_time):
        """Archive le trade dans l'historique"""
        try:
            history = []
            if os.path.exists(SPY_HISTORY_FILE):
                with open(SPY_HISTORY_FILE, 'r', encoding='utf-8') as f:
                    history = json.load(f)

            # 🔧 FIX 08/05: Réalisme testnet — déduire les fees Binance (0.1% maker + 0.1% taker
            # = 0.2% round-trip) que le testnet ne facture pas. Sans ça, l'espérance testnet
            # surestime systématiquement la prod de ~20 USDC pour 100 USDC de notional × 100 trades.
            # En prod, fees déjà incluses dans fills → on n'applique le correctif que sur testnet.
            FEES_RT_PCT = 0.2  # 0.1% buy + 0.1% sell
            pnl_pct_raw = pnl_pct
            pnl_usdt_raw = round(pos['quantity'] * (sell_price - pos['entry_price']), 4)
            if TESTNET_MODE:
                pnl_pct = round(pnl_pct - FEES_RT_PCT, 3)
                # fees absolues = 0.2% × notional d'entrée
                _notional_in = pos['entry_price'] * pos['quantity']
                _fees_usdt = round(_notional_in * FEES_RT_PCT / 100.0, 4)
                pnl_usdt_adj = round(pnl_usdt_raw - _fees_usdt, 4)
            else:
                pnl_usdt_adj = pnl_usdt_raw
                _fees_usdt = 0.0

            # Indicateurs d'entrée — pour optimisation IA
            _ind = pos.get('indicators') or {}
            history.append({
                'symbol': symbol,
                'entry_price': pos['entry_price'],
                'sell_price': sell_price,
                'quantity': pos['quantity'],
                'pnl_pct': round(pnl_pct, 3),
                'pnl_usdt': pnl_usdt_adj,
                'pnl_pct_raw': round(pnl_pct_raw, 3),
                'pnl_usdt_raw': pnl_usdt_raw,
                'fees_usdt': _fees_usdt,
                'realism_applied': bool(TESTNET_MODE),
                'max_pnl': round(pos.get('max_pnl', 0), 3),
                'hold_seconds': round(hold_time, 0),
                'hold_minutes': round(hold_time / 60, 1),
                'surge_type': pos.get('surge_type', ''),
                'surge_strength': pos.get('surge_strength', 0),
                'exit_reason': reason,
                'entry_time': pos.get('timestamp', ''),
                'exit_time': datetime.now(timezone.utc).isoformat(),
                # ── Indicateurs techniques à l'entrée ──────────────────────
                'entry_rsi':           pos.get('rsi_at_entry', _ind.get('rsi')),
                'entry_ema7_slope':    pos.get('ema7_slope_at_entry', _ind.get('ema7_slope_pct')),
                'entry_ema7_bullish':  pos.get('ema7_bullish_at_entry', not _ind.get('ema_bearish', True)),
                'entry_vol_ratio':     _ind.get('vol_ratio'),
                'entry_buy_ratio':     _ind.get('buy_ratio'),
                'entry_buy_vol_spike': _ind.get('buy_vol_spike'),
                'entry_mom_3m':        _ind.get('mom_3m'),
                'entry_mom_15m':       _ind.get('mom_15m'),
                'entry_ef_score':      _ind.get('ef_score'),
                'entry_ef_fails':      _ind.get('ef_fails'),
                'entry_delta_5m':      pos.get('delta_5m'),
                # ── Indicateurs EMA7 live à la sortie ──────────────────────
                'exit_ema7_slope':     pos.get('_ema7_slope'),
                'exit_ema7_bullish':   not pos.get('_ema_bearish_live', True) if pos.get('_ema_bearish_live') is not None else None,
                'exit_ema7_bd_count':  pos.get('_ema7_bd_count'),
            })
            
            # PnL est calculé sur tout l'historique — ne pas tronquer
            # La limite d'affichage (200) est appliquée côté API/dashboard

            # Mise à jour circuit breaker
            pnl_usdt_val = pnl_usdt_adj
            self._update_coin_score(symbol, pnl_pct, pnl_usdt_val)

            # 📊 Mise à jour compteurs session
            if hasattr(self, '_parent_spy') and self._parent_spy:
                self._parent_spy.session_pnl += pnl_usdt_val
                if pnl_usdt_val > 0:
                    self._parent_spy.session_wins += 1
                else:
                    self._parent_spy.session_losses += 1

            # 🧠 Alimenter le détecteur comportemental
            if hasattr(self, '_parent_spy') and self._parent_spy and self._parent_spy.behavior:
                self._parent_spy.behavior.feed_trade_result({
                    'max_pnl': round(pos.get('max_pnl', 0), 3),
                    'pnl_pct': round(pnl_pct, 3),
                    'hold_seconds': round(hold_time, 0),
                    'exit_reason': reason,
                    'symbol': symbol,
                    'timestamp': time.time(),
                })

            # 🔴 FIX: Écriture atomique pour éviter lectures partielles par le dashboard
            tmp_file = SPY_HISTORY_FILE + '.tmp'
            with open(tmp_file, 'w', encoding='utf-8') as f:
                json.dump(history, f, indent=2, default=str)
            os.replace(tmp_file, SPY_HISTORY_FILE)

            # 📊 Mise à jour compteur cumulatif (filet de sécurité PnL)
            try:
                cumul_file = os.path.join(os.path.dirname(SPY_HISTORY_FILE) or '.', 'spy_cumulative_stats.json')
                cumul = {}
                if os.path.exists(cumul_file):
                    with open(cumul_file, 'r') as cf:
                        cumul = json.load(cf)
                pnl_usdt = round(pos['quantity'] * (sell_price - pos['entry_price']), 4)
                if TESTNET_MODE:
                    pnl_usdt = pnl_usdt_adj  # même ajustement fees pour le cumul
                cumul['total_trades'] = cumul.get('total_trades', 0) + 1
                if pnl_usdt > 0:
                    cumul['total_wins'] = cumul.get('total_wins', 0) + 1
                else:
                    cumul['total_losses'] = cumul.get('total_losses', 0) + 1
                cumul['total_pnl_usdt'] = round(cumul.get('total_pnl_usdt', 0) + pnl_usdt, 4)
                cumul['last_updated'] = datetime.now(timezone.utc).isoformat()
                tmp_cumul = cumul_file + '.tmp'
                with open(tmp_cumul, 'w') as cf:
                    json.dump(cumul, cf, indent=2)
                os.replace(tmp_cumul, cumul_file)
            except Exception:
                pass

        except Exception:
            # Nettoyer le fichier temporaire si nécessaire
            try:
                tmp_file = SPY_HISTORY_FILE + '.tmp'
                if os.path.exists(tmp_file):
                    os.remove(tmp_file)
            except:
                pass
    
    @property
    def count(self):
        return len(self.positions)


# ═══════════════════════════════════════════════════════════════════════════════
# MARKET SPY v3 - PUMP CATCHER
# ═══════════════════════════════════════════════════════════════════════════════

class MarketSpy:
    """
    Scanner ultrarapide: scan toutes les 12s, détecte les surges,
    achète immédiatement, vend dès essoufflement.
    """
    
    def __init__(self, dry_run=False):
        self.dry_run = dry_run
        self.client = SpyApiClient()
        self.detector = SurgeDetector()
        self.positions = SpyPositionManager(self.client)
        self.positions._parent_detector = self.detector
        self.positions._parent_spy = None

        # 🆕 FIX 05/05: WebSocket miniTicker — prix temps réel (fallback REST si WS stale)
        self.ws_ticker = MiniTickerWSClient()
        self.ws_ticker.start()
        self._ws_used_count = 0    # stats: scans servis par WS
        self._rest_used_count = 0  # stats: scans servis par REST (fallback)

        self.watchlist = self._load_watchlist()
        self._market_ctx = {'regime': 'NEUTRAL', 'is_freefall': False,
                            'is_recovery_window': False, 'btc_mom_3h': 0.0,
                            'btc_mom_5h': 0.0, 'bullish_pct': 50.0}  # 🆕 Contexte macro

        self.scan_count = 0
        self.surges_detected = 0
        self.surges_confirmed = 0
        self.trades_executed = 0
        self.session_pnl = 0.0
        self.session_wins = 0
        self.session_losses = 0
        self.trades_this_hour = 0
        self.hour_start = time.time()
        self.start_time = time.time()
        self.last_scan_time = None
        self.last_scan_duration = 0
        self.last_eligible_count = 0
        self.last_surges = []  # Liste cumulative des surges des 4 dernières heures
        self.current_phase = 'INITIALIZING'
        self.errors_count = 0
        self.scan_times = []  # Historique des durées de scan
        self.last_watchlist_auto_update = 0  # 🆕 Timestamp dernier rafraîchissement auto watchlist
        self._signal_snapshot = None          # Snapshot signaux macro (cache externe)
        self._signal_top_candidates = []      # Top candidats explosifs du dernier cycle
        self._no_surge_scans = 0              # Scans consécutifs sans surge (pilotage sensibilité testnet)
        
        # 🧠 Détecteur comportemental — qualifie le comportement humain des participants
        self.behavior = get_behavior_detector() if _BEHAVIOR_AVAILABLE else None
        # Surges en attente de vérification follow-through (symbol → {price, timestamp})
        self._pending_ft_checks = {}
        # 🔧 FIX 29/04: BREAKOUT_SURGE différé d'un scan (7s) pour filtrer les wicks de fin de tendance
        # Logique: FLASH = violent → acheter immédiatement. BREAKOUT (2 scans, ~14s) → 7s de plus ne coûtent rien.
        # Si le prix tient après 7s → vrai breakout. S'il retrace → wick évité (ex: ORCA 15:29).
        self._pending_breakouts = {}  # symbol → {price, ts, surge_data}
        # 🎯 FRAMEWORK_BUY — cooldowns pour entrées directes sur signal BUY_NOW (5min)
        self._framework_cooldowns = {}  # symbol → timestamp dernier essai

        # 🤖 ML Signal Classifier (TESTNET uniquement)
        self.ml_classifier = None
        if TESTNET_MODE and _ML_CLASSIFIER_AVAILABLE:
            try:
                self.ml_classifier = SignalClassifier.load()
                _auc = self.ml_classifier.training_stats.get('test_auc') or self.ml_classifier.training_stats.get('metrics', {}).get('auc_roc', 0)
                logger.info(f"   🤖 ML Classifier chargé (seuil={self.ml_classifier.optimal_threshold:.2f}, "
                           f"AUC={_auc:.4f})")
            except Exception as e:
                logger.warning(f"   ⚠️ ML Classifier indisponible: {e}")
        elif TESTNET_MODE and not _ML_CLASSIFIER_AVAILABLE:
            logger.warning(f"   ⚠️ ML Classifier: imports manquants ({_ml_err if '_ml_err' in dir() else 'unknown'})")

        logger.info("═" * 60)
        logger.info("🕵️ MARKET SPY v3 - Pump Catcher")
        logger.info("═" * 60)
        logger.info(f"   Mode: {'DRY-RUN' if dry_run else 'LIVE'}")
        logger.info(f"   Scan: every {SCAN_INTERVAL}s")
        logger.info(f"   Surge: +{SURGE_MIN_PRICE_CHANGE}%/{SCAN_INTERVAL}s | "
                   f"Vol: {SURGE_MIN_VOLUME_RATIO}x")
        logger.info(f"   Position: {_load_position_size():.0f} USD base (scaling auto ×{0.05625*100:.1f}% du solde) | Max: {SPY_MAX_POSITIONS}")
        logger.info(f"   Exit: Trailing -{TRAILING_STOP_PCT}%/{TRAILING_STOP_WIDE}%/{TRAILING_STOP_ULTRA}% (dyn) | SL -{HARD_STOP_LOSS_PCT}% | "
                   f"NO TP CAP (trailing only) | Max {MAX_HOLD_MINUTES}min")
        if self.behavior:
            logger.info(f"   🧠 {self.behavior.get_status_line()}")
        if self.ml_classifier:
            logger.info(f"   🤖 ML Filter: ACTIF (TESTNET) — seuil {self.ml_classifier.optimal_threshold:.2f}")
        logger.info("═" * 60)
        # ═══ PANNEAU DE CONTRÔLE — FILTRES ACTIFS ══════════════════════════
        logger.info("📋 FILTRES ACTIFS — contrôle configuration")
        logger.info("─" * 60)
        # Entrée
        logger.info(f"   [ENTRÉE]")
        logger.info(f"     • Surge min        : +{SURGE_MIN_PRICE_CHANGE}% / {SCAN_INTERVAL}s (scan)  | Volume ≥ {SURGE_MIN_VOLUME_RATIO}x")
        _ef_thresh = "≤2/5" if TESTNET_MODE else "≤3/5"
        logger.info(f"     • ENTRY_FILTER IA  : {_ef_thresh} règles → rejet | surge≥0.634% | ema7≥0.526% | wick<0.373 | RSI≥74.2 | vol≥2.02x{'  [TESTNET]' if TESTNET_MODE else ''}")
        logger.info(f"     • Spread max       : 0.8% (liquidité post-spike)")
        logger.info(f"     • Slippage max     : {MAX_BUY_SLIPPAGE_PCT}% (prix détection → ask réel)")
        logger.info(f"     • Bid retrait      : 0.4% (market makers se désengagent → skip)")
        logger.info(f"     • Vol new listing  : >500× → suspect → skip")
        logger.info(f"     • ENTRY_PEAK_BUY   : surge concentré sur dernier scan seul → skip")
        logger.info(f"     • Anti fast-spiker : SPECULATION+FLASH+spiker → skip")
        _strict = ', '.join(SPY_SYMBOL_BLACKLIST) or '(aucun)'
        _bl = ', '.join(FLASH_SURGE_BLACKLIST) or '(aucun)'
        _strict_coins = ', '.join(FLASH_SURGE_STRICT.keys()) or '(aucun)'
        logger.info(f"     • Blacklist globale: {_strict}")
        logger.info(f"     • Flash blacklist  : {_bl}")
        logger.info(f"     • Flash strict     : {_strict_coins}")
        logger.info(f"     • WebSocket        : {'ACTIF (prix 1s)' if _WS_AVAILABLE else 'INACTIF → REST fallback'}")
        # Sortie
        logger.info(f"   [SORTIE]")
        logger.info(f"     • Trailing stops   : -{TRAILING_STOP_PCT}% / -{TRAILING_STOP_WIDE}% / -{TRAILING_STOP_LARGE}% / -{TRAILING_STOP_ULTRA}% (paliers)")
        logger.info(f"     • Hard SL          : -{HARD_STOP_LOSS_PCT}%")
        logger.info(f"     • Max hold         : {MAX_HOLD_MINUTES}min")
        logger.info(f"     • Profit lock      : armé à +{PROFIT_LOCK_ARM_PNL}% | retrace max {int(PROFIT_LOCK_RETRACE*100)}% | floor min {PROFIT_LOCK_MIN_FLOOR:+}%")
        logger.info(f"     • EMA soft retrace : {int(PROFIT_LOCK_RETRACE_EMA_SOFT*100)}% (petits gains 0.4-1%)")
        # Cooldown
        logger.info(f"   [COOLDOWN]")
        logger.info(f"     • Fast-loss (<2min, <-0.5%) : 6h de blocage")
        logger.info(f"     • 3+ pertes consécutives    : 3h de blocage")
        logger.info(f"     • Standard                  : ~30-45min")
        _startup_ratio = float(_load_settings().get('capitalRatioPct', 100.0))
        logger.info(f"   [POSITIONS]")
        logger.info(f"     • Taille base      : {_load_position_size():.0f} USD | Ratio capital : {_startup_ratio:.1f}% | Max simultanées : {SPY_MAX_POSITIONS}")
        logger.info(f"     • Max trades/heure : {SPY_MAX_TRADES_PER_HOUR}")
        logger.info("═" * 60)
        # Lien retour pour que le position manager puisse alimenter le behavior
        self.positions._parent_spy = self
    
    def _load_watchlist(self):
        """Charge la watchlist complète pour le spy: manuels + auto-ajoutés + spy_injected.
        
        🔧 FIX 29/03: Le spy scanne symbols (dashboard/bot) + auto_added (spy only).
        Les auto_added ne sont PAS dans symbols[] pour ne pas polluer le dashboard.
        🔧 FIX 10/04: Inclure spy_injected — survit aux redémarrages (était perdu en mémoire seulement).
        """
        try:
            if os.path.exists(WATCHLIST_FILE):
                with open(WATCHLIST_FILE, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                manual = set(data.get('symbols', []))
                auto = set(data.get('auto_added', {}).keys())
                injected = set(data.get('spy_injected', {}).keys())
                return manual | auto | injected
        except Exception:
            pass
        return set()
    
    def _inject_to_watchlist(self, symbol, surge, details):
        """🆕 FIX 27/02: Injecte un symbole confirmé dans la watchlist du bot.
        
        🔧 FIX 29/03: Injection via spy_injected{} seulement (pas dans symbols[]).
        Le bot recharge la watchlist toutes les ~30s et lit spy_injected.
        Le symbole est marqué avec un TTL de 24h.
        """
        try:
            # Ne pas injecter les symboles invalides sur cet exchange/mode
            if symbol in SpyApiClient._invalid_symbols:
                return  # Silencieux — évite les order_failed répétés
            # 🔧 FIX 09/04: Ne pas injecter les coins bloqués par circuit breaker
            _cb_blocked, _cb_reason = self.positions._is_coin_blocked(symbol)
            if _cb_blocked:
                return  # Silencieux pour éviter la pollution de logs
            watchlist_data = {'symbols': [], 'spy_injected': {}}
            if os.path.exists(WATCHLIST_FILE):
                with open(WATCHLIST_FILE, 'r', encoding='utf-8') as f:
                    watchlist_data = json.load(f)
            
            symbols = set(watchlist_data.get('symbols', []))
            spy_injected = watchlist_data.get('spy_injected', {})
            
            # Si déjà dans les symboles manuels du dashboard, juste noter
            if symbol in symbols:
                if symbol in spy_injected:
                    spy_injected[symbol]['last_surge'] = now_paris().isoformat()
                    spy_injected[symbol]['surge_count'] = spy_injected[symbol].get('surge_count', 0) + 1
                    watchlist_data['spy_injected'] = spy_injected
                    with open(WATCHLIST_FILE, 'w', encoding='utf-8') as f:
                        json.dump(watchlist_data, f, indent=2)
                    logger.info(f"      📡 {symbol}: TTL spy renouvelé (surge #{spy_injected[symbol]['surge_count']})")
                return
            
            # Ajouter dans spy_injected (PAS dans symbols[] = flux dashboard)
            spy_injected[symbol] = {
                'added_at': now_paris().isoformat(),
                'last_surge': now_paris().isoformat(),
                'surge_type': surge.get('surge_type', ''),
                'surge_strength': round(surge.get('surge_strength', 0), 2),
                'vol_ratio': round(details.get('vol_ratio', 0), 1),
                'surge_count': 1,
                'ttl_hours': 24
            }
            
            watchlist_data['spy_injected'] = spy_injected
            watchlist_data['updated_at'] = now_paris().isoformat()
            
            with open(WATCHLIST_FILE, 'w', encoding='utf-8') as f:
                json.dump(watchlist_data, f, indent=2)
            
            self.watchlist.add(symbol)
            
            logger.info(f"      📡 {symbol} INJECTÉ (spy_injected)! "
                       f"(surge={surge.get('surge_type')}, strength={surge.get('surge_strength', 0):.1f}%, "
                       f"vol={details.get('vol_ratio', 0):.1f}x)")
            logger.info(f"      → Le bot IA l'analysera au prochain cycle (~30s)")
            
        except Exception as e:
            logger.error(f"      ⚠️ Erreur injection watchlist: {e}")
    
    def _cleanup_expired_spy_symbols(self):
        """Retire de la watchlist les symboles injectés par le spy dont le TTL est expiré.
        
        Un symbole expiré = aucun nouveau surge depuis 'ttl_hours' heures.
        Appelé toutes les ~50 scans (~25min) pour garder la watchlist propre.
        """
        try:
            if not os.path.exists(WATCHLIST_FILE):
                return
            with open(WATCHLIST_FILE, 'r', encoding='utf-8') as f:
                watchlist_data = json.load(f)

            spy_injected = watchlist_data.get('spy_injected', {})
            if not spy_injected:
                return

            now = now_paris()
            expired = []
            for symbol, info in spy_injected.items():
                ttl_h = info.get('ttl_hours', 24)
                last_surge_str = info.get('last_surge') or info.get('added_at', '')
                try:
                    last_surge_dt = datetime.fromisoformat(last_surge_str)
                except (ValueError, TypeError):
                    last_surge_dt = now  # sécurité: ne pas supprimer si date invalide
                hours_elapsed = (now - last_surge_dt).total_seconds() / 3600
                if hours_elapsed >= ttl_h:
                    expired.append(symbol)

            if not expired:
                return

            # Vérifier qu'aucun expired n'est en position active dans le bot
            active_positions = set()
            try:
                from pathlib import Path
                pos_file = Path(WATCHLIST_FILE).parent / 'positions.json'
                if pos_file.exists():
                    with open(pos_file) as pf:
                        pos_data = json.load(pf)
                    active_positions = set(pos_data.keys()) if isinstance(pos_data, dict) else set()
            except Exception:
                pass

            removed = []
            for symbol in expired:
                if symbol in active_positions:
                    logger.info(f"   ⏳ SPY TTL: {symbol} expiré mais EN POSITION — conservé")
                    continue
                # 🔧 FIX 29/03: ne retirer que de spy_injected (pas de symbols[] = flux dashboard)
                del spy_injected[symbol]
                self.watchlist.discard(symbol)
                removed.append(symbol)

            if removed:
                watchlist_data['spy_injected'] = spy_injected
                watchlist_data['updated_at'] = now.isoformat()
                with open(WATCHLIST_FILE, 'w', encoding='utf-8') as f:
                    json.dump(watchlist_data, f, indent=2)
                logger.info(f"   🧹 SPY TTL: {len(removed)} symbole(s) expiré(s) retiré(s) de la watchlist: {', '.join(removed)}")

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

    def _auto_update_watchlist(self):
        """🆕 Met à jour les symboles auto-découverts par le spy (toutes les heures).

        🔧 FIX 29/03: SÉPARATION STRICTE dashboard/spy:
        - auto_added{} = symboles spy-only (vol >= 2M$), stockés UNIQUEMENT dans auto_added
        - symbols[] = liste manuelle du dashboard/bot, JAMAIS modifiée par cette fonction
        - Le spy scanne symbols + auto_added en runtime (via _load_watchlist)
        - Le dashboard/bot ne voit que symbols[] → pas d'inflation

        🚀 OPT 07/04: Reclassement dynamique des slots + track momentum breakout
        - 10 slots "volume"    : top volume 24h (liquidité)
        - 5  slots "momentum"  : top gainers 24h >= 2M$ vol (pompes émergentes)
        - Rebalance complet chaque heure, sauf positions actives protégées
        """
        SLOTS_VOLUME   = 20   # 🔧 OPT 25/04: 12→20 — univers USDC limité, scanner plus de paires pour compenser
        SLOTS_MOMENTUM = 8    # 🔧 OPT 25/04: 5→8 slots momentum — plus de gainers émergents USDC
        MAX_AUTO_ADDED = SLOTS_VOLUME + SLOTS_MOMENTUM  # 28 total
        MIN_AUTO_VOLUME = 500_000  # 🔧 OPT 25/04: 1.5M→500K — USDC a 5x moins de paires qu'USDT, inclure toutes >= 500K
        MOMENTUM_MAX_GAIN_24H = 30.0   # 🔧 FIX 16/04b: 20%→30% — breakouts entre +20-30% encore actifs si vol solide
        # Acheter un coin qui a déjà +30-50% = queue du pump, retournement immédiat

        # Stablecoins, gold et tokens synthétiques à ne jamais ajouter
        EXCLUDE_EXACT = {
            'RLUSDUSD', 'USD1USD', 'XUSDUSD', 'USDPUSD', 'TUSDUSD',
            'BUSDUSD', 'USDTUSD', 'FDUSDUSD', 'DAIUSD', 'PAXGUSD',
            'EURUSD', 'GBPUSD', 'BFUSDUSD', 'WBTCUSD',
            'USDEUSD', 'CUSD', 'WLFIUSD',
        }
        EXCLUDE_KEYWORDS = ['USDT', 'TUSD', 'BUSD', 'FDUSD', 'PAXG', 'XAUT']
        # Majors trop liquides pour pump-catching
        EXCLUDE_MAJORS = {
            'BTCUSD', 'ETHUSD', 'BNBUSD', 'XRPUSD', 'SOLUSD',
            'ADAUSD', 'DOGEUSD', 'TRXUSD', 'DOTUSD', 'LTCUSD',
        }
        try:
            logger.info("🔄 Mise à jour symboles spy depuis Kraken...")
            response = self.client.public_get(
                f"{PRODUCTION_API}/api/v3/ticker/24hr"
            )
            if not response or not isinstance(response, list):
                logger.warning("   ⚠️ Réponse Kraken vide — skip mise à jour")
                return

            # Construire la liste des paires USD éligibles AVEC leur volume
            eligible_with_vol = []
            for ticker in response:
                sym = ticker.get('symbol', '')
                if not sym.endswith('USD'):
                    continue
                if sym in EXCLUDE_EXACT or sym in EXCLUDE_MAJORS:
                    continue
                base = sym[:-3]
                # 🔧 FIX 10/04: Retrait filtre isascii — excluait 币安人生USD (vol >1.5M$)
                if base.isascii() and not base.isalnum():
                    continue
                if any(kw in base for kw in EXCLUDE_KEYWORDS):
                    continue
                try:
                    vol = float(ticker.get('quoteVolume', 0))
                    price = float(ticker.get('lastPrice', 0))
                except (ValueError, TypeError):
                    continue
                if vol >= MIN_AUTO_VOLUME and price >= 0.000001:
                    eligible_with_vol.append((sym, vol))

            # ── TRACK VOLUME : top 10 par volume 24h ─────────────────────────
            eligible_with_vol.sort(key=lambda x: -x[1])
            top_volume = [s for s, _ in eligible_with_vol[:SLOTS_VOLUME]]

            # ── TRACK MOMENTUM : top 5 gainers 24h (pompes émergentes) ───────
            eligible_momentum = []
            for ticker in response:
                sym = ticker.get('symbol', '')
                if not sym.endswith('USD'):
                    continue
                if sym in EXCLUDE_EXACT or sym in EXCLUDE_MAJORS:
                    continue
                base = sym[:-3]
                # 🔧 FIX 10/04: Retrait filtre isascii — autorise les symboles Unicode (币安人生USD)
                if base.isascii() and not base.isalnum():
                    continue
                if any(kw in base for kw in EXCLUDE_KEYWORDS):
                    continue
                try:
                    vol    = float(ticker.get('quoteVolume', 0))
                    price  = float(ticker.get('lastPrice', 0))
                    change = float(ticker.get('priceChangePercent', 0))
                except (ValueError, TypeError):
                    continue
                if vol >= MIN_AUTO_VOLUME and price >= 0.000001 and change > 0:
                    eligible_momentum.append((sym, change, vol))
            eligible_momentum.sort(key=lambda x: -x[1])
            # Sauvegarder tous les gains 24h AVANT le filtre — sert à la logique de retrait
            eligible_change_map = {s: c for s, c, v in eligible_momentum}
            # 🔧 FIX 14/04: Exclure les coins déjà > MOMENTUM_MAX_GAIN_24H% — ce sont des
            # fins de pompe, pas des débuts. On prend les meilleurs gainers DANS la fourchette
            # [+2%, +15%] = momentum émergent, pas encore overbought.
            eligible_momentum = [(s, c, v) for s, c, v in eligible_momentum
                                 if 2.0 <= c <= MOMENTUM_MAX_GAIN_24H]
            top_momentum = [s for s, _, _ in eligible_momentum[:SLOTS_MOMENTUM]]

            # Union des deux tracks (sans doublons)
            new_target = list(dict.fromkeys(top_volume + top_momentum))[:MAX_AUTO_ADDED]
            eligible = set(s for s, _ in eligible_with_vol)

            # Charger la watchlist
            if not os.path.exists(WATCHLIST_FILE):
                return
            with open(WATCHLIST_FILE, 'r', encoding='utf-8') as f:
                watchlist_data = json.load(f)

            manual_symbols = set(watchlist_data.get('symbols', []))
            auto_added = watchlist_data.get('auto_added', {})
            now = now_paris()
            added = []
            removed = []

            # Retirer les auto-ajoutés qui ne font plus partie de la cible ET
            # ne sont plus éligibles (vol < 2M$) depuis > 24h — positions protégées
            active_positions = self._load_bot_positions()
            for sym in list(auto_added.keys()):
                if sym in eligible:
                    auto_added[sym]['last_seen'] = now.isoformat()
                else:
                    last_seen_str = auto_added[sym].get('last_seen', auto_added[sym].get('added_at', ''))
                    try:
                        last_seen_dt = datetime.fromisoformat(last_seen_str)
                    except (ValueError, TypeError):
                        last_seen_dt = now
                    hours_invisible = (now - last_seen_dt).total_seconds() / 3600
                    if hours_invisible >= 24 and sym not in active_positions:
                        del auto_added[sym]
                        self.watchlist.discard(sym)
                        removed.append(sym)

            # Ajouter les nouveaux meilleurs dans l'ordre de priorité (volume d'abord)
            vol_map = {s: v for s, v in eligible_with_vol}

            # 🚀 REBALANCE : remplacer les coins hors cible par les nouveaux meilleurs
            # (sauf si position active en cours)
            # Garde-fou : n'effectuer le rebalance que si new_target est suffisamment rempli
            if len(new_target) < SLOTS_VOLUME:
                logger.warning(f"   ⚠️ new_target trop court ({len(new_target)}) — rebalance annulé cette heure")
            else:
                current_auto = set(auto_added.keys())
                to_remove = current_auto - set(new_target) - active_positions

                # 🔧 FIX 15/04: Retrait prudent — ne pas retirer un coin déjà en scan si
                # son volume est encore OK et son gain 24h est < 40% (pompe non terminée).
                # Le filtre MOMENTUM_MAX_GAIN_24H bloque les *nouveaux ajouts* de tops,
                # mais un coin déjà ajouté à 5% qui monte à 23% doit rester en scan.
                for sym in to_remove:
                    sym_vol  = vol_map.get(sym, 0)
                    sym_gain = eligible_change_map.get(sym, 0)
                    if sym_vol >= MIN_AUTO_VOLUME and 0 < sym_gain <= 40.0:
                        # Coin toujours actif — juste sorti des slots standards
                        auto_added[sym]['last_seen'] = now.isoformat()
                        continue
                    del auto_added[sym]
                    self.watchlist.discard(sym)
                    removed.append(sym)

            for sym in new_target:
                if sym in manual_symbols or sym in auto_added:
                    continue
                if len(auto_added) >= MAX_AUTO_ADDED:
                    break
                auto_added[sym] = {
                    'added_at':   now.isoformat(),
                    'last_seen':  now.isoformat(),
                    'volume_24h': vol_map.get(sym, 0),
                }
                self.watchlist.add(sym)
                added.append(sym)

            # Sauvegarder — symbols[] n'est JAMAIS modifié ici (flux dashboard)
            watchlist_data['auto_added'] = auto_added
            watchlist_data['updated_at'] = now.isoformat()
            with open(WATCHLIST_FILE, 'w', encoding='utf-8') as f:
                json.dump(watchlist_data, f, indent=2)

            total_scan = len(manual_symbols) + len(auto_added)
            logger.info(
                f"   ✅ Spy scan: {total_scan} symboles "
                f"({len(manual_symbols)} dashboard + {len(auto_added)} spy-auto: "
                f"{len([s for s in auto_added if s in top_volume])}vol "
                f"+ {len([s for s in auto_added if s in top_momentum and s not in top_volume])}momentum)"
                f" (+{len(added)} ajoutés, -{len(removed)} retirés)"
            )
            if added:
