                                entry['consec_losses'] = 0
                                changed = True
                        except Exception:
                            pass
                    # Auto-reset consec_losses si dernier trade > 24h
                    # Un historique vieux de 24h+ ne reflète plus le contexte actuel
                    last_trade = entry.get('last_trade')
                    if last_trade and entry.get('consec_losses', 0) > 0 and not entry.get('blocked_until'):
                        try:
                            from dateutil.parser import parse as _dtparse
                            lt = _dtparse(str(last_trade))
                            if (now - lt).total_seconds() > 86400:  # > 24h
                                entry['consec_losses'] = 0
                                changed = True
                        except Exception:
                            pass
                if changed:
                    self._save_coin_scores(data)
                return data
        except Exception:
            pass
        return {}

    def _save_coin_scores(self, scores):
        """Sauvegarde atomique de spy_coin_scores.json."""
        try:
            tmp = SPY_COIN_SCORES_FILE + '.tmp'
            with open(tmp, 'w', encoding='utf-8') as f:
                json.dump(scores, f, indent=2, default=str)
            os.replace(tmp, SPY_COIN_SCORES_FILE)
        except Exception as e:
            logger.debug(f"Erreur save coin_scores: {e}")

    def _update_coin_score(self, symbol, pnl_pct, pnl_usdt=0.0):
        """Met à jour le score du coin après chaque trade et applique le circuit breaker."""
        scores = self._load_coin_scores()
        entry = scores.get(symbol, {
            'trades': 0, 'wins': 0, 'losses': 0,
            'consec_losses': 0, 'blocked_until': None,
            'last_trade': None, 'total_pnl_usdt': 0.0
        })
        entry['trades'] += 1
        entry['last_trade'] = now_paris().isoformat()
        entry['total_pnl_usdt'] = round(entry.get('total_pnl_usdt', 0.0) + pnl_usdt, 4)
        if pnl_pct > 0:
            entry['wins'] += 1
            entry['consec_losses'] = 0  # Reset serie perdante
            entry['blocked_until'] = None  # Débloquer si un trade gagne
        else:
            entry['losses'] += 1
            entry['consec_losses'] += 1
            if entry['consec_losses'] >= SPY_CB_MAX_CONSEC_LOSSES:
                block_until = now_paris() + timedelta(hours=SPY_CB_BLOCK_HOURS)
                entry['blocked_until'] = block_until.isoformat()
                logger.warning(
                    f"   🔴 CIRCUIT BREAKER {symbol}: {entry['consec_losses']} pertes consec. "
                    f"→ bloqué jusqu'au {block_until.strftime('%d/%m %H:%M')} ({SPY_CB_BLOCK_HOURS}h)"
                )
        scores[symbol] = entry
        self._save_coin_scores(scores)

    def _is_coin_blocked(self, symbol, surge_strength=0.0):
        """Vérifie si le coin est bloqué par le circuit breaker.
        
        Critères stricts pour ne bloquer que les vrais perdants chroniques,
        pas les bons coins avec une série perdante temporaire.
        
        surge_strength: si >= 2.5%, les blocages stats (winrate/PnL) sont bypassés.
        Si >= 3.0% (flash extrême), même le blocage temporel est court-circuité —
        un breakout violent peut marquer un vrai retournement même sur un perdant.
        """
        scores = self._load_coin_scores()
        entry = scores.get(symbol)
        if not entry:
            return False, ''

        total = entry.get('trades', 0)
        wins = entry.get('wins', 0)
        total_pnl = entry.get('total_pnl_usdt', 0)

        # 1. Blocage temporel (3 pertes consécutives) — bypassé si FLASH extreme (≥3%)
        # 🔧 16/04: FLASH ≥3% peut marquer un vrai retournement (ex: BIO +38% hors blocage)
        blocked_until = entry.get('blocked_until')
        if blocked_until and total_pnl <= 0:
            try:
                from dateutil.parser import parse as dtparse
                bt = dtparse(blocked_until)
                if now_paris() < bt:
                    if surge_strength >= 3.0:
                        # Flash extrême = possible vrai breakout même sur coin perdant — on tente
                        pass  # laisser passer, les autres filtres (vol 5x, confirm) feront le tri
                    else:
                        remaining = (bt - now_paris()).days
                        return True, f"CB: {entry.get('consec_losses',0)} pertes consec. + PnL négatif → bloqué {remaining}j"
            except Exception:
                pass

        # 🔧 CB-FIX: Si surge très fort (≥2.5%), bypasser les blocages stat
        # (winrate historique / PnL) — le coin peut avoir des données stale ou seeding.
        # Le blocage temporel (blocked_until ci-dessus) reste toujours actif.
        if surge_strength >= 2.5:
            return False, ''

        # 2. Winrate catastrophique ET coin perdant (>= 8 trades, winrate < 15%, PnL négatif significatif)
        # 🔧 16/04 refonte: seuil remonté 5→8 trades — 5 trades peut juste refléter un mauvais contexte de marché
        # Il faut un échantillon suffisant pour être statistiquement significatif
        if total >= 8 and total_pnl < -3.0:
            wr = wins / total
            if wr < 0.15:
                return True, f"CB: winrate {wr*100:.0f}% ({wins}/{total}), PnL {total_pnl:+.1f}"

        # 3. PnL très négatif (>= 8 trades et perte > 30 USDT)
        # 🔧 16/04 refonte: seuil remonté 5→8 trades, perte 20→30 USDT
        if total >= 8 and total_pnl < -30:
            return True, f"CB: PnL cumulé {total_pnl:+.1f} USDT ({total} trades)"

        return False, ''

    # ─────────────────────────────────────────────────────────────────────────────

    def open_position(self, symbol, surge_data, confirm_details, cached_regime=None):
        """Ouvre une position spy (achat market immédiat)"""
        if len(self.positions) >= SPY_MAX_POSITIONS:
            logger.warning(f"   ⚠️ Max {SPY_MAX_POSITIONS} positions spy")
            return False
        if symbol in self.positions:
            return False

        # ── Circuit breaker ──
        surge_strength_val = surge_data.get('strength', surge_data.get('surge_strength', 0.0)) if isinstance(surge_data, dict) else 0.0
        blocked, cb_reason = self._is_coin_blocked(symbol, surge_strength=surge_strength_val)
        if blocked:
            logger.info(f"   🚫 {symbol}: {cb_reason} → SKIP")
            return False

        # 🔧 FIX 06/04: Position sizing proportionnel au solde (réinvestissement auto)
        # Calcule la position comme un % fixe du solde USDC réel sur Binance.
        # Ratio = capitalRatioPct / 100 (configurable via bot_settings.json)
        # Si le solde grandit grâce aux gains, les positions grandissent proportionnellement.
        # Plancher = positionSize de bot_settings.json (jamais en dessous)
        # Plafond = 3× positionSize (évite de surexposer après un gros gain)
        surge_strength = surge_data.get('surge_strength', 0)
        _base_size = _load_position_size()  # Ex: 900 USDT depuis bot_settings.json
        _settings_now = _load_settings()
        _capital_ratio = float(_settings_now.get('capitalRatioPct', 5.625)) / 100.0
        _current_balance = 0  # 🔧 OPT 10/04: Initialisé ici pour réutilisation hors try
        try:
            _current_balance = self.client.get_balance("USD")
            _proportional_size = _current_balance * _capital_ratio
            # Plancher: jamais en dessous de la taille de base configurée
            # Plafond: max 3× la taille de base (limite le risque)
            position_size = max(_base_size, min(_proportional_size, _base_size * 3))
            if position_size > _base_size * 1.01:  # Log seulement si scaling actif
                logger.info(f"   💹 Réinvestissement: solde={_current_balance:.0f} USD → position={position_size:.0f} USD (base={_base_size:.0f}, ratio={_capital_ratio*100:.1f}%)")
        except Exception:
            position_size = _base_size  # Fallback sur taille fixe

        # 🔧 FIX 06/05: Cap scaling si WR récent < 40% — ne pas amplifier les pertes
        # Lire l'historique récent (20 derniers trades) depuis espion_history.json
        try:
            _hist_file = os.path.join(SCRIPT_DIR, 'espion_history.json')
            if os.path.exists(_hist_file):
                with open(_hist_file, 'r', encoding='utf-8') as _hf:
                    _hist = json.load(_hf)
                _recent = _hist[-20:] if len(_hist) >= 20 else _hist
                if len(_recent) >= 10:  # Minimum 10 trades pour être représentatif
                    _recent_wins = sum(1 for t in _recent if t.get('pnl_usdt', 0) > 0)
                    _recent_wr = _recent_wins / len(_recent)
                    if _recent_wr < 0.40 and position_size > _base_size:
                        logger.info(f"   ⚠️ [WR_CAP] WR récent={_recent_wr:.0%} < 40% → scaling désactivé (position limitée à {_base_size:.0f} USD)")
                        position_size = _base_size
        except Exception:
            pass

        # 🧠 Multiplicateur comportemental (avant le régime technique)
        behavior_mult = 1.0
        if hasattr(self, '_parent_spy') and self._parent_spy and self._parent_spy.behavior:
            bh = self._parent_spy.behavior
            behavior_mult = bh.get_position_multiplier()
            if behavior_mult < 1.0:
                logger.info(f"   🧠 Comportement {bh.current_regime}: position ×{behavior_mult:.0%}")
        position_size = position_size * behavior_mult
        if position_size < 10:
            logger.info(f"   🧠 Position trop petite ({position_size:.0f} USDT) → SKIP")
            return False

        # Vérifier le régime de marché (utilise le cache du scan — plus d'appel API bloquant)
        # 🔧 FIX 31/03: Utilise cached_regime passé par run_scan() au lieu de detect_regime()
        # Avant: detect_regime() faisait 8-10s d'appels API (BTC 1h+5m + 6 altcoins)
        # Maintenant: le régime est pré-chargé toutes les 50s par run_scan() → 0 latence
        regime_multiplier = 1.0
        regime_label = 'BULL'
        regime_name = cached_regime or 'NEUTRAL'
        if regime_name == 'BEAR':
            # 🔧 FIX 14/03: Blocage BEAR différencié par type de surge
            surge_type_now = surge_data.get('surge_type', '')
            is_trending_type = surge_type_now in ('TRENDING_SURGE', 'SLOW_PUMP', 'BUILDING_SURGE', 'SUSTAINED_SURGE', 'ACCELERATING_SURGE', 'LONG_TREND_SURGE')
            is_extreme_buy = confirm_details and confirm_details.get('buy_ratio', 0) >= 0.85
            is_extreme_flash_open = surge_type_now == 'FLASH_SURGE' and surge_strength >= 3.0
            if is_trending_type and not is_extreme_buy:
                logger.info(f"   🚫 BEAR régime: {surge_type_now} bloqué (besoin BuyVol ≥85% en BEAR)")
                return False
            elif surge_type_now == 'FLASH_SURGE' and not is_extreme_flash_open:
                buy_spike = confirm_details.get('buy_vol_spike', 1.0) if confirm_details else 1.0
                # 🔧 FIX 04/06: 2.0x → 1.5x — seuil 2.0x trop restrictif en BEAR:
                # analyse 77 FLASH_SURGE WR=48% +43.26% total, mais 0 achat exécuté
                # aujourd'hui car buy_spike souvent 0.5-1.5x. 1.5x préserve le filtre
                # anti-distribution tout en laissant passer les surges légitimes.
                if buy_spike < 1.5:
                    logger.info(f"   🚫 BEAR régime: FLASH {surge_strength:.2f}% bloqué (buy_spike {buy_spike:.1f}x < 1.5x requis)")
                    return False
            elif not is_trending_type and not is_extreme_flash_open and surge_strength < 1.5:
                logger.info(f"   🚫 BEAR régime: surge {surge_strength:.2f}% < 1.5% requis → BLOQUÉ")
                return False
            regime_multiplier = 0.50
            regime_label = f'BEAR (50% — flash fort autorisé)'
        elif regime_name == 'CORRECTION':
            regime_multiplier = 0.65
            regime_label = f'CORRECTION (65%)'
        elif regime_name == 'NEUTRAL':
            regime_multiplier = 0.80
            regime_label = 'NEUTRAL (80%)'
        else:
            regime_label = f'{regime_name} (100%)'
        
        position_size = position_size * regime_multiplier
        position_size = max(position_size, 10)  # Minimum 10 USDT
        # 🆕 OPT 28/05: FRAMEWORK_BUY — position plafonnée à 100 USDT
        # Justification: WR=18% sur 5 jours, avg max_pnl=0.38% → position de 650 USDT est excessive.
        # Cap à 100 USDT jusqu'à validation du signal sur ≥50 trades avec WR≥40%.
        if surge_data.get('surge_type', '').startswith('FRAMEWORK') and position_size > 100:
            logger.info(f"   🎯 [FRAMEWORK] Position plafonnée: {position_size:.0f} → 100 USDT (cap testnet)")
            position_size = 100

        logger.info(f"   📊 Régime: {regime_label} → Position: {position_size:.0f} USDT")

        # � Signaux macro (observation mode — log uniquement, aucun impact sur position)
        if _SIGNALS_AVAILABLE:
            _sig_snap = get_signal_snapshot()
            if _sig_snap:
                logger.info(f"   📡 [OBS] Signaux: {signal_log_entry(_sig_snap)}")
            else:
                logger.debug("   📡 [OBS] Pas de cache signal (aggregator non démarré)")

        # �🔧 OPT 10/04: Réutiliser _current_balance déjà récupéré ci-dessus (évite 1 appel API inutile)
        balance = _current_balance if _current_balance > 0 else self.client.get_balance("USD")
        if balance < position_size + 5:
            logger.warning(f"   ⚠️ Solde insuffisant ({balance:.2f} USD)")
            return False

        # ✂️ FIX 15/03: Vérification finale prix live — évite d'acheter la mèche
        # Après confirm_surge (~0.5-1s) + gestion positions (~0.5s), le prix a pu inverser.
        # Si price live < surge_price - 0.4% → le pump est terminé, on achèterait dans la chute.
        try:
            live_ticker = self.client.public_get(
                f"{SCAN_API}/api/v3/ticker/price", {"symbol": symbol}
            )
            if live_ticker:
                live_price = float(live_ticker.get('price', surge_data['price']))
                _retrace_live = ((surge_data['price'] - live_price) / surge_data['price']) * 100
                # 🔧 FIX 02/05: Seuil proportionnel à la force du surge (était fixe 0.25%)
                # Problème: FLASH_SURGE +1.38% → retrace 0.36% → SKIP à tort (bruit normal).
                # TEST achetait 10s plus tard (cycle décalé, retrace<0.25%) au même prix.
                # Fix: tolérer jusqu'à 30% de la force du surge, capé entre 0.25% et 0.55%.
                # Ex: surge 1.38% → seuil 0.41% | surge 0.5% → seuil 0.25% | surge 5% → 0.55%
                _surge_str_live = surge_data.get('strength', 1.0)
                # 🔧 TESTNET 17/05: pipeline confirm+ML ~1-3s → retrace naturel jusqu'à 1.5%
                _retrace_threshold = (max(0.60, min(_surge_str_live * 0.70, 1.50))
                                      if TESTNET_MODE
                                      else max(0.25, min(_surge_str_live * 0.30, 0.55)))
                if _retrace_live > _retrace_threshold:
                    logger.info(
                        f"   ⚠️ {symbol}: Prix retombé {_retrace_live:.1f}% depuis détection "
                        f"({live_price:.6f} < {surge_data['price']:.6f}) → SKIP (mèche évitée, seuil={_retrace_threshold:.2f}%)"
                    )
                    return False
        except Exception:
            pass  # Fallback: continuer sans vérification live

        # � FIX 11/04: Garde slippage réel (bookTicker ask vs prix détection)
        # Un ordre MARKET en prod paie le ask, pas le lastPrice. Sur coins illiquides,
        # spread + pump résiduel peut dépasser 1-5%, mangeant tout le budget SL (1.2%).
        # Ex: FFUSDC +0.91% slippage → marge restante 0.29% sur SL 1.2% → SL instantané.
        #     币安人生USDC +5.08% slippage → INSTANT_REVERSAL immédiat.
        # 🔧 FIX 08/05: ACTIVÉ AUSSI EN TESTNET (SCAN_API=prod, donc bookTicker réel)
        # Raison: les trades testnet servent de prédicteur prod. Sans ce filtre, on prend en
        # testnet des trades qu'on refuserait en prod → divergence +2492€ vs -7€ observée.
        # Filtrer sur le book prod réel rend les métriques testnet honnêtes.
        if True:  # was: if not TESTNET_MODE
            try:
                book = self.client.public_get(
                    f"{SCAN_API}/api/v3/ticker/bookTicker", {"symbol": symbol}
                )
                if book:
                    ask_price = float(book.get('askPrice', 0))
                    bid_price = float(book.get('bidPrice', 0))
                    # 🔧 FIX 04/06: bid/ask=0 → bookTicker invalide → liquidité inconnue
                    # Ex: PEPEUSDC bid=0/ask=0 → slippage 3.24% non détecté → -3.14% PnL
                    if ask_price == 0 or bid_price == 0:
                        logger.info(
                            f"   🚫 {symbol}: bookTicker ask/bid=0 → liquidité inconnue → SKIP"
                        )
                        return False
                    if ask_price > 0:
                        slippage_pct = (ask_price - surge_data['price']) / surge_data['price'] * 100
                        # 🔧 FIX 05/05: Paires micro-cap (prix < 0.001 USDT) → spread absolu élevé
                        # → slippage réel souvent > 0.5% sur FLASH_SURGE → seuil réduit à 0.3%
                        _slippage_max = 0.3 if surge_data['price'] < 0.001 else MAX_BUY_SLIPPAGE_PCT
                        if slippage_pct > _slippage_max:
                            logger.info(
                                f"   ⚠️ {symbol}: slippage ask trop élevé ({slippage_pct:.2f}% > {_slippage_max}%) "
                                f"→ SKIP (ask={ask_price:.6f} vs détection={surge_data['price']:.6f})"
                            )
                            return False
                    # 🔧 FIX 05/05: Spread ask/bid — si le bid est déjà retombé loin sous l'ask,
                    # les acheteurs ont disparu après le spike → on achèterait le sommet sans sortie.
                    # Ex: HIVE ask=0.0851 bid=0.0839 → spread=1.4% → INSTANT_REVERSAL garanti.
                    # Seuil: SL est à 1.2%, donc spread ≥ 0.8% = marge résiduelle < 0.4% → trop risqué.
                    if ask_price > 0 and bid_price > 0:
                        spread_pct = (ask_price - bid_price) / ask_price * 100
                        _spread_max = 1.5 if TESTNET_MODE else 0.8  # 🔧 TESTNET 17/05: plus permissif
                        if spread_pct > _spread_max:
                            logger.info(
                                f"   ⚠️ {symbol}: spread ask/bid trop large ({spread_pct:.2f}% > {_spread_max}%) "
                                f"→ SKIP (ask={ask_price:.6f} bid={bid_price:.6f}, spike sans liquidité)"
                            )
                            return False
                        # 🔧 FIX 05/05: Bid retraité sous le prix de détection → acheteurs partis du surge.
                        # Si bid < détection - 0.4% : les participants qui ont poussé le prix
                        # sont sortis. On achèterait le sommet sans acheteurs pour maintenir.
                        # Complémentaire au spread (spread mesure ask-bid, pas bid vs détection).
                        # Ex: detection=0.0851, bid=0.0840 → bid_retrait=1.29% → SKIP
                        if surge_data.get('price', 0) > 0:
                            _bid_retrait_pct = (surge_data['price'] - bid_price) / surge_data['price'] * 100
                            _bid_max_retrait = 1.0 if TESTNET_MODE else 0.4  # 🔧 TESTNET 17/05
                            if _bid_retrait_pct > _bid_max_retrait:
                                logger.info(
                                    f"   🚫 [BID_RETRAIT] {symbol}: bid retraité {_bid_retrait_pct:.2f}% "
                                    f"sous détection (bid={bid_price:.6f} < dét={surge_data['price']:.6f}) "
                                    f"→ acheteurs partis → SKIP"
                                )
                                return False
            except Exception:
                pass  # Fallback: continuer si bookTicker indisponible

        # 🔧 MOD6 02/05: Filtre volume acheteur — réutilise buy_ratio déjà calculé par confirm_surge_klines()
        # Un spike de volume avec majorité de vendeurs = distribution déguisée en surge → SKIP
        if confirm_details is not None:
            _entry_buy_ratio = confirm_details.get('buy_ratio', 1.0)
            if _entry_buy_ratio < ENTRY_MIN_BUY_RATIO:
                logger.info(
                    f"   🚫 [ENTRY_FILTER] {symbol}: volume acheteur insuffisant "
                    f"({_entry_buy_ratio:.1%} < {ENTRY_MIN_BUY_RATIO:.0%}) → SKIP (distribution masquée)"
                )
                return False
            # 🔧 FIX 05/05: Anti-faux-rebond fast spikers en SPECULATION.
            # Sans appel API supplémentaire: on réutilise buy_ratio/vol_ratio déjà calculés.
            _behavior_regime = ''
            if hasattr(self, '_parent_spy') and self._parent_spy and getattr(self._parent_spy, 'behavior', None):
                _behavior_regime = getattr(self._parent_spy.behavior, 'current_regime', '') or ''
            _is_fast_spiker_flash = (surge_data.get('surge_type') == 'FLASH_SURGE' and symbol in FLASH_SURGE_STRICT)
            if _is_fast_spiker_flash and _behavior_regime == 'SPECULATION':
                _entry_vol_ratio = confirm_details.get('vol_ratio', 1.0)
                # 🔧 TESTNET 11/05: 0.75→0.65 — en testnet, moins conservateur sur les fast spikers
                # L'objectif est de générer assez de trades pour valider la stratégie spy pro.
                _min_fast_buy_ratio = 0.65 if TESTNET_MODE else 0.75
                _min_fast_vol_ratio = 1.0
                if _entry_buy_ratio < _min_fast_buy_ratio or _entry_vol_ratio < _min_fast_vol_ratio:
                    logger.info(
                        f"   🚫 [ENTRY_FILTER_FAST] {symbol}: SPECULATION+FLASH fast spiker "
                        f"(buy={_entry_buy_ratio:.0%}<{_min_fast_buy_ratio:.0%} "
                        f"ou vol={_entry_vol_ratio:.1f}x<{_min_fast_vol_ratio:.1f}x) → SKIP"
                    )
                    return False

            # 🔧 FIX 05/05: Anti-peak-buy FLASH_SURGE — surge concentré dans le dernier scan (7s).
            # Si Δ1 ≥ Δ2×0.98 : hausse vient d'éclater à l'instant → achat probable au sommet
            # → INSTANT_REVERSAL max=0%. Exiger Δ2>Δ1×0.98 prouve 14s de momentum minimum.
            # Cases: HIVE 08:59 (Δ1=Δ2=1.91%), NOT 06:07 (Δ1=1.20%>Δ2=0.79%)
            if surge_data.get('surge_type') == 'FLASH_SURGE':
                _c1 = surge_data.get('change_1scan', 0)
                _c2 = surge_data.get('change_2scan', 0)
                if _c2 > 0 and _c1 >= _c2 * 0.98:
                    logger.info(
                        f"   🚫 [ENTRY_PEAK_BUY] {symbol}: FLASH surge concentré dernier scan "
                        f"(Δ1={_c1:.2f}% ≥ Δ2={_c2:.2f}%×0.98={_c2*0.98:.2f}%) → SKIP"
                    )
                    return False

        # �🔵 FIX 25/03: Ne pas retenter les symboles invalides (erreur -1121 auto-blacklist)
        if symbol in SpyApiClient._invalid_symbols:
            logger.warning(f"   ⛔ {symbol}: symbole invalide sur {'testnet' if TESTNET_MODE else 'exchange'} — SKIP (blacklist auto)")
            return False

        logger.info(f"   💰 ACHAT {position_size} USDT de {symbol} (surge {surge_strength:.1f}%)...")
        
        try:
            order = self.client.market_buy(symbol, position_size)
            if not order:
                logger.error(f"   ❌ Ordre échoué {symbol} — paire probablement absente du {'testnet' if TESTNET_MODE else 'exchange'}")
                return False

            # 🔬 Log exécution BUY — slippage = prix signal vs prix réel fill
            _exec_buy_log_id = None
            if self.exec_logger:
                try:
                    _exec_buy_log_id = self.exec_logger.log_buy(
                        symbol=symbol,
                        order_response=order,
                        theoretical_price=surge_data['price'],
                        signal_pattern=surge_data.get('surge_type', 'UNKNOWN'),
                    )
                except Exception as _elog_err:
                    logger.debug(f"exec_logger BUY KO: {_elog_err}")

            # Parser le fill
            filled_price = float(order.get('fills', [{}])[0].get('price', surge_data['price']))
            filled_qty = sum(float(f.get('qty', 0)) for f in order.get('fills', []))
            if filled_qty == 0:
                filled_qty = float(order.get('executedQty', 0))
            if filled_price == 0:
                cumm = float(order.get('cummulativeQuoteQty', 0))
                if filled_qty > 0:
                    filled_price = cumm / filled_qty
            
            # Récupérer step_size pour la vente
            step = self.client.get_symbol_step_size(symbol)
            if step:
                self.step_size_cache[symbol] = step
            
            # TP dynamique selon la force du surge
            vol_ratio = confirm_details.get('vol_ratio', 1.0) if confirm_details else 1.0
            dynamic_tp = get_dynamic_tp(surge_data['surge_strength'], vol_ratio)
            
            position = {
                'entry_price': filled_price,
                'quantity': filled_qty,
                'stop_loss': filled_price * (1 - HARD_STOP_LOSS_PCT / 100),
                'take_profit': filled_price * (1 + dynamic_tp / 100),
                'dynamic_tp_pct': dynamic_tp,
                'trailing_stop': None,
                'max_price': filled_price,
                'max_pnl': 0.0,
                'side': 'BUY',
                'order_id': order.get('orderId'),
                'timestamp': datetime.now(timezone.utc).isoformat(),
                'entry_time': time.time(),
                'surge_type': surge_data['surge_type'],
                'surge_strength': surge_data['surge_strength'],
                'source': 'MARKET_SPY',
                'indicators': confirm_details,
                'last_prices': [filled_price],
                'consecutive_drops': 0,
                # 🔧 FIX 14/03: RSI à l'entrée — utilisé pour durcir la sortie si RSI saturé
                'rsi_at_entry': confirm_details.get('rsi', 50) if confirm_details else 50,
                # 🆕 FIX 15/04: Pente EMA7 à l'entrée — protège EARLY_SL si EMA7 montait au moment de l'achat
                # EMA7 live n'est calculé qu'à 90s, mais on achète souvent sur EMA7 haussier
                # → stocker la pente connue pour protéger la position dans les 60-90s critiques
                'ema7_slope_at_entry': confirm_details.get('ema7_slope_pct', 0) if confirm_details else 0,
                # 🔧 FIX 29/04: EMA7 > EMA25 à l'entrée — plus fiable que la pente seule pour détecter tendance haussiere
                # Ex: ORCA 15:29 — EMA7>EMA25 clairement (chart), mais pente 7-kline ≤40.05% au moment du spike
                # → 'ema_bearish'=False dans confirm_details → stocker comme protection IR/EARLY_SL
                'ema7_bullish_at_entry': (not confirm_details.get('ema_bearish', True)) if confirm_details else False,
                # 🔧 FIX 15/04: Δ5 (momentum 5 scans) stocké à l'entrée — protège INSTANT_REVERSAL
                # quand le trend 5min est fort (ex: BIO Δ5=+3.96% → IR en 13s trop agressif)
                'delta_5m': surge_data.get('change_5scan', 0),
                # 🔬 ID du log d'exécution BUY — pour apparier au SELL dans analyze_execution.py
                'exec_log_id': _exec_buy_log_id,
            }
            
            self.positions[symbol] = position
            self._save_positions()
            self._save_to_main(symbol, position)
            
            logger.info(f"   ✅ ACHAT OK: {filled_qty} {symbol} @ {filled_price}")
            logger.info(f"      SL: -{HARD_STOP_LOSS_PCT}% | TP: +{dynamic_tp}% (dyn) | "
                       f"Trailing: -{TRAILING_STOP_PCT}%/{TRAILING_STOP_WIDE}% après +{TRAILING_ACTIVATION}%")
            
            return True
            
        except Exception as e:
            logger.error(f"   ❌ Erreur achat {symbol}: {e}")
            traceback.print_exc()
            return False
    
    def check_positions(self, tickers_dict):
        """
        Check TOUTES les positions à chaque scan (~12s).
        Utilise les prix des tickers (gratuit, pas d'API call).
        """
        if not self.positions:
            return
        
        to_sell = []
        now = time.time()
        
        for symbol, pos in list(self.positions.items()):
            price = tickers_dict.get(symbol)
            if price is None:
                try:
                    data = self.client.public_get(
                        f"{SCAN_API}/api/v3/ticker/price", {"symbol": symbol}
                    )
                    if data:
                        price = float(data['price'])
                except Exception:
                    continue
            if price is None:
                continue
            
            entry = pos.get('entry_price')
            if entry is None or entry <= 0:
                continue
            pnl_pct = ((price - entry) / entry) * 100
            hold_minutes = (now - pos.get('entry_time', now)) / 60
            # Mémoriser prix live dans la position (pour _write_status)
            pos['_current_price_live'] = price
            pos['_pnl_pct_live'] = round(pnl_pct, 4)
            # 🔧 FW 20/05: Entrées FRAMEWORK = tendance validée, gains différés
            # Critères de sortie propres au framework (différents du spy flash-surge) :
            #   • Trailing activation : +3.0% (vs 1.0% std) — laisser la tendance se déployer
            #   • Profit lock arm     : +1.5% (vs 0.40% std) — pas couper avant que la hausse commence
            #   • EMA7 min confirmations : +2 lectures supplémentaires (patience sur trend)
            #   • FRAMEWORK_STALL     : si 30min+ et pnl<-0.5% et EMA7 baissier → sortir
            _is_framework = pos.get('surge_type', '').startswith('FRAMEWORK')
            _max_hold_eff = 20 if _is_framework else MAX_HOLD_MINUTES  # 🆕 OPT 28/05: 60→20min (positions stagnantes coûteuses)
            _early_sl_delay_eff = 300 if _is_framework else EARLY_SL_DELAY  # 🆕 OPT 28/05: FW = majors lents, délai 90s→300s
            _trailing_act_eff     = 3.0 if _is_framework else TRAILING_ACTIVATION
            _fw_profit_lock_arm   = 1.5 if _is_framework else PROFIT_LOCK_ARM_PNL
            _fw_profit_lock_floor = -0.40 if _is_framework else PROFIT_LOCK_MIN_FLOOR
            
            # MAJ max price
            max_price = pos.get('max_price')
            if max_price is None:
                pos['max_price'] = price
                pos['max_pnl'] = pnl_pct
            elif price > max_price:
                pos['max_price'] = price
                pos['max_pnl'] = pnl_pct
            
            # Mini historique
            last_prices = pos.get('last_prices', [])
            last_prices.append(price)
            if len(last_prices) > 10:
                last_prices = last_prices[-10:]
            pos['last_prices'] = last_prices
            
            sell_reason = None
            hold_seconds = now - pos.get('entry_time', now)

            # Stagnation adaptative selon contexte marché
            _mctx = getattr(self, '_market_ctx', {})
            # _stagnation_minutes : inutilisé après suppression de STAGNATION_EXIT (02/05/2026)
            # _regime = _mctx.get('regime', 'NEUTRAL')
            # _stagnation_minutes = ...

            # ─── TENDANCE EMA7 — indicateur PRIMAIRE de tenue/sortie ─────────────────
            # Calculé avant toutes les règles de sortie.
            #   • _ema7_rising = True  → EMA7 monte  → supprimer les sorties "douces"
            #     (stagnation, momentum exit, reversal, volume rouge)
            #   • _ema7_bd     = True  → EMA7 pente négative (< -0.07%/3 bougies)
            #     → signal de vente PRIMAIRE (RÈGLE 0d) — EMA25 non requis
            # Refresh toutes les 90s via klines 1m (30 bougies, cache léger).
            _ema_cache_key = f"_ema_check_{symbol}"
            _ema_last_ts = pos.get(_ema_cache_key, 0)
            if hold_seconds >= 90 and now - _ema_last_ts >= 90:
                try:
                    _klines_ema = self.client.public_get(
                        f"{SCAN_API}/api/v3/klines",
                        {"symbol": symbol, "interval": "1m", "limit": 30}
                    )
                    if _klines_ema and len(_klines_ema) >= 10:
                        def _ema_calc(vals, period):
                            if len(vals) < period:
                                return vals[-1] if vals else 0.0
                            k = 2.0 / (period + 1)
                            e = sum(vals[:period]) / period
                            for v in vals[period:]:
                                e = v * k + e * (1 - k)
                            return e
                        _cls_ema = [float(k[4]) for k in _klines_ema]
                        _e7  = _ema_calc(_cls_ema, 7)
                        _e25 = _ema_calc(_cls_ema, 25) if len(_cls_ema) >= 25 else _ema_calc(_cls_ema, len(_cls_ema))
                        _e7_prev = _ema_calc(_cls_ema[:-3], 7) if len(_cls_ema) >= 10 else _e7
                        _slope = (_e7 - _e7_prev) / _e7_prev * 100 if _e7_prev > 0 else 0.0
                        pos[_ema_cache_key]      = now
                        pos['_ema7_last']        = round(_e7, 8)
                        pos['_ema25_last']       = round(_e25, 8)
                        pos['_ema7_slope']       = round(_slope, 3)
                        pos['_ema7_rising_live'] = _slope > 0.05     # > +0.05%/3 bougies = montée confirmée
                        pos['_ema_bearish_live'] = _e7 < _e25
                        _new_bd = (_slope < -0.07)
                        pos['_ema7_bd_live']     = _new_bd
                        # 🔧 FIX 04/05: Compteur de lectures consécutives en baisse
                        # Évite les faux déclenchements sur micro-dip (1 seule lecture négative)
                        pos['_ema7_bd_count'] = (pos.get('_ema7_bd_count', 0) + 1) if _new_bd else 0
                except Exception:
                    pass
            # Lire états depuis cache (None = pas encore calculé, < 90s de hold)
            _ema7_rising = pos.get('_ema7_rising_live', None)   # None = inconnu
            _ema7_bd     = pos.get('_ema7_bd_live', False)       # pente EMA7 négative confirmée = vendre
            # 🆕 FIX 15/04: EMA7 bullish cross (EMA7 > EMA25) même si slope < 0.05%
            # Sur 5m haussier, la pente 1m peut être faible (0-0.05%) mais EMA7 > EMA25 = tendance intacte
            # Ex: BIOUSDC vendu à +0.47% STAGNATION alors qu'EMA7>EMA25 et gros pump juste après
            # → si EMA7 clairement au-dessus d'EMA25, protéger STAGNATION comme _ema7_rising
            _ema7_bearish = pos.get('_ema_bearish_live', None)   # None = inconnu; False = EMA7 > EMA25
            _ema7_bullish = _ema7_rising or (_ema7_bearish is not None and not _ema7_bearish)

            # TREND_MOMENTUM désactivé (02/05/2026) : trailing standard uniquement.

            # ═══ RÈGLE 0: INSTANT REVERSAL — achat de mèche, retournement immédiat ═══
            # 🔧 FIX 31/03: Seuil -0.4% → -0.6% pour laisser respirer les coins volatils
            # Les micro-caps ont souvent +/-0.4% de spread naturel. Seuls les vrais
            # retournements (>0.6%) doivent être coupés immédiatement.
            # 🔧 FIX 15/04: Exception Δ5 fort — si momentum 5min ≥ 2.0%, le trend est haussier
            # et le dip en < 20s est probablement une mèche de spread, pas un vrai retournement.
            # Seuil élargi à -1.0% dans ce cas (ex: BIO -0.84% en 13s avec Δ5=+3.96% = mèche).
            _delta5_pos = pos.get('delta_5m', 0)
            _ema7_entry_slope_ir = pos.get('ema7_slope_at_entry', 0)
            _ema7_entry_bullish_ir = pos.get('ema7_bullish_at_entry', False)  # 🔧 FIX 29/04: EMA7>EMA25 à l'entrée
            # Si EMA7 haussier (live, slope >0.05%, OU EMA7>EMA25 à l'entrée), c'est une mèche normale
            _ema7_protect_ir = _ema7_bullish or (_ema7_rising is None and (_ema7_entry_slope_ir > 0.05 or _ema7_entry_bullish_ir))
            _ir_threshold = -0.6  # défaut
            if _delta5_pos >= 2.0 and hold_seconds < 20:
                _ir_threshold = -1.0   # fort momentum 5min
            elif _ema7_protect_ir:
                _ir_threshold = -1.0   # EMA7 haussier = mèche, pas vrai retournement
            # 🆕 FIX 08/05: INSTANT_REVERSAL_EARLY — coupe encore plus tôt si max_pnl=0%
            # et perte rapide. 19 INSTANT_REVERSAL/14j = -93 USDC en testnet, le seuil -0.6%
            # à 45s laissait la perte s'aggraver (avg -0.52%). En coupant dès 15-30s à -0.35%,
            # on économise ~0.2% par trade sans risque (max_pnl=0% = aucun acheteur ne suit).
            # Désactivé si EMA7 protect actif (vraie tendance, mèche acceptable).
            if 15 <= hold_seconds < 30 and pos['max_pnl'] < 0.05 and pnl_pct < -0.35 and not _ema7_protect_ir:
                sell_reason = f"INSTANT_REVERSAL_EARLY ({pnl_pct:+.2f}% en {hold_seconds:.0f}s, max={pos['max_pnl']:+.2f}%)"
            elif hold_seconds < 45 and pos['max_pnl'] < 0.05 and pnl_pct < _ir_threshold:
                sell_reason = f"INSTANT_REVERSAL ({pnl_pct:+.2f}% en {hold_seconds:.0f}s, max={pos['max_pnl']:+.2f}%)"

            # ═══ RÈGLE 0a: MICRO_BOUNCE_TRAP — bounce précoce [0.15%-0.40%] suivi de retournement net ══
            # Pattern: prix spike brièvement (0.15-0.40%) dans les 45-90 premières secondes
            # puis rechute immédiate sous le prix d'entrée → faux signal, couper avant HARD_SL.
            # Zone [0.15%, 0.40%]: au-dessus, l'INSTANT_REVERSAL (max<0.05%) ne couvre pas;
            # au-dessus de 0.40%, PROFIT_LOCK s'arme. Cette règle couvre le gap.
            # 🆕 FIX 04/05: implémentée suite à analyse HARD_SL (BIO 09:13, ORCA 13:35, ORDI 18:42).
            elif 45 <= hold_seconds < 90 and EARLY_SL_MIN_PNL <= pos['max_pnl'] < PROFIT_LOCK_ARM_PNL and pnl_pct < -0.20:
                sell_reason = f"MICRO_BOUNCE (max={pos['max_pnl']:+.2f}%, now={pnl_pct:+.2f}%, hold={hold_seconds:.0f}s)"
                logger.info(f"   🪤 MICRO_BOUNCE_TRAP {symbol}: bounce={pos['max_pnl']:+.2f}% puis rechute={pnl_pct:+.2f}% en {hold_seconds:.0f}s → exit")

            # ═══ RÈGLE 0b: EARLY_SL — signal mort confirmé après 90s (300s pour FW) ══
            # Prix n'a JAMAIS vraiment décollé (max_pnl < EARLY_SL_MIN_PNL=0.15%) et déjà
            # en perte > EARLY_SL_PCT=0.5% → "Dead on Arrival" détecté.
            # Constantes définies lignes 193-195, règle implémentée ici (était code mort).
            # 🆕 FIX 04/05: BIO 07:32 (63 ticks max=0.00%), ORCA 13:11 (max=0.00%).
            # 🆕 OPT 28/05: FW trades = majors/mid-caps plus lents → délai 90s→300s pour éviter sorties prématurées.
            elif hold_seconds >= _early_sl_delay_eff and pos['max_pnl'] < EARLY_SL_MIN_PNL and pnl_pct < -EARLY_SL_PCT:
                sell_reason = f"EARLY_SL (max={pos['max_pnl']:+.2f}%, pnl={pnl_pct:+.2f}%, hold={hold_seconds:.0f}s)"
                logger.info(f"   🛑 EARLY_SL {symbol}: jamais décollé (max={pos['max_pnl']:+.2f}%) → perte confirmée {pnl_pct:+.2f}% à {hold_seconds:.0f}s")

            # ═══ RÈGLE 0d: EMA7_DOWNTREND — pente EMA7 négative confirmée (PRIMAIRE) ══
            # EMA7 est le SIGNAL PRINCIPAL de sortie après l'achat.
            # Condition de base: pente EMA7 < -0.07%/3 bougies.
            # 🔧 FIX 04/05: Confirmation sur 2 lectures consécutives (~180s) pour éviter
            # les faux déclenchements sur micro-dip d'inflexion (EMA7 encore haussière globalement).
            # Exception → vente immédiate (1 seule lecture suffit) :
            #   • pnl < -0.70% ET slope < -0.15% (perte profonde + déclin marqué → couper avant HARD_SL)
            # EMA25 = information uniquement, PAS un trigger de sortie.
            # S'applique dès 90s de hold, quel que soit le pnl (protège gains ET coupe pertes).
            # Le calcul EMA7 est fait en amont (bloc "TENDANCE EMA7") — on lit le cache ici.
            # Tant que EMA7 monte (_ema7_rising), les règles "douces" (stagnation, momentum,
            # reversal, volume_rouge) sont neutralisées → position maintenue au maximum.
            elif hold_seconds >= 90 and _ema7_bd:
                _e7   = pos.get('_ema7_last', 0)
                _e25  = pos.get('_ema25_last', 0)
                _slp  = pos.get('_ema7_slope', 0)
                _bd_n = pos.get('_ema7_bd_count', 1)
                # Sortie immédiate si: perte profonde + pente marquée (avant HARD_SL)
                # EMA7<EMA25 = info seulement, pas un trigger de vente
                _deep_loss_out = pnl_pct < -0.70 and _slp < -0.15
                # 🔧 FIX 13/05: Si EMA7 encore au-dessus d'EMA25 (trend structurel haussier),
                # attendre 3 lectures consécutives au lieu de 2 — évite les sorties prématurées
                # lors d'une consolidation normale dans un uptrend (ex: ZEC, SOLV vendus trop tôt)
                _ema7_still_above_ema25 = (_ema7_bearish is not None and not _ema7_bearish)
                _min_bd = 3 if _ema7_still_above_ema25 else 2
                if _is_framework:
                    _min_bd += 2  # 🆕 FW 20/05: +2 lectures — tendance lente = plus de patience avant de couper
                if _deep_loss_out or _bd_n >= _min_bd:
                    # 🔧 FIX 19/05: EMA7 encore au-dessus d'EMA25 (_ema7_bullish) = tendance structurelle
                    # haussière intacte → ne pas vendre sur EMA7_DOWNTREND (consolidation normale).
                    # Cohérent avec PROFIT_LOCK (if not _ema7_bullish) & MAX_HOLD (bypass si _ema7_bullish).
                    # EDEN 19/05: 2 ventes prématurées alors qu'EMA7 restait au-dessus d'EMA25 et
                    # que le prix continuait à monter — gain potentiel manqué sur trend haussier confirmé.
                    # Seule exception: perte profonde (_deep_loss_out) → couper quoi qu'il arrive.
                    if _ema7_bullish and not _deep_loss_out:
                        # Garde de sécurité: HARD_SL toujours actif même si EMA7 bullish bypass
                        if pos.get('stop_loss') is not None and price <= pos['stop_loss']:
                            sell_reason = f"HARD_SL ({pnl_pct:+.2f}%)"
                        else:
                            logger.info(f"   ⏸️ EMA7_DOWNTREND {symbol}: slope négatif mais EMA7>EMA25 → maintien "
                                        f"(slope={_slp:+.3f}%, n={_bd_n}, pnl={pnl_pct:+.2f}%, max={pos['max_pnl']:+.2f}%)")
                    else:
                        _trigger = ("perte_profonde" if _deep_loss_out
                                    else f"x{_bd_n}_lectures")
                        sell_reason = (
                            f"EMA7_DOWNTREND (EMA7={_e7:.6f}, "
                            f"slope={_slp:+.3f}%, pnl={pnl_pct:+.2f}%, trigger={_trigger})"
                        )
                        logger.info(f"   📉 EMA7 DOWNTREND {symbol}: baisse confirmée "
                                    f"(slope={_slp:+.3f}%, n={_bd_n}/{_min_bd}, trigger={_trigger}) → vente "
                                    f"(pnl={pnl_pct:+.2f}%, max={pos['max_pnl']:+.2f}%)")
                else:
                    logger.info(f"   ⏳ EMA7 DOWNTREND {symbol}: lecture négative "
                                f"(slope={_slp:+.3f}%, n={_bd_n}/{_min_bd}) — attente confirmation "
                                f"(pnl={pnl_pct:+.2f}%, max={pos['max_pnl']:+.2f}%)")

            # ═══ RÈGLE 1: HARD STOP LOSS ═══
            elif pos.get('stop_loss') is not None and price <= pos['stop_loss']:
                sell_reason = f"HARD_SL ({pnl_pct:+.2f}%)"

            # ═══ RÈGLE 1b: TRAILING STOP DÉJÀ ACTIVÉ — prix repasse sous le TS ═══
            # Bug: TS activé à +1.12% (ex: 4.4711), prix retombe en négatif → le bloc
            # TRAILING (elif pnl >= TRAILING_ACTIVATION) n'est pas entré et le TS stocké
            # n'est jamais vérifié. Résultat: on attend le HARD_SL à -1.2% au lieu de
            # sortir au niveau du trailing stop déjà acquis.
            # 🔧 FIX 14/04: On ne déclenche RULE 1b que si pnl > 0 (positif).
            # Si pnl ≤ 0, le HARD_SL s'occupe de la protection — le TS stocké ne doit
            # pas produire une sortie en perte alors que le hard SL n'est pas encore atteint.
            # Avant ce fix: GIGGLE max=+1.32% → TS=+0.31% stocké → prix redescend à +0.12%
            # → RULE 1b vendait à +0.12% alors que la tendance continuait à la hausse.
            # 🔧 FIX 19/05: EMA7 haussier = tendance intacte → ne pas déclencher le TS (EMA7 = priorité).
            elif pos.get('trailing_stop') and price <= pos['trailing_stop'] and pnl_pct > 0 and not _ema7_bullish:
                sell_reason = f"TRAILING (activated, max={pos['max_pnl']:+.2f}%, now={pnl_pct:+.2f}%, TS={pos['trailing_stop']:.6f})"

            # ═══ RÈGLE 2: TRAILING STOP DYNAMIQUE ═══
            # 02/05/2026: simplifié — 3 paliers, sans modificateurs EMA7 ni uptrend
            # Ratchet strict: le TS ne descend jamais. Activation à TRAILING_ACTIVATION (1.0%) sauf FRAMEWORK (0.6%).
            # 🔧 FIX 19/05: EMA7 haussier = tendance intacte → mettre à jour le TS (ratchet) mais ne pas vendre.
            # Si EMA7 encore bullish, le TS se déplace vers le haut mais ne déclenche pas — EMA7_DOWNTREND gère la sortie.
            elif pnl_pct >= _trailing_act_eff:
                trail_pct = get_dynamic_trailing(pos.get('surge_strength', 1.0), pnl_pct)
                trailing_sl = pos['max_price'] * (1 - trail_pct / 100)
                # RATCHET: le trailing stop ne doit JAMAIS baisser
                old_ts = pos.get('trailing_stop') or 0
                if trailing_sl > old_ts:
                    pos['trailing_stop'] = trailing_sl
                else:
                    trailing_sl = old_ts
                if price <= trailing_sl and not _ema7_bullish:
                    sell_reason = f"TRAILING (max={pos['max_pnl']:+.2f}%, now={pnl_pct:+.2f}%, trail={trail_pct:.1f}%)"

            # ═══ RÈGLE 2b: PROFIT_LOCK pré-trailing (anti-retour en perte) ═══
            # Zone ciblée: max_pnl dans [0.4%, seuil trailing) → trailing pas encore actif.
            # But: empêcher un trade passé en gain de finir en perte significative.
            # Ne s'applique pas aux trades qui dépassent le seuil trailing (laisse le trailing gérer).
            # 🆕 FW 20/05: arm threshold séparé pour framework (1.5% vs 0.40%) — hausses lentes
            elif hold_seconds >= 90 and _fw_profit_lock_arm <= pos['max_pnl'] < _trailing_act_eff:
                # 🔧 FIX 18/05: EMA7 haussier (positif) = tendance intacte → laisser le trade respirer
                # Bug EDEN: PROFIT_LOCK vendait prématurément alors qu'EMA7 était encore en hausse
                # EMA7 = condition PRIMAIRE de sortie — si haussier, ne pas couper sur PROFIT_LOCK
                if not _ema7_bullish:
                    _slp = pos.get('_ema7_slope', 0.0)
                    _ema_soft = (_slp <= PROFIT_LOCK_EMA_SOFT_SLOPE) or (_ema7_rising is False)
                    _max_retrace = PROFIT_LOCK_RETRACE_EMA_SOFT if _ema_soft else PROFIT_LOCK_RETRACE
                    _lock_floor = max(_fw_profit_lock_floor, pos['max_pnl'] - _max_retrace)

                    # Si EMA7 accélère fortement à la hausse, on laisse respirer (évite de couper un vrai départ).
                    # 🔧 FIX 05/05: Override si pnl < PROFIT_LOCK_MIN_FLOOR (plancher absolu) — cas CFG:
                    # EMA7 slope=+1.37% → bypass → pnl=-0.27% non bloqué → HARD_SL -1.75%.
                    _hard_floor_breach = pnl_pct < _fw_profit_lock_floor
                    if (_slp < 0.15 or _hard_floor_breach) and pnl_pct <= _lock_floor:
                        sell_reason = (
                            f"PROFIT_LOCK (max={pos['max_pnl']:+.2f}%, floor={_lock_floor:+.2f}%, "
                            f"slope={_slp:+.3f}%, now={pnl_pct:+.2f}%)"
                        )

            # ═══ RÈGLE 2c: FRAMEWORK_STALL — tendance non matérialisée ═══════════════
            # Si hold ≥ 30min, pnl encore négatif (< -0.5%) ET EMA7 confirmé baissier :
            # la hausse attendue n'est pas venue → sortir à -30min plutôt qu'attendre 60min.
            # Ex: BCH acheté au pic puis range baissier → couper à 30min pas 60min.
            # Ne se déclenche PAS si EMA7 bullish (trend encore intègre → laisser courir).
            elif _is_framework and hold_minutes >= 30 and pnl_pct < -0.5 and not _ema7_bullish and pos.get('_ema7_bd_count', 0) >= 3:
                sell_reason = f"FRAMEWORK_STALL (hold={hold_minutes:.1f}min, pnl={pnl_pct:+.2f}%, ema7_bd={pos.get('_ema7_bd_count',0)})"
                logger.info(f"   ⏱️ FRAMEWORK_STALL {symbol}: tendance non matérialisée après {hold_minutes:.0f}min → sortie ({pnl_pct:+.2f}%)")

            # 🆕 FW_EMA_CROSS_NEG — EMA7 < EMA25 persistant + pnl négatif sans slope raide
            # Couvre les large-caps (BNB, ETH...) où la pente EMA7 est trop douce pour déclencher
            # EMA7_DOWNTREND (-0.07%), mais l'EMA7 est clairement sous EMA25 depuis 15min+ avec pnl < 0.
            # → sortie à ~35min au lieu d'attendre 60min MAX_HOLD
            elif (_is_framework and hold_minutes >= 35
                  and pos.get('_ema_bearish_live', False)   # EMA7 < EMA25 (cross baissier persistant)
                  and not _ema7_rising                       # EMA7 ne remonte pas
                  and pnl_pct < -0.30):                     # 🆕 OPT 28/05: -0.15→-0.30 (évite sorties sur bruit, WR=0% avec -0.15)
                sell_reason = f"FW_EMA_CROSS_NEG (hold={hold_minutes:.1f}min, pnl={pnl_pct:+.2f}%, slope={pos.get('_ema7_slope',0):+.3f}%)"
                logger.info(f"   ⏱️ FW_EMA_CROSS_NEG {symbol}: EMA7<EMA25 persistant + pnl négatif → sortie ({pnl_pct:+.2f}%)")

            # ═══ RÈGLE 3: DURÉE MAX — bypass si EMA7 haussier et trade positif ═══
            # 🔧 FIX 18/05: EMA7 haussier (positif) = tendance intacte → ne pas forcer la sortie sur durée seule
            # Bug EDEN: MAX_HOLD vendait à 12min alors qu'EMA7 restait positif (hausse manquée)
            # Si EMA7 encore en hausse et pnl >= 0 : laisser courir, EMA7_DOWNTREND gèrera la sortie
            # 🆕 FW 20/05: FRAMEWORK_EXTEND — logging périodique + cap absolu + stagnation prolongée
            elif hold_minutes >= _max_hold_eff:
                if _ema7_bullish and pnl_pct >= 0:
