                        'price_change_24h': _pump_24h,
                        'surge_type': surge_type,
                        'surge_strength': round(surge_strength, 3),
                        'timestamp': now
                    })
        
        return surges
    
    def confirm_surge(self, client, symbol, surge_data, market_ctx=None):
        """
        Confirmation rapide avec klines 1m (dernières bougies).
        Vérifie: volume spike + direction haussière + pas de rejet.

        market_ctx (optionnel): dict retourné par get_btc_market_context().
          - is_recovery_window → seuils assouplis (capturer les premiers movers du rebond)
          - is_freefall        → seuils durcis (pumps instables en chute libre)
        """
        if market_ctx is None:
            market_ctx = {}
        # 🔧 FIX 06/06: 7→30 klines — EMA25 nécessite 26+ bougies (sinon EMA7=EMA25 toujours).
        # Avec 7 klines: _ema7_cf=_ema25_cf (mêmes données) → ema_bearish_now=False toujours.
        # _ema7_pre fallback vers _ema7_cf (qui inclut le spike) car len<8 → ema_bearish_pre=False.
        # Résultat: ema_bearish_effective=False → Cas E (flash_ema_bearish) jamais déclenché.
        # Même problème: RSI14 retourne 50.0 (7<15), ema7_slope=0.0 (7<8), mom_15m=0 (7<15).
        klines = client.public_get(
            f"{SCAN_API}/api/v3/klines",
            {"symbol": symbol, "interval": "1m", "limit": 30}
        )
        
        if not klines or len(klines) < 3:
            return False, {"reason": "insufficient_klines"}
        
        closes = [float(k[4]) for k in klines]
        opens = [float(k[1]) for k in klines]
        highs = [float(k[2]) for k in klines]
        lows = [float(k[3]) for k in klines]
        volumes_quote = [float(k[7]) for k in klines]

        current_vol = volumes_quote[-1]

        # Volume moyen des bougies précédentes (excluant la dernière)
        avg_vol = np.mean(volumes_quote[:-1]) if len(volumes_quote) > 1 else current_vol
        vol_ratio = current_vol / avg_vol if avg_vol > 0 else 0

        # Aussi vérifier le volume de l'avant-dernière bougie
        prev_vol_ratio = volumes_quote[-2] / avg_vol if avg_vol > 0 and len(volumes_quote) > 1 else 0
        best_vol_ratio = max(vol_ratio, prev_vol_ratio)

        # ═══ TAKER BUY VOLUME — pression acheteuse agressive ═══
        # Binance klines k[10] = volume USDT côté acheteurs (takers frappant l'ask)
        # Ratio buy/total > 0.5 = majorité acheteurs | > 0.65 = forte pression acheteuse
        # Ratio < 0.35 + gros volume = vendeurs qui dominent (distribution / pump & dump)
        taker_buy_vols = []
        for k in klines:
            try:
                taker_buy_vols.append(float(k[10]))
            except (IndexError, ValueError):
                taker_buy_vols.append(0.0)

        current_buy_vol  = taker_buy_vols[-1]  if taker_buy_vols else 0.0
        prev_buy_vol     = taker_buy_vols[-2]  if len(taker_buy_vols) > 1 else current_buy_vol

        # Ratio pour les 2 dernières bougies (même logique que best_vol_ratio)
        current_buy_ratio = current_buy_vol / volumes_quote[-1]  if volumes_quote[-1] > 0 else 0.5
        prev_buy_ratio    = prev_buy_vol    / volumes_quote[-2]  if len(volumes_quote) > 1 and volumes_quote[-2] > 0 else 0.5
        best_buy_ratio    = max(current_buy_ratio, prev_buy_ratio)

        # Spike du volume pur achat vs moyenne des bougies précédentes
        avg_buy_vol   = sum(taker_buy_vols[:-1]) / len(taker_buy_vols[:-1]) if len(taker_buy_vols) > 1 else (current_buy_vol or 1.0)
        buy_vol_spike = current_buy_vol / avg_buy_vol if avg_buy_vol > 0 else 1.0

        # Interprétation
        strong_buy_pressure = best_buy_ratio >= 0.68  # 🔧 OPT 17/03: Acheteurs agressifs (0.62→0.68 — moins de faux positifs)
        sell_pressure       = best_buy_ratio < 0.45 and best_vol_ratio > 1.5  # 🔧 FIX 29/04: 0.35→0.45 — <45% acheteurs + gros vol = pression vendeuse (RAY 37% passé à travers)
        # 🔧 OPT 18/03: Pour TREND/BUILDING, exiger une vraie pression acheteuse (>50% côté acheteurs)
        # Les HARD_SL ANKRUSDT -1.30/-1.44% provenaient de TREND sans buy dominance
        _surge_type_now = surge_data.get('surge_type', '')
        # Patterns momentum désactivés (02/05): ne pas les compter dans les checks "trend".
        _is_trend_type = _surge_type_now in ('TRENDING_SURGE', 'BUILDING_SURGE', 'SLOW_PUMP', 'LONG_TREND_SURGE')
        _weak_buy_for_trend = _is_trend_type and best_buy_ratio < 0.55
        
        # Bougies vertes récentes
        green_count = sum(1 for i in range(-3, 0) if closes[i] > opens[i])
        
        # Momentum court terme
        mom_3m = ((closes[-1] - closes[-4]) / closes[-4]) * 100 if len(closes) >= 4 else 0
        
        # ── EMA9 / EMA21 / RSI(14) calculés depuis les 20 klines ──────────────
        def _ema(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
        
        def _rsi(vals, period=14):
            if len(vals) < period + 1:
                return 50.0
            gains = [max(vals[i] - vals[i-1], 0) for i in range(1, len(vals))]
            losses = [max(vals[i-1] - vals[i], 0) for i in range(1, len(vals))]
            avg_g = sum(gains[-period:]) / period
            avg_l = sum(losses[-period:]) / period
            if avg_l == 0:
                return 100.0
            return round(100 - 100 / (1 + avg_g / avg_l), 1)
        
        # Filtre d'entrée : EMA7/EMA25 — 🔧 OPTIM 25/03 (variables renommées 02/05 pour clarté)
        # Même couple que la sortie EMA7_DOWNTREND — pas de double référentiel
        _ema7_cf  = _ema(closes, 7)
        _ema25_cf = _ema(closes, 25) if len(closes) >= 25 else _ema(closes, len(closes))
        ema_bearish_now = _ema7_cf < _ema25_cf  # True = tendance baissière (avec dernier spike inclus)

        # 🔧 FIX 03/05: Trend pre-spike — calculer EMA7/EMA25 SANS la dernière bougie
        # Problème PARTI/ZEN: FLASH_SURGE pousse le close de la bougie courante vers le haut,
        # rendant _ema7_cf > _ema25_cf momentanément → ema_bearish_now=False → filtres Cas A/E désactivés.
        # Le vrai trend sous-jacent (avant le spike) reste BEARISH.
        # Solution: calculer EMA7/EMA25 sur closes[:-1] pour détecter la tendance réelle.
        _ema7_pre  = _ema(closes[:-1], 7) if len(closes) >= 8 else _ema7_cf
        _ema25_pre = _ema(closes[:-1], 25) if len(closes) >= 26 else (_ema(closes[:-1], len(closes) - 1) if len(closes) > 1 else _ema25_cf)
        ema_bearish_pre = _ema7_pre < _ema25_pre  # Trend baissier AVANT le spike du surge actuel
        # Si la tendance était baissière avant le spike → traiter comme bearish (dead cat bounce)
        # Exception: _effective_extreme (FLASH ≥3% avec vraie pression acheteuse) peut renverser la tendance
        ema_bearish_effective = ema_bearish_now or ema_bearish_pre  # Union: bearish si l'un ou l'autre est bearish

        # 🆕 FIX 14/04: Pente EMA7 sur les 5 dernières bougies
        # Un EMA7 > EMA25 (haussier) mais en pente DESCENDANTE = tendance qui s'effondre
        # Ex: BROCCOLI — EMA7>EMA25 mais EMA7 descend depuis 5 bougies → achat dans un downtrend
        # slope > 0 = EMA7 monte / slope < 0 = EMA7 descend même si encore au-dessus de EMA25
        if len(closes) >= 8:
            _ema7_prev = _ema(closes[:-3], 7)  # EMA7 calculée 3 bougies avant
            ema7_slope_pct = (_ema7_cf - _ema7_prev) / _ema7_prev * 100 if _ema7_prev > 0 else 0.0
        else:
            ema7_slope_pct = 0.0
        # 🔧 FIX 03/05: Seuil -0.15% → -0.10% (réagir plus tôt à un déclin EMA7)
        ema7_declining = ema7_slope_pct < -0.10  # Déclin > -0.10% sur 3 bougies = tendance baissière réelle

        rsi_now = _rsi(closes)
        
        # 15-minute context: momentum + % bougies rouges (DERNIÈRES 15 bougies)
        # 🔧 FIX 06/06: Utiliser closes[-15] (pas closes[0]) — avec limit=30, closes[0]=30min ago
        mom_15m = ((closes[-1] - closes[-15]) / closes[-15]) * 100 if len(closes) >= 15 else 0
        # Compter les bougies rouges parmi les 15 dernières (indices -14 à -1)
        red_bars_15 = sum(1 for i in range(max(1, len(closes) - 14), len(closes)) if closes[i] < closes[i-1])
        
        # Bougie actuelle verte?
        current_green = closes[-1] > opens[-1]
        
        # Rejet? (grande mèche haute vs corps)
        body = abs(closes[-1] - opens[-1])
        upper_wick = highs[-1] - max(closes[-1], opens[-1])
        rejection = upper_wick > body * 2 if body > 0 else False
        
        # ═══ BREAKOUT VERIFICATION ═══
        # Le prix actuel doit être un VRAI nouveau sommet, pas juste un rebond en V
        max_recent_high = max(highs[:-1]) if len(highs) > 1 else highs[-1]
        breakout_margin = ((closes[-1] - max_recent_high) / max_recent_high) * 100
        is_genuine_breakout = breakout_margin > 0.3  # Doit être >0.3% au-dessus du max récent
        
        # ═══ V-BOUNCE DETECTION ═══
        # Si le prix a chuté >1% récemment puis remonte, c'est un rebond, pas un surge
        min_recent_low = min(lows[-5:-1]) if len(lows) >= 5 else min(lows[:-1]) if len(lows) > 1 else lows[-1]
        max_older_high = max(highs[:-3]) if len(highs) > 3 else max(highs[:-1]) if len(highs) > 1 else highs[-1]
        recent_drop = ((min_recent_low - max_older_high) / max_older_high) * 100
        is_v_bounce = recent_drop < -1.0 and closes[-1] < max_older_high * 1.005  # Chute >1% + pas encore au-dessus
        
        # ═══ ENTRY FILTER IA — 05/05/2026 ═══
        # Règles apprises sur 396 vrais trades (WR 56.6% → 76.3% sur les trades qui passent)
        # Mode OBSERVATION: log les échecs mais ne bloque pas encore
        _price_vs_ema7  = ((closes[-1] - _ema7_cf) / _ema7_cf * 100) if _ema7_cf > 0 else 0.0
        _wick_range     = (highs[-1] - lows[-1])
        _upper_wick_r   = (upper_wick / _wick_range) if _wick_range > 0 else 0.0
        _ef_rules = {
            'surge_ok':    surge_data.get('surge_strength', 0) >= 0.634,   # return_1m > 0.634%
            'ema7_ok':     _price_vs_ema7 >= 0.526,                         # prix > EMA7 +0.526%
            'wick_ok':     _upper_wick_r < 0.373,                           # mèche haute < 37.3%
            'rsi_ok':      rsi_now >= 74.2,                                  # RSI confirmé ≥ 74.2
            'volume_ok':   best_vol_ratio >= 2.02,                           # volume ≥ 2x normal
        }
        _ef_score = sum(_ef_rules.values())  # 0-5 règles validées
        _ef_fails = [k for k, v in _ef_rules.items() if not v]
        if _ef_score < 5:
            logger.debug(f"      [ENTRY_FILTER] {symbol}: {_ef_score}/5 règles OK | échecs: {_ef_fails} | vol_r={best_vol_ratio:.1f} rsi={rsi_now:.0f} ema7={_price_vs_ema7:.2f}% wick={_upper_wick_r:.2f}")
        # 🔧 FIX 06/05: HARD BLOCK si ≤ 3/5 règles (durci — WR=32% → trop permissif à ≤2)
        # Exception HOTLIST: coin en haute surveillance CoinGecko/Binance → seuil ≤2/5 (1 règle de tolérance)
        # 🔧 TESTNET 11/05: En testnet, seuil ≤2/5 (3 règles suffisent) — volume de trades nécessaire pour valider spy pro
        _hl_coin = _HOTLIST.get(symbol)
        _hotlist_active = bool(_hl_coin and _hl_coin.get('expires_at', '') > datetime.utcnow().isoformat())
        # 🧠 AI_OPP: assouplir si le coin est dans le TOP AI (score ≥ 55 = sélectionné par ai_opportunity_selector)
        _ai_opp_entry = _AI_OPP_SCORES.get(symbol, {})
        _ai_opp_active = bool(_ai_opp_entry and _ai_opp_entry.get('score', 0) >= 55)
        # 🆕 LOG FRAMEWORK: signaler les coins détectés par le framework 7 couches
        if _ai_opp_active:
            _fw_score   = _ai_opp_entry.get('score', 0)
            _fw_rank    = _ai_opp_entry.get('rank', '?')
            _fw_dir     = _ai_opp_entry.get('direction', '?')
            logger.info(
                f"   🧠 [FRAMEWORK 7C] {symbol} éligible — score={_fw_score:.0f}/100 "
                f"rank=#{_fw_rank} dir={_fw_dir} | "
                f"ENTRY_FILTER {_ef_score}/5 (seuil assoupli: 3 règles suffisent)"
            )
        # 🔧 TESTNET 17/05: seuil ≤1 (bloque seulement si 0-1/5 règles) — génère plus de data testnet
        _ef_limit = 1 if TESTNET_MODE else (2 if (_hotlist_active or _ai_opp_active) else 3)
        if _ef_score <= _ef_limit:
            _hl_note = f" [HOTLIST: {_hl_coin['reason']}]" if _hotlist_active else ""
            _ai_note = f" [AI_OPP: score={_ai_opp_entry['score']:.0f} rank=#{_ai_opp_entry['rank']}]" if _ai_opp_active and not _hotlist_active and not TESTNET_MODE else ""
            _tn_note = " [TESTNET: seuil 2/5]" if TESTNET_MODE and not _hotlist_active else ""
            logger.info(f"      🛑 [ENTRY_FILTER] {symbol}: {_ef_score}/5 règles OK → rejet | {_ef_fails}{_hl_note}{_ai_note}{_tn_note}")
            _ef_reason = f'ENTRY_FILTER_{_ef_score}of5({",".join(_ef_fails)})'
            return False, {'reason': _ef_reason, 'ef_rules': _ef_rules, 'rejection_reasons': [_ef_reason]}

        details = {
            'vol_ratio': round(best_vol_ratio, 1),
            'green_count_3': green_count,
            'mom_3m': round(mom_3m, 3),
            'current_green': current_green,
            'rejection': rejection,
            'current_vol_usdt': round(current_vol, 0),
            'avg_vol_usdt': round(avg_vol, 0),
            'breakout_margin': round(breakout_margin, 2),
            'is_v_bounce': is_v_bounce,
            'rsi': rsi_now,
            'ema_bearish': ema_bearish_now,
            'ema7_slope_pct': round(ema7_slope_pct, 3),  # 🆕 FIX 14/04: pente EMA7
            'ema7_declining': ema7_declining,
            'mom_15m': round(mom_15m, 3),
            'red_bars_15': red_bars_15,
            # 🆕 Taker buy volume
            'buy_ratio': round(best_buy_ratio, 2),
            'buy_vol_spike': round(buy_vol_spike, 1),
            'strong_buy_pressure': strong_buy_pressure,
            'sell_pressure': sell_pressure,
            # 🆕 ENTRY_FILTER
            'ef_score': _ef_score,
            'ef_fails': _ef_fails,
        }
        
        # Critères de confirmation
        # Mode adaptatif: si le surge est fort (FLASH ≥ 1.2%), on assouplit
        # car le surge EST la preuve — les bougies passées ne l'ont pas encore
        surge_strength = surge_data.get('surge_strength', 0)
        is_strong_flash = (surge_data.get('surge_type') == 'FLASH_SURGE' and surge_strength >= 1.0)
        # 🔧 FIX 02/07: ema_bearish_now → ema_bearish_effective (inclut la tendance PRE-spike).
        # Analyse exec_logs mai-juin: LONG_TREND_SURGE WR=18.8%, 10/16 sorties EMA7_DOWNTREND.
        # ema_bearish_now pouvait être False juste parce que la bougie courante (déjà incluse
        # dans le calcul) faisait momentanément remonter EMA7 au-dessus d'EMA25, alors que la
        # tendance sous-jacente restait baissière (dead cat bounce) → is_trending validait à tort.
        is_trending = surge_data.get('surge_type') in ('TRENDING_SURGE', 'SLOW_PUMP', 'LONG_TREND_SURGE') and not ema_bearish_effective
        is_extreme_flash = surge_data.get('surge_type') == 'FLASH_SURGE' and surge_strength >= 3.0  # 🔧 FIX 14/03: FLASH ≥3% = kline ~10s partiellement remplie
        is_momentum = False  # Pattern désactivé (02/05/2026)
        is_trend_momentum = False  # Pattern désactivé (02/05/2026)

        # 🆕 Adaptation selon contexte marché macro
        is_recovery = market_ctx.get('is_recovery_window', False)
        is_freefall  = market_ctx.get('is_freefall', False)
        btc_mom_3h   = market_ctx.get('btc_mom_3h', 0.0)
        bullish_pct  = market_ctx.get('bullish_pct', 50.0)

        # Suffixe de log pour info
        mkt_label = ''
        if is_recovery:
            mkt_label = ' [REBOND: seuils assouplis]'
        elif is_freefall:
            mkt_label = ' [FREEFALL: seuils durcis]'
        elif btc_mom_3h < -1.0:
            mkt_label = f' [BTC mom3h={btc_mom_3h:.1f}%]'

        confirmed = True
        reasons = []

        # ══ 0. ✂️ FIX 15/03: ANTI-MÈCHE — Le surge est-il encore actif ? ════════════════
        # Problème REZ/FET: FLASH_SURGE détecté en 10s, mais par le temps de fetch klines
        # + confirmation + ordre (~1-3s), le prix avait déjà inversé → achat de la mèche.
        # On compare le prix de détection avec le close actuel de la kline.
        surge_detect_price = surge_data.get('price', closes[-1])
        price_retrace_pct = (
            (surge_detect_price - closes[-1]) / surge_detect_price * 100
            if surge_detect_price > 0 else 0.0
        )
        # Mèche haute = spike bref puis retour brutal → le close < high de façon marquée
        last_wick_pct = (highs[-1] - closes[-1]) / highs[-1] * 100 if highs[-1] > 0 else 0.0

        if not is_extreme_flash:
            # Cas A: Prix retraité depuis détection → mèche probable
            # 🔧 TEST 31/03: Seuil assoupli pour FLASH fort (0.3→0.5%) — les flash légitimes
            # retracent naturellement 0.3-0.5% le temps de la confirmation klines
            _retrace_limit = 0.5 if is_strong_flash else 0.3
            if price_retrace_pct > _retrace_limit and surge_data.get('surge_strength', 0) < 2.0:
                confirmed = False
                reasons.append(f"surge_retrace({price_retrace_pct:.1f}%_depuis_detection)")
            # Cas B: Grande mèche haute ET close déjà sous le prix de détection = spike & reverse
            elif last_wick_pct > 0.4 and closes[-1] < surge_detect_price:
                confirmed = False
                reasons.append(f"wick_trap(mèche_haute={last_wick_pct:.1f}%)")

        # ══ 1. VOLUME SPIKE ════════════════════════════════════════════════════════
        # ENJ/ANKR: le vrai pump = volume barre VERTE 5-10x supérieure à la normale
        # Adaptatif selon contexte marché:
        #      NORMAL   → 1.5x
        if is_extreme_flash:
            vol_threshold = 0.2  # 🔧 FIX 14/03: kline ~10s/60s partiellement remplie → volume actuel incomplet
        elif is_strong_flash and ema_bearish_effective:
            vol_threshold = 1.0  # 🔧 FIX 05/05: EMA bearish + FLASH → rebond sans volume = dead cat, exiger ≥1.0x
        elif is_strong_flash:
            vol_threshold = 0.8  # 🔧 TEST 31/03: 1.0→0.8 — FLASH fort = le surge EST la preuve, volume partiel OK
        elif is_trending:
            vol_threshold = 0.8  # 🔧 FIX: tendance soutenue → avg_vol déjà élevé, ratio naturellement bas
        elif is_recovery:
            vol_threshold = 1.2
        elif is_freefall:
            vol_threshold = 2.0
        else:
            vol_threshold = 1.5
        if best_vol_ratio < vol_threshold:
            # 🆕 Exception: forte pression acheteuse compense un ratio global légèrement faible
            # Logique: si 65%+ du volume est côté acheteurs, le signal est quand même valide
            # 🔧 FIX 12/04: Ajouter buy_vol_spike >= 1.0 — 87% buy_ratio avec spike 0.5x = faux signal
            if strong_buy_pressure and best_vol_ratio >= vol_threshold * 0.7 and buy_vol_spike >= 1.0:
                pass  # Pression acheteuse compense — vol accepté
            else:
                confirmed = False
                reasons.append(f"vol_low({best_vol_ratio:.1f}x<{vol_threshold}x)")
        # 🔧 FIX 05/05: Vol > 500x = artefact nouveau listing (baseline ~0) → carnet vide, slippage sortie massif
        # Ex: WUSDC vol=3434x (Δ1=Δ2=Δ5 identiques → 1ère bougie) → perte -1.45% sur exit
        elif best_vol_ratio > 500:
            confirmed = False
            reasons.append(f"vol_suspect_new_listing({best_vol_ratio:.0f}x>500x)")
            logger.info(f"   🚫 VOL ANORMAL: {symbol} vol={best_vol_ratio:.0f}x > 500x → probable nouveau listing, carnet vide → SKIP")
        
        # 2. Direction haussière (au moins 2/3 bougies vertes)
        #    Pour flash fort: 1/3 suffit (le pump vient de démarrer, bougies passées sont pré-pump)
        min_green = 0 if is_extreme_flash else (1 if is_strong_flash else 2)  # 🔧 FIX 14/03: extreme flash = kline partielle
        if green_count < min_green:
            confirmed = False
            reasons.append(f"not_bullish({green_count}/3 green)")
        
        # 3. Pas de rejet (mèche haute trop grande)
        if rejection and not is_extreme_flash:  # 🔧 FIX 14/03: extreme flash = spike partiel → mèche comptabilisée avant que la bougie se ferme
            confirmed = False
            reasons.append("rejection_wick")
        
        # 4. Bougie actuelle verte
        if not current_green:
            # Pour flash fort: tolérer si momentum récent très positif
            # Pour trending: tolérer une bougie rouge (pullback normal dans une tendance haussière)
            # 🔧 FIX 14/03: extreme flash = kline 10s encore ouverte → bougie "rouge" = avant le spike
            if not is_extreme_flash and not (is_strong_flash and mom_3m > 0.5) and not is_trending:
                confirmed = False
                reasons.append("current_red")
        
        # 5. Momentum positif
        #    🔧 Adaptatif:
        #      Flash fort  → tolérer léger négatif (< -1% bloque)
        #      REBOND      → 0.05% suffit (rebond précoce = mom encore modeste)
        #      FREEFALL    → 0.5% minimum (pump doit être fort pour survivre)
        #      Trending    → 0.1%
        #      Autres      → 0.2%
        if is_extreme_flash:
            pass  # 🔧 FIX 14/03: FLASH ≥3% = kline ~10s, momentum 3m non représentatif (données pré-spike)
        elif is_strong_flash:
            if mom_3m < -1.0:
                confirmed = False
                reasons.append(f"mom_negative_flash({mom_3m:.2f}%)")
        elif is_recovery:
            if mom_3m < 0.05:
                confirmed = False
                reasons.append(f"mom_weak_rebond({mom_3m:.2f}%)")
        elif is_freefall:
            if mom_3m < 0.5:
                confirmed = False
                reasons.append(f"mom_insuffisant_freefall({mom_3m:.2f}%<0.5%)")
        else:
            min_mom = 0.05 if surge_data.get('surge_type') == 'SLOW_PUMP' else (0.1 if surge_data.get('surge_type') in ('TRENDING_SURGE', 'BUILDING_SURGE', 'SUSTAINED_SURGE') else 0.2)
            if mom_3m < min_mom:
                confirmed = False
                reasons.append(f"mom_weak({mom_3m:.2f}%)")
        
        # 6. Vérifier que c'est un VRAI breakout (pas juste un micro-pic)
        #    🔧 FIX 02/03: Seuils assouplis — le surge lui-même EST le breakout
        #    Flash fort: pas de breakout check (le surge prouve le mouvement)
        #    Normal: 0.1% suffit (0.3% trop strict, rejetait 80%+ des surges)
        if not is_strong_flash and not is_trending:
            # 🔧 FIX: TRENDING_SURGE n'a pas besoin de breakout — la tendance graduelle
            # implique des pullbacks naturels sous le max récent des 20 klines
            min_breakout = 0.1
            if breakout_margin < min_breakout:
                confirmed = False
                reasons.append(f"no_breakout({breakout_margin:+.2f}%<{min_breakout}%)")
        
        # 7. Détecter les V-bounces (chute récente >1% puis remontée)
        #    🔧 FIX 02/03: Seuls les gros V-bounces (>3%) sont bloqués
        #    Les petits rebonds (-1% à -3%) peuvent être début de pump
        deep_v_bounce_threshold = -6.0 if is_trending else -3.0  # 🔧 FIX: tendance → pullbacks de -5% normaux
        deep_v_bounce = recent_drop < deep_v_bounce_threshold and closes[-1] < max_older_high * 1.005
        if deep_v_bounce and not is_strong_flash:
            confirmed = False
            reasons.append(f"v_bounce(drop={recent_drop:+.1f}%)")
        
        # 8. Pas déjà trop pumpé sur 24h
        #    🔧 FIX: TRENDING_SURGE avec EMA haussière → tolérer jusqu'à 100%
        #    La tendance soutenue + EMA9>EMA21 = momentum réel, pas dead cat bounce
        #    🆕 16/04: FLASH extreme (≥3%) + vol ≥2.5x + strong buy pressure = breakout violent → autoriser jusqu'à 120%
        #    Double verrou 16/04: vol 5x→2.5x (coins carnet léger n'atteignent pas 5x) MAIS buy_ratio ≥68% obligatoire
        #    Exemple NEIRO: +9.6% sur vol 2.2x mais buy_ratio dominant = signal réel malgré faible vol absolu
        if is_extreme_flash and best_vol_ratio >= 2.5 and strong_buy_pressure:
            max_pumped = SURGE_MAX_ALREADY_PUMPED_EXTREME_FLASH
        elif is_trending:
            max_pumped = SURGE_MAX_ALREADY_PUMPED_TRENDING
        else:
            max_pumped = SURGE_MAX_ALREADY_PUMPED
        if surge_data.get('price_change_24h', 0) > max_pumped:
            confirmed = False
            reasons.append(f"already_pumped_24h({surge_data['price_change_24h']}%)")

        # 8.5 🆕 SELL PRESSURE DETECTION (distribution / pump & dump)
        # Gros volume total MAIS vendeurs qui dominent = les baleines vendent pendant le pump
        # Ratio < 45% acheteurs avec volume spike > 1.5x = signal court-circuit de la hausse
        if sell_pressure:  # 🔧 FIX 13/04: supprimé "and not is_strong_flash" — sell_pressure s'applique même aux flash
            confirmed = False
            sell_pct = round(best_buy_ratio * 100)
            reasons.append(f"sell_pressure(buy={sell_pct}%<45%,vol={best_vol_ratio:.1f}x)")
            logger.info(f"   🚫🔴 SELL PRESSURE: Volume élevé ({best_vol_ratio:.1f}x) mais {sell_pct}% côté vendeurs → distribution")

        # 🔧 FIX 13/04: FLASH_SURGE sans pression acheteuse réelle = fausse alerte
        # Cas ENJ 13/04: buy_vol_spike=0.4 + buy_ratio=39% → achat en pleine baisse sans acheteurs
        # Condition duale: si AUCUN des deux critères n'est satisfait, refuser l'entrée
        #   - buy_vol_spike >= 1.0 : volume d'achat actuel >= moyenne (acheteurs présents)
        #   - best_buy_ratio >= 0.55 : majorité des traders côté achat
        if is_strong_flash and buy_vol_spike < 1.0 and best_buy_ratio < 0.55:
            confirmed = False
            reasons.append(f"flash_no_buy_pressure(spike={buy_vol_spike:.1f}x,buy={round(best_buy_ratio*100)}%<55%)")
            logger.info(f"   🚫 FLASH sans pression acheteuse: spike={buy_vol_spike:.1f}x, buy_ratio={round(best_buy_ratio*100)}% → pas de réel intérêt acheteur")

        # 🔧 OPT 18/03: Blocage TREND sans pression acheteuse
        # TRENDING/BUILDING/SLOW_PUMP sans majority buyers = entrée dans une tendance affaiblie
        if _weak_buy_for_trend and confirmed:
            confirmed = False
            buy_pct_tr = round(best_buy_ratio * 100)
            reasons.append(f"trend_weak_buyers(buy={buy_pct_tr}%<55%)")
            logger.info(f"   🚫 TREND faible: {_surge_type_now} buy_ratio={buy_pct_tr}% < 55% requis → risque HARD_SL")

        # ══ 9. 🔧 FIX 07/03: BLOCAGE RSI TRAP + DOWNTREND ════════════════════
        # Problème: SIGN acheté à RSI=17.5 EMA_Bearish BB_Baisse → perte assurée
        # Un FLASH_SURGE de 12s en pleine chute = dead cat bounce → INTERDIT

        # 🔧 FIX 29/04: EXTREME_FLASH (≥3%) avec buy_ratio faible + contexte baissier = mèche de manipulation
        # Ex: RAY +5.75% en 7s avec 37% buy ratio + EMA déclinant = wick vendé immédiatement
        # Dans ce cas, retirer l'exemption is_extreme_flash pour les checks EMA/downtrend
        # 🔧 FIX 03/05: Utiliser ema_bearish_effective (inclut pre-spike) pour détecter les dead cat bounces
        # 🔧 FIX 18/05: EMA baissière → seuil buy_ratio plus strict (0.45→0.65) — contexte baissier établi
        # exige une vraie pression acheteuse dominante pour qu'un extreme_flash soit considéré légitime
        _wick_buy_threshold = 0.65 if ema_bearish_effective else 0.45
        _extreme_flash_is_wick = is_extreme_flash and best_buy_ratio < _wick_buy_threshold and (ema_bearish_effective or ema7_declining)
        _effective_extreme = is_extreme_flash and not _extreme_flash_is_wick  # True seulement si vraie poussée acheteuse

        # Cas A: RSI bas + EMA baissière = piège dead cat bounce
        # 🔧 FIX 22/03: Élargi RSI ≤22 → RSI ≤35 (RESOLV acheté à RSI=30.8 + EMA_bearish = -17%/24h)
        # RSI 22-35 + EMA baissière = toujours en downtrend, le micro-rebond est éphémère
        # Exception: EXTREME_FLASH ≥3% AVEC vraie pression acheteuse peut être un vrai renversement
        if rsi_now <= 35 and ema_bearish_effective and not _effective_extreme:
            confirmed = False
            reasons.append(f"rsi_trap(RSI={rsi_now:.1f}+EMA_bearish)")
            logger.info(f"   🚫🔴 RSI TRAP: RSI={rsi_now:.1f}≤35 + EMA9<EMA21 → dead cat bounce, achat interdit")

        # Cas B: EMA bearish + 15min très négatif + majorité bougies rouges → downtrend établi
        elif ema_bearish_effective and mom_15m < -1.0 and red_bars_15 >= 8 and not is_strong_flash and not is_extreme_flash:  # 🔧 FIX 14/03: seuils assouplis (-1.5→-1.0, 10→8) pour détecter downtrend plus tôt
            confirmed = False
            reasons.append(f"downtrend_context(15m={mom_15m:+.1f}% {red_bars_15}/15 red)")
            logger.info(f"   🚫 DOWNTREND: EMA_bearish mom15m={mom_15m:+.1f}% {red_bars_15}/15 rouges → dead cat bounce")

        # Cas C: Chute violente sur 15min même avec flash fort
        elif mom_15m < -3.0 and red_bars_15 >= 11:
            confirmed = False
            reasons.append(f"severe_downtrend(15m={mom_15m:+.1f}% {red_bars_15}/15 red)")
            logger.info(f"   🚫 CHUTE SÉVÈRE: 15m={mom_15m:+.1f}% {red_bars_15}/15 rouges → BLOQUÉ")

        # Cas D: EMA7 en pente descendante — cross haussier mais momentum en train de mourir
        # 🆕 FIX 14/04: EMA7 > EMA25 (haussier) mais EMA7 descend → tendance en train de s'inverser
        # Pattern BROCCOLI: EMA bullish cross mais EMA7 décline depuis 3+ bougies = faux signal
        # 🔧 FIX 29/04: EXTREME_FLASH qui est une mèche (_extreme_flash_is_wick) n'est plus exempt
        # Seul vrai EXTREME avec pression acheteuse réelle (_effective_extreme) reste autorisé
        # 🔧 FIX 13/05: Si EMA baissière (EMA7<EMA25) ET slope négatif simultanément = double signal
        # downtrend → aucun flash ne justifie l'entrée (CFG acheté pendant déclin EMA7+EMA_bearish)
        elif ema7_declining and not _effective_extreme and not (is_strong_flash and surge_strength >= 1.5 and best_buy_ratio >= 0.45 and not ema_bearish_effective):
            if not ema_bearish_now:  # EMA7>EMA25 mais clip = situation trompeuse, avertissement
                logger.info(f"   ⚠️ EMA7 SLOPE < 0: EMA7 décline ({ema7_slope_pct:+.2f}%/3 bougies) "
                            f"malgré EMA7>EMA25 → momentum faiblissant")
            confirmed = False
            reasons.append(f"ema7_declining(slope={ema7_slope_pct:+.2f}%)")
            logger.info(f"   🚫 EMA7 DÉCLINANT: pente={ema7_slope_pct:+.2f}% < -0.15% → tendance s'affaiblit, achat bloqué")

        # Cas E: 🔧 FIX 18/05: FLASH_SURGE en contexte EMA baissier → TOUJOURS bloqué
        # BUGS corrigés:
        #   1) Ancien code: seulement surge < 2.5% → gap non couvert pour surge [2.5%, 3.0%)
        #   2) Exception Δ5 ≥ 2.0% circulaire: Δ5 élevé CAR c'est le surge lui-même → dead cat
        #      non détecté (ex: ORCA acheté en tendance baissière établie)
        # Seule exception: EXTREME_FLASH (≥3%) avec vraie pression acheteuse → _effective_extreme=True
        # (nécessite buy_ratio ≥ 0.65 en contexte baissier via _wick_buy_threshold ci-dessus)
        elif ema_bearish_effective and is_strong_flash and not _effective_extreme:
            _c5 = surge_data.get('change_5scan', 0)
            confirmed = False
            reasons.append(f"flash_ema_bearish(EMA7<EMA25,surge={surge_strength:.2f}%,Δ5={_c5:.2f}%)")
            logger.info(f"   🚫 FLASH EMA BEARISH: surge={surge_strength:.2f}% EMA7<EMA25 "
                        f"Δ5={_c5:.2f}% → dead cat en contexte baissier établi, achat bloqué")
        
        details['confirmed'] = confirmed
        details['rejection_reasons'] = reasons
        details['market_label'] = mkt_label

        # ─── Couche 7 shadow log ──────────────────────────────────────────────
        # Toujours exécuté (shadow mode) — ne modifie jamais `confirmed`.
        # Quand COUCHE7_ENABLED=True ET COUCHE7_SHADOW_MODE=False (déploiement futur),
        # la logique de blocage sera appliquée ici.
        if _COUCHE7_AVAILABLE and market_ctx:
            try:
                _c7_detector = market_ctx.get('couche7_detector')
                if _c7_detector is not None:
                    _c7_ctx = _c7_detector.get_context(symbol)
                    _macro_regime = market_ctx.get('regime', 'NEUTRAL')
                    _token_regime = _c7_ctx.regime if _c7_ctx else 'N/A'
                    _combined = get_combined_signal(_macro_regime, _token_regime) if _c7_ctx else 'N/A'
                    _bot_dec = 'BUY' if confirmed else 'SKIP'

                    # Shadow log : toujours enregistrer
                    log_couche7_decision(
                        symbol=symbol,
                        context=_c7_ctx,
                        bot_decision=_bot_dec,
                        reason=','.join(reasons) if reasons else 'ok',
                        extra={
                            'surge_type': surge_data.get('surge_type', ''),
                            'surge_strength': surge_data.get('surge_strength', 0),
                            'macro_regime': _macro_regime,
                            'combined_signal': _combined,
                        },
                    )

                    # Log console shadow
                    if _c7_ctx:
                        _c7_emoji = {'PHOENIX': '🦅', 'BULL_RUN': '📈',
                                     'PARABOLIC_EXTENSION': '🚀', 'PUMP_TERMINAL': '⛔',
                                     'CONSOLIDATION': '➡️', 'BEAR_RANGE': '🔻'}.get(_token_regime, '❓')
                        _shadow_note = ' [SHADOW — pas de blocage]' if COUCHE7_SHADOW_MODE else ''
                        logger.info(
                            f"   🔭 [C7] {symbol}: régime={_token_regime} conf={_c7_ctx.confidence:.2f} "
                            f"macro={_macro_regime} → signal={_combined}{_shadow_note} {_c7_emoji}"
                        )

                        # Déploiement futur (COUCHE7_ENABLED=True + COUCHE7_SHADOW_MODE=False) :
                        # if COUCHE7_ENABLED and not COUCHE7_SHADOW_MODE:
                        #     if _combined == 'AVOID' and confirmed:
                        #         confirmed = False
                        #         reasons.append(f'C7_AVOID(regime={_token_regime},macro={_macro_regime})')
            except Exception as _c7_err:
                logger.debug(f"[C7] shadow error {symbol}: {_c7_err}")
        # ─────────────────────────────────────────────────────────────────────

        return confirmed, details
    
    def _load_loss_state(self):
        """Charge l'état des pertes depuis le fichier JSON (survit aux redémarrages)."""
        try:
            now = time.time()
            if os.path.exists(SPY_LOSS_STATE_FILE):
                with open(SPY_LOSS_STATE_FILE, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                for sym, info in data.items():
                    n = info.get('consec_losses', 0)
                    blocked_until = info.get('blocked_until', 0)
                    if n > 0:
                        self.coin_consec_losses[sym] = n
                    if isinstance(blocked_until, (int, float)) and blocked_until > now:
                        self.coin_loss_blocked_until[sym] = blocked_until
            # 🔧 FIX 09/04: Synchroniser aussi avec spy_coin_scores.json qui contient les
            # blocked_until en ISO datetime (circuit breaker long terme, ex: 3-7 jours).
            # Sans ça, un coin bloqué 3j continue à générer des surges et des appels API.
            if os.path.exists(SPY_COIN_SCORES_FILE):
                with open(SPY_COIN_SCORES_FILE, 'r', encoding='utf-8') as f:
                    scores = json.load(f)
                from datetime import datetime, timezone
                for sym, info in scores.items():
                    bu_iso = info.get('blocked_until')
                    if not bu_iso or not isinstance(bu_iso, str):
                        continue
                    try:
                        bu_dt = datetime.fromisoformat(bu_iso)
                        if bu_dt.tzinfo is None:
                            bu_dt = bu_dt.replace(tzinfo=timezone.utc)
                        bu_ts = bu_dt.timestamp()
                        if bu_ts > now:
                            # Utiliser le blocage le plus long entre les deux sources
                            self.coin_loss_blocked_until[sym] = max(
                                self.coin_loss_blocked_until.get(sym, 0), bu_ts
                            )
                    except (ValueError, TypeError):
                        pass
            loaded = sum(1 for s, bu in self.coin_loss_blocked_until.items() if bu > now)
            logger.info(f"   📂 Loss state chargé: {loaded} coin(s) encore bloqué(s)")
        except Exception as e:
            logger.debug(f"Loss state load error: {e}")

    def _save_loss_state(self):
        """Sauvegarde l'état des pertes sur disque."""
        try:
            data = {}
            for sym in set(list(self.coin_consec_losses.keys()) + list(self.coin_loss_blocked_until.keys())):
                data[sym] = {
                    'consec_losses': self.coin_consec_losses.get(sym, 0),
                    'blocked_until': self.coin_loss_blocked_until.get(sym, 0)
                }
            with open(SPY_LOSS_STATE_FILE, 'w', encoding='utf-8') as f:
                json.dump(data, f, indent=2)
        except Exception as e:
            logger.debug(f"Loss state save error: {e}")

    def set_cooldown(self, symbol, pnl=None, max_pnl=None, sell_reason=None, hold_minutes=None):
        """Marquer un symbole en cooldown. pnl=None → rejet, pnl<0 → perte, pnl>0 → gain.
        max_pnl: pic de gain durant le trade. Si < 0.25%, considéré comme timing-loss → blocage réduit.
        sell_reason: raison de vente (ex: INSTANT_REVERSAL → blocage minimum 30 min, jamais réduit).
        hold_minutes: durée du trade. Si <2min et pnl<-0.5% → fast-loss, blocage 6h."""
        self.cooldown[symbol] = time.time()
        if pnl is not None and pnl < 0:
            self.coin_consec_losses[symbol] += 1
            n = self.coin_consec_losses[symbol]
            # 🔧 FIX 05/05: FAST_LOSS — hold<2min ET pnl<-0.5% = dead-cat ou market-maker trap
            # Le coin a retracé instantanément → probabilité de re-pump immédiat très faible
            # Bloquer 6h pour éviter de retenter plusieurs fois le même piège dans la journée
            _is_fast_loss = (hold_minutes is not None and hold_minutes < 2.0 and pnl < -0.5)
            if _is_fast_loss:
                block_sec = 21600  # 6h pour fast-loss (<2min, <-0.5%)
            elif n >= 3:
                block_sec = 10800  # 🔧 FIX 14/04: 3h après 3+ pertes consécutives (was 6h, réduit à 90min le 10/04 → trop court: TST acheté 3× en 1h)
            elif n == 2:
                block_sec = 3600   # 🔧 FIX 14/04: 1h après 2 pertes consécutives (was 2h, réduit à 30min le 10/04 → trop court)
            else:
                block_sec = 900    # 15 min après 1ère perte
            # 🔧 FIX 12/04: INSTANT_REVERSAL = pump inversé instantanément → minimum 30 min, jamais réduit
            # Cas: max_pnl=0% car inversion < 45s → ressemble à timing-loss mais c'est un coin dangereux
            # Le timing-loss halving ne doit pas s'appliquer → on risque de racheter 15 min plus tard sur le même piège
            is_instant_reversal = bool(sell_reason and 'INSTANT_REVERSAL' in sell_reason)
            # 🆕 FIX 04/05: Dead-on-Arrival = max_pnl < 0.05% (jamais vert) — signal de distribution
            # active. Ne doit PAS bénéficier du timing-loss halving (risque de réentrée sur piège).
            # Même traitement que 2 pertes consécutives (1h blocage minimum).
            is_dead_on_arrival = (max_pnl is not None and max_pnl < 0.05) and not is_instant_reversal
            is_timing_loss = (max_pnl is not None and max_pnl < 0.25) and not is_instant_reversal and not is_dead_on_arrival
            # 🔧 FIX 05/05: Fast-loss override — durée > blocage standard (pas réductible)
            if _is_fast_loss and block_sec < 21600:
                block_sec = 21600
            if _is_fast_loss:
                tag = f" [FAST_LOSS hold={hold_minutes:.1f}min pnl={pnl:+.2f}% → 6h]"
                self.coin_block_no_early.add(symbol)
            elif is_instant_reversal:
                block_sec = max(block_sec, 1800)  # Minimum 30 min pour une inversion instantanée
                tag = f" [INSTANT_REVERSAL → 30min min]"
                self.coin_block_no_early.add(symbol)  # 🆕 22/04: interdit de déblocage anticipé
            elif is_dead_on_arrival:
                block_sec = max(block_sec, 3600)  # 🆕 FIX 04/05: 60min min si jamais vert → distribution active
                tag = f" [dead-on-arrival max={max_pnl:+.2f}% → 60min min]"
                self.coin_block_no_early.discard(symbol)
            elif is_timing_loss and block_sec > 900:
                block_sec = block_sec // 2
                tag = f" [timing-loss max={max_pnl:+.2f}% → ½ durée]"
                self.coin_block_no_early.discard(symbol)
            else:
                tag = f" [max={max_pnl:+.2f}%]" if max_pnl is not None else ""
                self.coin_block_no_early.discard(symbol)
            self.coin_loss_blocked_until[symbol] = time.time() + block_sec
            # 🆕 22/04: Stocker le prix de sortie pour détecter la reprise de tendance
            if current_price := getattr(self, '_last_price', {}).get(symbol, 0):
                self.coin_exit_price[symbol] = current_price
            logger.info(f"   🔒 {symbol}: {n} perte(s) consécutive(s) → bloqué {block_sec//60} min{tag}")
            self._save_loss_state()
        elif pnl is not None and pnl > 0:
            self.coin_consec_losses[symbol] = 0
            self.coin_loss_blocked_until.pop(symbol, None)
            self.coin_exit_price.pop(symbol, None)   # 🆕 22/04
            self.coin_block_no_early.discard(symbol)  # 🆕 22/04
            self._save_loss_state()


# ═══════════════════════════════════════════════════════════════════════════════
# POSITION MANAGER - Gestion ultrarapide des positions spy
# ═══════════════════════════════════════════════════════════════════════════════

class SpyPositionManager:
    """
    Gère les positions spy avec monitoring serré (check à chaque scan ~12s).
    Stratégie: trailing stop très serré + vente rapide dès essoufflement.
    """
    
    def __init__(self, client):
        self.client = client
        self.positions = {}
        self.step_size_cache = {}
        self._load_positions()
        # 🔬 Logging d'exécution — mesure slippage/spread réels pour diagnostic prod
        if _EXEC_LOGGER_AVAILABLE:
            self.exec_logger = ExecutionLogger(log_dir="./exec_logs", binance_client=self.client)
        else:
            self.exec_logger = None
    
    def _load_positions(self):
        try:
            if os.path.exists(SPY_TRADES_FILE):
                with open(SPY_TRADES_FILE, 'r', encoding='utf-8') as f:
                    self.positions = json.load(f)
        except Exception:
            self.positions = {}
        # 🔧 FIX ORPHAN: Au redémarrage, nettoyer posit ions.json des entrées MARKET_SPY
        # qui ne correspondent pas aux positions actives dans espion_trades.json.
        # Cause de REZ "bot position active" : crash entre _remove_from_main() et la vente
        # réelle → positions.json restait avec REZ → spy bloquait tous les surges REZ après.
        self._cleanup_orphaned_spy_positions()

    def _cleanup_orphaned_spy_positions(self):
        """Supprime de positions.json les entrées MARKET_SPY absentes de espion_trades.json"""
        try:
            if not os.path.exists(POSITIONS_FILE):
                return
            with open(POSITIONS_FILE, 'r', encoding='utf-8') as f:
                main_positions = json.load(f)
            changed = False
            for symbol in list(main_positions.keys()):
                pos = main_positions[symbol]
                if isinstance(pos, dict) and pos.get('source') == 'MARKET_SPY':
                    if symbol not in self.positions:
                        del main_positions[symbol]
                        changed = True
                        logger.info(f"   🧹 Positions.json: {symbol} orphelin SPY supprimé")
            if changed:
                with open(POSITIONS_FILE, 'w', encoding='utf-8') as f:
                    json.dump(main_positions, f, indent=2, default=str)
        except Exception as e:
            logger.debug(f"Cleanup orphaned positions: {e}")

    def _save_positions(self):
        try:
            with open(SPY_TRADES_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.positions, f, indent=2, default=str)
        except Exception as e:
            logger.error(f"Erreur save: {e}")
    
    def _save_to_main(self, symbol, data):
        """Enregistre dans positions.json du bot principal"""
        try:
            positions = {}
            if os.path.exists(POSITIONS_FILE):
                with open(POSITIONS_FILE, 'r', encoding='utf-8') as f:
                    positions = json.load(f)
            positions[symbol] = {
                'entry_price': data['entry_price'],
                'quantity': data['quantity'],
                'stop_loss': data['stop_loss'],
                'take_profit': data['take_profit'],
                'side': 'BUY',
                'order_id': data.get('order_id'),
                'timestamp': data['timestamp'],
                'pattern': data.get('surge_type', 'SPY_PUMP_CATCH'),
                'source': 'MARKET_SPY',
                'max_price': data['entry_price'],
                'max_pnl': 0.0,
                'bb_breakout_entry': False
            }
            with open(POSITIONS_FILE, 'w', encoding='utf-8') as f:
                json.dump(positions, f, indent=2, default=str)
        except Exception as e:
            logger.error(f"Erreur save main: {e}")
    
    def _remove_from_main(self, symbol):
        try:
            if os.path.exists(POSITIONS_FILE):
                with open(POSITIONS_FILE, 'r', encoding='utf-8') as f:
                    positions = json.load(f)
                if symbol in positions:
                    del positions[symbol]
                    with open(POSITIONS_FILE, 'w', encoding='utf-8') as f:
                        json.dump(positions, f, indent=2, default=str)
        except Exception:
            pass
    
    # ─── CIRCUIT BREAKER ───────────────────────────────────────────────────────

    def _load_coin_scores(self):
        """Charge spy_coin_scores.json (crée si absent). Auto-nettoie les blocked_until expirés et l'historique obsolète."""
        try:
            if os.path.exists(SPY_COIN_SCORES_FILE):
                with open(SPY_COIN_SCORES_FILE, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                now = now_paris()
                changed = False
                for sym, entry in data.items():
                    # Nettoyage auto: réinitialiser blocked_until si la date est passée
                    bu = entry.get('blocked_until')
                    if bu:
                        try:
                            from dateutil.parser import parse as _dtparse
                            bt = _dtparse(str(bu))
                            if now >= bt:
                                entry['blocked_until'] = None
