#!/usr/bin/env python3
"""
═══════════════════════════════════════════════════════════════════════════════
🕵️ MARKET SPY v3 - Pump Catcher / Scalper ultrarapide
═══════════════════════════════════════════════════════════════════════════════

Stratégie: Détecter les hausses inattendues, significatives et éphémères,
acheter pendant la hausse et revendre dès les premiers signes de baisse.

PRINCIPE:
  - Scan ultra-rapide de TOUTES les paires USDT toutes les 10-12 secondes
  - Comparer le prix actuel vs prix du scan précédent → détecter les surges
  - Confirmer avec mini-analyse klines 1m (5 bougies = 5 minutes)
  - Acheter IMMÉDIATEMENT si surge confirmé
  - Monitoring serré des positions: trailing stop TRÈS serré (0.5%)
  - Revente automatique dès essoufflement (max ~10-15 min de holding)

EXEMPLE (KITE 08/02):
  19:32 → Détection surge +1.5% en 3 min avec volume x5
  19:32 → Achat immédiat @ 0.1395
  19:41 → Prix commence à baisser, trailing stop touché
  19:41 → Vente @ 0.1410 → +1.1% en 9 minutes

USAGE:
  python market_spy.py                 # Mode continu (défaut)
  python market_spy.py --dry-run       # Simulation sans achat
  python market_spy.py --once          # Un seul scan

═══════════════════════════════════════════════════════════════════════════════
"""

import os
import sys
import json
import time
import hmac
import hashlib
import argparse
import logging
import logging.handlers
import traceback
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
from collections import defaultdict
from zoneinfo import ZoneInfo

import requests
import numpy as np

# ─── Timezone Paris (gère automatiquement heure d'été/hiver) ───
PARIS_TZ = ZoneInfo('Europe/Paris')

def now_paris() -> datetime:
    """Retourne l'heure actuelle en timezone Paris."""
    return datetime.now(PARIS_TZ)

# ─── Module comportemental ───
try:
    from market_behavior import get_behavior_detector, BehaviorRegime
    _BEHAVIOR_AVAILABLE = True
except ImportError:
    _BEHAVIOR_AVAILABLE = False

# ─── Module ML Signal Classifier (TESTNET uniquement) ───
_ML_CLASSIFIER_AVAILABLE = False
_ml_classifier = None
try:
    sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'spy_optimizer'))
    from signal_classifier import SignalClassifier
    from feature_engineering import compute_features_at_timestamp
    import pandas as pd
    _ML_CLASSIFIER_AVAILABLE = True
except ImportError as _ml_err:
    pass  # Will log at startup if TESTNET_MODE

# ═══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════════════

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

sys.path.insert(0, SCRIPT_DIR)
try:
    from config import (
        BINANCE_API_KEY, BINANCE_API_SECRET, TESTNET_MODE,
        MAX_OPEN_POSITIONS, MIN_ORDER_SIZE, MAX_ORDER_SIZE,
    )
except ImportError:
    print("❌ Impossible de charger config.py")
    sys.exit(1)

# ─── URLs API ───
PRODUCTION_API = "https://api.binance.com"
TESTNET_API = "https://testnet.binance.vision"
TRADING_API = TESTNET_API if TESTNET_MODE else PRODUCTION_API
SCAN_API = PRODUCTION_API  # Scan TOUJOURS sur prod (testnet incomplet)

# ─── FILTRES DE SCAN ───
MIN_VOLUME_USDT = 800_000         # Volume 24h min (🔧 OPT 25/04: 1.5M→800K USDC — univers USDC limité (50 paires vs 241 USDT), capture les flash surges sur coins 0.5-1.5M USDC)
MAX_PRICE = 50_000                 # Prix max
MIN_PRICE = 0.0005                 # Prix min
EXCLUDE_STABLECOINS = True

# ─── DÉTECTION DE SURGE ───
SCAN_INTERVAL = 7                  # 🔧 OPT 31/03: 10→7s — détection BREAKOUT en 14s au lieu de 20s
SURGE_MIN_PRICE_CHANGE = 1.0      # 🔧 OPT 17/03: Hausse min +1.0% entre 2 scans (0.7→1.0 — élimine le bruit, achat-sommet)
SURGE_MIN_PRICE_CHANGE_2 = 1.1    # 🔧 TEST 17/04: Hausse min sur 2 scans (1.5→1.1 — test seuil abaissé pour capter plus de breakouts)
SURGE_MIN_VOLUME_RATIO = 3.0      # 🔧 OPT 17/03: Volume minute actuel vs moyenne ≥ 3.0x (2.0→3.0 — vraie pression)
SURGE_CONFIRM_KLINES = 5          # Vérifier 5 bougies 1m pour confirmer
# ─── TYPE 3: MOMENTUM_SURGE — hausse graduelle détectée sur fenêtre longue ───
# Cas STO: +12% en 30min sans aucune bougie à +1% → invisible pour FLASH/BREAKOUT
# Détection: compare prix actuel vs prix il y a ~2-3 min (20-30 scans)
MOMENTUM_MIN_CHANGE_20 = 4.0      # 🆕 04/04: Hausse min +4% sur ~2.5 min (20 scans × 7s) — conservateur pour éviter entrées tardives
MOMENTUM_MIN_CHANGE_40 = 6.0      # 🆕 04/04: Hausse min +6% sur ~5 min (40 scans × 7s)
MOMENTUM_MIN_CHANGE_1 = 0.3       # 🆕 04/04: Le dernier scan doit être encore positif (momentum vivant)
MOMENTUM_COOLDOWN = 600            # 🆕 04/04: Cooldown 10 min entre 2 MOMENTUM sur même coin
MOMENTUM_MAX_PER_HOUR = 3          # 🆕 04/04: Max 3 trades MOMENTUM par heure (limité pour protéger le PnL)
# 🆕 20/04: TYPE 5 — TREND_MOMENTUM: tendance soutenue sur 10-15min (cas GUN +13% en 50min)
# Comble le gap entre MOMENTUM (2.5min) et LONG_TREND (36min) — détecte les hausses graduelles
# Plus conservateur que LONG_TREND: seuils plus bas → entre plus tôt dans la tendance
TREND_MOMENTUM_MIN_CHANGE_85 = 2.5   # Hausse min +2.5% sur ~10 min (85 scans × 7s)
TREND_MOMENTUM_MIN_CHANGE_130 = 3.5  # Hausse min +3.5% sur ~15 min (130 scans × 7s)
TREND_MOMENTUM_MIN_CHANGE_1 = 0.15   # Dernier scan encore positif (tendance vivante)
TREND_MOMENTUM_MIN_CHANGE_5 = 0.4    # Hausse min +0.4% sur 5 derniers scans (~35s) — momentum court terme confirmé
TREND_MOMENTUM_COOLDOWN = 900        # 15 min cooldown par coin — pas de re-entrée rapide dans la même tendance
TREND_MOMENTUM_MAX_PER_HOUR = 2      # Max 2 TREND_MOMENTUM par heure globalement
# 🆕 FIX 11/04: TYPE 4 — LONG_TREND: tendances horaires graduelles (cas 币安人生 +32% sur 10h)
LONG_TREND_MIN_RISE = 10.0          # Hausse min +10% entre plus ancien et plus récent snapshot
LONG_TREND_SNAPSHOT_INTERVAL = 360  # Snapshot toutes les 360s (6min) par coin
LONG_TREND_MIN_SNAPSHOTS = 6        # Minimum 6 snapshots (~36min) avant détection
LONG_TREND_MAX_SNAPSHOTS = 12       # 12 × 6min = 72min d'historique max
LONG_TREND_COOLDOWN = 3600          # 1h cooldown par coin — pas de re-entrée dans la même tendance
LONG_TREND_MAX_PER_HOUR = 2         # Max 2 LONG_TREND par heure globalement
SURGE_MAX_ALREADY_PUMPED = 50.0   # 🔧 FIX 25/02: 30→50% pour ne pas bloquer les coins en forte tendance (ALLO, etc.)
SURGE_MAX_ALREADY_PUMPED_TRENDING = 40.0   # 🔧 FIX 14/03: Réduit 100→40% — coin déjà +40%/24h TRENDING = queue de pump
SURGE_MAX_ALREADY_PUMPED_EXTREME_FLASH = 120.0  # 🆕 16/04: FLASH ≥3%/scan + vol≥5x confirmé → breakout réel même coin fortement pumpé (cas ORDI +135%)
SURGE_MAX_DECLINE_24H = -15.0     # 🔧 FIX 31/03: Bloquer coins en chute >15%/24h — micro-rebonds dans downtrend = piège
# Coins interdits de FLASH_SURGE uniquement — pumps <10s, inversion garantie même sur gros breakout
FLASH_SURGE_BLACKLIST = {
    'NOMUSDC',         # 🔧 10/04: 3× INSTANT_REVERSAL/jour, pump <10s
    '0GUSDC',          # 🔧 15/04: 3T / 0% WR / -0.84%/trade — INSTANT_REVERSAL 100%, hold 0.2min
}
# Coins "fast spikers" — ont de vrais breakouts occasionnels mais génèrent des IR sur micro-spikes
# Solution: seuil d'entrée relevé (ne s'applique qu'à FLASH_SURGE)
# Si surge ≥ seuil OU Δ5 ≥ 2.0% (continuation forte) → achat autorisé
FLASH_SURGE_STRICT = {
    'ENJUSDC':         2.0,  # 🔧 15/04: 12T 17%WR mais +2.63%/+1.26% réels — seuil x2
    'GIGGLEUSDC':      2.5,  # 🔧 15/04: 12T 17%WR — spike très court, besoin d'impulsion forte
    'TSTUSDC':         2.5,  # 🔧 15/04: 7T 14%WR — même pattern
    'BROCCOLI714USDC': 2.0,  # 🔧 15/04: 7T 14%WR mais +3.49% réel — seuil x2
}
# 🔧 FIX 15/02: Lire positionSize depuis bot_settings.json (même valeur que le bot principal)
def _load_position_size():
    """Charge positionSize depuis bot_settings.json"""
    try:
        settings_path = os.path.join(SCRIPT_DIR, 'bot_settings.json')
        if os.path.exists(settings_path):
            with open(settings_path, 'r', encoding='utf-8') as f:
                settings = json.load(f)
            return float(settings.get('positionSize', MAX_ORDER_SIZE))
    except Exception:
        pass
    return float(MAX_ORDER_SIZE)

_BASE_POSITION_SIZE = _load_position_size()
SPY_POSITION_SIZE = _BASE_POSITION_SIZE            # Taille de base = positionSize du bot (ex: 500 USDT)
SPY_POSITION_SIZE_STRONG = _BASE_POSITION_SIZE      # Même taille pour surge fort (plus de distinction)
SPY_MAX_POSITIONS = 3              # Max 3 positions spy simultanées
SPY_MAX_TRADES_PER_HOUR = 6       # 🔧 FIX 14/02: Limiter les trades par heure (5→6)

# ─── EXIT STRATEGY (optimisée sur 160 trades réels - FIX 14/02) ───
# 🔧 Stratégie hybride v2: trailing PROGRESSIF + early SL + stagnation exit
# Basé sur analyse: trailing 0.3% trop serré (+19% gains perdus), trades >5min = négatifs
# PAS de Take Profit fixe = PAS de limite de gains
TRAILING_STOP_PCT = 0.3           # Trailing serré: -0.3% (petits gains < 2%)
TRAILING_STOP_WIDE = 1.5          # Trailing moyen: -1.5% (gains 2-5%)
TRAILING_STOP_LARGE = 2.5         # Trailing large: -2.5% (gains 5-10%)
TRAILING_STOP_ULTRA = 4.0         # Trailing ultra: -4.0% (gains > 10%)
TRAILING_ACTIVATION = 1.0         # 🔧 OPT 15/03: Activer le trailing après +1.0% de gain (was 0.5%) — laisser les winners courir davantage (+0.4% avg/trade)
HARD_STOP_LOSS_PCT = 1.2          # 🔧 OPT 15/03: Remonté 1.0→1.2% — donne plus de respiration (optimisé crash-test 30j)
MAX_BUY_SLIPPAGE_PCT = 0.5        # 🔧 FIX 11/04: Slippage max toléré entre prix détection et ask réel (ordre MARKET paie l'ask, pas le last)
EARLY_SL_PCT = 0.5                # 🔧 FIX 31/03: SL rapide à -0.5% (was -0.4%) — cohérent avec INSTANT_REVERSAL à -0.6%
EARLY_SL_DELAY = 60               # 🔧 OPT 15/03: 1 minute (was 2min) — faux signaux visibles encore plus vite (optimisé crash-test 30j)
EARLY_SL_MIN_PNL = 0.2            # ✂️ FIX 15/03: Si max_pnl < 0.2% après 2min → faux signal confirmé (was 0.3%)
MAX_HOLD_MINUTES = 15             # 🔧 OPT 15/03: Réduit 20→15min — les trades longs (>15min) ont avg P&L négatif (optimisé crash-test 30j)
STAGNATION_EXIT_MINUTES = 10      # 🆕 FIX 14/02: Si après 10min gain < 0.5% → sortir
MOMENTUM_EXIT_CANDLES = 3         # Vendre si 3 baisses consécutives (réactivité)
VOLUME_EXIT_SELL_RATIO = 0.38     # 🔧 FIX 29/03: Constante manquante — seuil vente rouge (< 38% acheteurs)

def get_dynamic_tp(surge_strength, vol_ratio=1.0):
    """TP de référence — plafond de sécurité max (sortie réelle = trailing stop).
    Stocké dans la position pour lisibilité des logs/dashboard.
    Le trailing stop active bien avant ce seuil dans la majorité des cas.
    """
    # Tiered par force du surge — scale réaliste 6-25%
    if surge_strength >= 4.0:
        base_tp = 25.0
    elif surge_strength >= 3.0:
        base_tp = 20.0
    elif surge_strength >= 2.0:
        base_tp = 15.0
    elif surge_strength >= 1.5:
        base_tp = 10.0
    elif surge_strength >= 1.0:
        base_tp = 8.0
    else:
        base_tp = 6.0
    # Boost léger si volume anormalement fort
    if vol_ratio >= 5.0:
        base_tp = min(base_tp * 1.3, 30.0)
    elif vol_ratio >= 3.0:
        base_tp = min(base_tp * 1.1, 25.0)
    return round(base_tp, 1)

def get_dynamic_trailing(surge_strength, current_pnl=0):
    """Trailing dynamique v2 — optimisé sur 160 trades réels.
    
    PROBLÈME v1: trailing 0.3% pour PnL < 2% trop serré → coupe les winners
    pendant les micro-dips normaux d'un pump (+19% gains laissés sur 30 trades).
    
    FIX 14/02: Trailing PROGRESSIF entre 0.5% et 2% de gains:
    - PnL ≥ 15% → trailing -5.0% (mouvement explosif type BERA +25%)
    - PnL ≥ 10% → trailing -4.0% (mouvement exceptionnel)
    - PnL ≥ 5%  → trailing -2.5% (gros surge confirmé)
    - PnL ≥ 3%  → trailing -1.8% (surge confirmé, laisser respirer)
    - PnL ≥ 2%  → trailing -1.5% (surge en cours)
    - PnL ≥ 1%  → trailing -0.7% (pump confirmé, micro-dips normaux)
    - PnL 0.5-1% → trailing -0.5% (en montée, pas trop serrer)
    - PnL < 0.5% → trailing -0.3% (juste activé, protéger)
    """
    if current_pnl >= 15.0:
        return 5.0                 # 5.0% - mouvement exceptionnel
    if current_pnl >= 10.0:
        return TRAILING_STOP_ULTRA # 4.0% - très large
    if current_pnl >= 5.0:
        return TRAILING_STOP_LARGE # 2.5% - large
    if current_pnl >= 3.0:
        return 2.0                 # 🔧 FIX 29/03: 1.8→2.0% — micro-dips normaux dans un surge confirmé
    if current_pnl >= 2.0:
        return 1.8                 # 🔧 FIX 29/03: 1.5→1.8% — laisser respirer les gains 2-3%
    if current_pnl >= 1.0:
        return 1.0                 # 🔧 FIX 29/03: 0.7→1.0% — pump confirmé, tolérer micro-dips
    if current_pnl >= 0.5:
        return 0.7                 # 🔧 FIX 29/03: 0.5→0.7% — en montée, un peu plus de marge
    return TRAILING_STOP_PCT       # 0.3% - juste activé, protéger


def _detect_uptrend(pos):
    """Détecte si le prix est en tendance haussière à partir du mini-historique.
    
    Retourne True si le prix fait des higher-lows (tendance soutenue).
    Utilisé pour élargir les trailing et désactiver le MOMENTUM_EXIT.
    
    Logique:
    - Besoin d'au moins 5 points de données (50s de scan)
    - Calcule la moyenne de la 1ère moitié vs la 2ème moitié
    - Si la 2ème moitié est > 1ère moitié ET le prix actuel > prix min récent + 0.3%
      → tendance haussière confirmée
    """
    last_prices = pos.get('last_prices', [])
    if len(last_prices) < 5:
        return False
    half = len(last_prices) // 2
    avg_old = sum(last_prices[:half]) / half
    avg_new = sum(last_prices[half:]) / (len(last_prices) - half)
    min_recent = min(last_prices[-3:])
    entry_price = pos.get('entry_price', last_prices[0])
    # Tendance haussière: moyenne récente > ancienne ET prix au-dessus de l'entrée
    return avg_new > avg_old * 1.001 and min_recent > entry_price * 0.998

# ─── FICHIERS ───
POSITIONS_FILE = os.path.join(SCRIPT_DIR, "positions.json")
WATCHLIST_FILE = os.path.join(SCRIPT_DIR, "watchlist.json")
SPY_TRADES_FILE = os.path.join(SCRIPT_DIR, "espion_trades.json")
SPY_LOG_FILE = os.path.join(SCRIPT_DIR, "espion_opportunities.json")
SPY_HISTORY_FILE = os.path.join(SCRIPT_DIR, "espion_history.json")
SPY_STATUS_FILE = os.path.join(SCRIPT_DIR, "spy_status.json")
SPY_COIN_SCORES_FILE = os.path.join(SCRIPT_DIR, "spy_coin_scores.json")
SPY_LOSS_STATE_FILE  = os.path.join(SCRIPT_DIR, "spy_loss_state.json")   # 🔧 OPT 18/03: Persistance compteur pertes

# Circuit breaker : bloquer un coin après N pertes consécutives pendant X heures
# 🔧 16/04 refonte: binaire trop dur → durée en heures, auto-reset historique > 24h
SPY_CB_MAX_CONSEC_LOSSES = 4    # Pertes consécutives avant blocage (15/04: 7→3, 16/04: 3→4 — 4 réellement significatif)
SPY_CB_BLOCK_HOURS = 12         # Durée de blocage en HEURES (remplacement SPY_CB_BLOCK_DAYS: 2j trop long)

STABLECOINS = {"BUSDUSDC", "TUSDUSDC", "USDTUSDC", "DAIUSDC", "FDUSDUSDC",
               "EURUSDC", "GBPUSDC", "USDPUSDC", "USTCUSDC"}

# ═══════════════════════════════════════════════════════════════════════════════
# CONTEXTE MARCHÉ — Lecture du régime macro pour adapter les seuils
# ═══════════════════════════════════════════════════════════════════════════════

def get_btc_market_context():
    """
    Lit le contexte macro (BTC momentum, % alts haussiers) depuis market_regime.
    Retourne un dict utilisé pour adapter les seuils de confirmation et de sortie.

    Clés retournées:
      regime           : BULL_STRONG / BULL_WEAK / NEUTRAL / CORRECTION / BEAR
      btc_mom_3h       : momentum BTC sur 3h (%)
      btc_mom_5h       : momentum BTC sur 5h (%)
      bullish_pct      : % altcoins en tendance haussière (0-100)
      is_recovery_window : BTC 5h > 0.1% ET 3h > -1.0% → rebond en cours
                           → assouplir les seuils pour capturer les premiers movers
      is_freefall      : BTC 3h < -2% ET alts bullish < 30% → chute libre
                           → bloquer les entrées (sauf FLASH fort >= 1.5%)
    """
    ctx = {
        'regime': 'NEUTRAL',
        'btc_mom_3h': 0.0,
        'btc_mom_5h': 0.0,
        'bullish_pct': 50.0,
        'is_recovery_window': False,
        'is_freefall': False,
    }
    try:
        from market_regime import get_market_regime_detector
        detector = get_market_regime_detector()
        if detector:
            regime_name, metrics = detector.detect_regime()
            ctx['regime'] = regime_name
            btc = metrics.get('btc', {})
            alts = metrics.get('alts', {})
            ctx['btc_mom_3h'] = btc.get('mom_3h', 0.0)
            ctx['btc_mom_5h'] = btc.get('mom_5h', 0.0)
            ctx['bullish_pct'] = alts.get('bullish_pct', 50.0)
            # "Fenêtre de rebond": BTC 5h positif + 3h pas en chute libre
            # → retournement en cours, capturer les premiers movers plus tôt
            ctx['is_recovery_window'] = (
                ctx['btc_mom_5h'] > 0.1
                and ctx['btc_mom_3h'] > -1.0
                and ctx['bullish_pct'] > 30.0
            )
            # "Chute libre": BTC perd > 2% en 3h ET alts majoritairement bearish
            # → pumps éphémères qui s'inversent rapidement → bloquer
            ctx['is_freefall'] = (
                ctx['btc_mom_3h'] < -2.0
                and ctx['bullish_pct'] < 30.0
            )
    except Exception:
        pass
    return ctx

# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING
# ═══════════════════════════════════════════════════════════════════════════════

class ParisTimeFormatter(logging.Formatter):
    """Formateur qui affiche l'heure de Paris (UTC+1/+2 selon saison)."""
    def formatTime(self, record, datefmt=None):
        ct = datetime.fromtimestamp(record.created, tz=PARIS_TZ)
        if datefmt:
            return ct.strftime(datefmt)
        return ct.strftime('%H:%M:%S')

# 🔴 FIX 01/04: Logger dédié avec propagation désactivée
# Avant: basicConfig + logger enfant → double écriture de chaque ligne
logger = logging.getLogger("market_spy")
logger.setLevel(logging.INFO)
logger.propagate = False  # Empêcher la propagation vers le root logger

# Handler console (stdout)
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(ParisTimeFormatter('%(asctime)s [SPY] %(message)s', datefmt='%H:%M:%S'))
logger.addHandler(_console_handler)

# Handler fichier (WatchedFileHandler survit aux logrotate)
file_handler = logging.handlers.WatchedFileHandler(
    os.path.join(SCRIPT_DIR, "market_spy.log"), encoding='utf-8'
)
file_handler.setFormatter(ParisTimeFormatter('%(asctime)s [SPY] %(message)s', datefmt='%H:%M:%S'))
logger.addHandler(file_handler)


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

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

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

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


class SpyApiClient:
    """Client API ultraléger pour le scanner"""
    
    TIME_OFFSET = 0
    _last_sync = 0
    # Symboles invalides sur cet exchange/testnet — auto-blacklistés après erreur -1121
    # 🔧 FIX 13/04: Chargés depuis fichier persistant au démarrage
    _invalid_symbols: set = _load_invalid_symbols_cache()
    
    def __init__(self):
        self.api_key = BINANCE_API_KEY
        self.api_secret = BINANCE_API_SECRET
        self.session = requests.Session()
        self.session.headers.update({"X-MBX-APIKEY": self.api_key})
        # 🔧 OPT 10/04: Pool de connexions persistant — évite la renégociation TLS à chaque requête
        _adapter = requests.adapters.HTTPAdapter(
            pool_connections=2, pool_maxsize=8, max_retries=0
        )
        self.session.mount("https://", _adapter)
        self._sync_server_time()
    
    def _sync_server_time(self):
        try:
            resp = self.session.get(f"{TRADING_API}/api/v3/time", timeout=5)
            server_time = resp.json()['serverTime']
            local_time = int(time.time() * 1000)
            SpyApiClient.TIME_OFFSET = server_time - local_time
            SpyApiClient._last_sync = time.time()
        except Exception:
            SpyApiClient.TIME_OFFSET = 0
    
    def _sign(self, params):
        query_string = urlencode(params)
        signature = hmac.new(
            self.api_secret.encode('utf-8'),
            query_string.encode('utf-8'),
            hashlib.sha256
        ).hexdigest()
        return signature
    
    def public_get(self, url, params=None):
        try:
            resp = self.session.get(url, params=params, timeout=10)
            data = resp.json()
            if isinstance(data, dict) and data.get('code', 0) < 0:
                return None
            return data
        except Exception as e:
            logger.error(f"API error: {e}")
            return None
    
    def signed_request(self, method, endpoint, params=None):
        if params is None:
            params = {}
        if time.time() - SpyApiClient._last_sync > 300:
            self._sync_server_time()
        params['timestamp'] = int(time.time() * 1000) + SpyApiClient.TIME_OFFSET
        params['recvWindow'] = 10000
        params['signature'] = self._sign(params)
        url = f"{TRADING_API}{endpoint}"
        try:
            if method == "POST":
                resp = self.session.post(url, params=params, timeout=15)
            else:
                resp = self.session.get(url, params=params, timeout=15)
            data = resp.json()
            # Retry sur erreur timestamp
            if isinstance(data, dict) and data.get('code') in (-1021, -1022):
                self._sync_server_time()
                params['timestamp'] = int(time.time() * 1000) + SpyApiClient.TIME_OFFSET
                params.pop('signature', None)
                params['signature'] = self._sign(params)
                if method == "POST":
                    resp = self.session.post(url, params=params, timeout=15)
                else:
                    resp = self.session.get(url, params=params, timeout=15)
                data = resp.json()
            if isinstance(data, dict) and data.get('code', 0) < 0:
                error_code = data.get('code', 0)
                error_msg = data.get('msg', 'unknown')
                # 🔧 FIX 25/02: Identifier clairement les erreurs testnet (paire absente)
                if error_code == -1121:
                    # Extraire le symbole depuis params pour l'auto-blacklister
                    _bad_sym = params.get('symbol', '')
                    if _bad_sym and _bad_sym not in SpyApiClient._invalid_symbols:
                        SpyApiClient._invalid_symbols.add(_bad_sym)
                        _save_invalid_symbols_cache(SpyApiClient._invalid_symbols)  # 🔧 FIX 13/04: persister
                        logger.warning(f"⛔ {_bad_sym} ajouté à la blacklist invalide — paire absente du {'testnet' if TESTNET_MODE else 'prod'} (plus jamais tenté)")
                    logger.error(f"API error: symbole invalide sur {'testnet' if TESTNET_MODE else 'prod'} — {error_msg} (cette paire n'existe probablement pas sur le testnet)")
                else:
                    logger.error(f"API error [{error_code}]: {error_msg}")
                return None
            return data
        except Exception as e:
            logger.error(f"Signed request error: {e}")
            return None
    
    def get_balance(self, asset="USDT"):
        account = self.signed_request("GET", "/api/v3/account")
        if account:
            for b in account.get('balances', []):
                if b['asset'] == asset:
                    return float(b['free'])
        return 0
    
    def market_buy(self, symbol, usdt_amount):
        params = {
            "symbol": symbol,
            "side": "BUY",
            "type": "MARKET",
            "quoteOrderQty": f"{usdt_amount:.2f}"
        }
        return self.signed_request("POST", "/api/v3/order", params)
    
    def market_sell(self, symbol, quantity, step_size=None):
        """Vente market avec quantité exacte.
        🔧 FIX -2010: Binance déduit les frais (0.1%) du token reçu à l'achat,
        donc le solde réel est < executedQty stocké. On vérifie le vrai solde.
        """
        # Extraire le nom de l'asset (AIXBTUSDT → AIXBT, BTCUSDT → BTC)
        for quote in ('USDC', 'USDT', 'BUSD', 'BTC', 'ETH', 'BNB'):
            if symbol.endswith(quote):
                asset = symbol[:-len(quote)]
                break
        else:
            asset = symbol

        # Récupérer le vrai solde disponible et l'utiliser si inférieur
        try:
            real_balance = self.get_balance(asset)
            if real_balance > 0 and real_balance < quantity:
                logger.debug(f"market_sell: {asset} solde réel {real_balance} < qty stockée {quantity} → ajustement (frais)")
                quantity = real_balance
        except Exception:
            pass  # Fallback sur la quantité stockée

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

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

        params = {
            "symbol": symbol,
            "side": "SELL",
            "type": "MARKET",
            "quantity": f"{quantity}"
        }
        return self.signed_request("POST", "/api/v3/order", params)
    
    def get_symbol_step_size(self, symbol):
        """Récupère step_size du symbole pour la précision de vente"""
        data = self.public_get(f"{TRADING_API}/api/v3/exchangeInfo", {"symbol": symbol})
        if data and 'symbols' in data and len(data['symbols']) > 0:
            sym_info = data['symbols'][0]
            for f in sym_info.get('filters', []):
                if f['filterType'] == 'LOT_SIZE':
                    return float(f['stepSize'])
        return None


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

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

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

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

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

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

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

            # TYPE 3 — MOMENTUM: hausse graduelle sur ~2-5 min sans pic individuel
            # Cas STO 04/04: +12% en 30min, aucune bougie ≥1% → invisible pour FLASH/BREAKOUT
            # Condition: hausse cumulée significative + dernier scan encore positif (momentum vivant)
            # Sécurité: vérifier que la hausse est RÉGULIÈRE (pas un V-bounce volatil)
            if not is_surge and len(history) >= 21:
                # Variation sur ~2.5 min (20 scans)
                change_20 = ((price - history[-21][1]) / history[-21][1]) * 100
                # Variation sur ~5 min (40 scans) si assez d'historique
                change_40 = 0
                if len(history) >= 41:
                    change_40 = ((price - history[-41][1]) / history[-41][1]) * 100
                # Cooldown spécifique MOMENTUM par coin (éviter les signaux répétés)
                _mom_cd_key = f"_momentum_{symbol}"
                _mom_last = self.cooldown.get(_mom_cd_key, 0)
                _mom_cd_ok = (now - _mom_last) >= MOMENTUM_COOLDOWN
                # Limite horaire globale
                if not hasattr(self, '_momentum_trades_hour'):
                    self._momentum_trades_hour = []
                self._momentum_trades_hour = [t for t in self._momentum_trades_hour if now - t < 3600]
                _mom_hourly_ok = len(self._momentum_trades_hour) < MOMENTUM_MAX_PER_HOUR
                if _mom_cd_ok and _mom_hourly_ok and change_1 >= MOMENTUM_MIN_CHANGE_1:
                    _mom_detected = False
                    _mom_strength = 0
                    if change_40 >= MOMENTUM_MIN_CHANGE_40:
                        _mom_detected = True
                        _mom_strength = change_40
                    elif change_20 >= MOMENTUM_MIN_CHANGE_20:
                        _mom_detected = True
                        _mom_strength = change_20
                    # Vérification MONOTONICITÉ: la hausse doit être régulière
                    # Diviser la fenêtre en 4 quartiles, chacun doit être positif
                    # Empêche les V-bounces (chute puis remontée brutale)
                    if _mom_detected:
                        _win = 20 if _mom_strength == change_20 else 40
                        _quarter = max(_win // 4, 1)
                        _rising_quarters = 0
                        for _q in range(4):
                            _idx_start = -(_win + 1) + _q * _quarter
                            _idx_end = _idx_start + _quarter
                            if _idx_end >= 0:
                                _idx_end = -1
                            if abs(_idx_start) <= len(history) and abs(_idx_end) <= len(history):
                                _p_start = history[_idx_start][1]
                                _p_end = history[_idx_end][1]
                                if _p_end > _p_start:
                                    _rising_quarters += 1
                        # Exiger au moins 3/4 quartiles en hausse
                        if _rising_quarters >= 3:
                            is_surge = True
                            surge_type = "MOMENTUM_SURGE"
                            surge_strength = _mom_strength
                            self.cooldown[_mom_cd_key] = now
                            self._momentum_trades_hour.append(now)

            # TYPE 5 — TREND_MOMENTUM: hausse soutenue sur 10-15min ≥ +2.5-3.5%
            # 🆕 20/04: Comble le gap entre MOMENTUM (2.5min) et LONG_TREND (36min)
            # Cas GUN 20/04: +13% en 50min, aucun scan ≥1% → invisible pour FLASH/BREAKOUT/MOMENTUM
            # Détecte les tendances intermédiaires avec confirmation de régularité + momentum court-terme
            if not is_surge and len(history) >= 86:
                # Variation sur ~10 min (85 scans)
                change_85 = ((price - history[-86][1]) / history[-86][1]) * 100
                # Variation sur ~15 min (130 scans) si assez d'historique
                change_130 = 0
                if len(history) >= 131:
                    change_130 = ((price - history[-131][1]) / history[-131][1]) * 100
                # Cooldown spécifique TREND_MOMENTUM par coin
                _tm_cd_key = f"_trend_mom_{symbol}"
                _tm_last = self.cooldown.get(_tm_cd_key, 0)
                _tm_cd_ok = (now - _tm_last) >= TREND_MOMENTUM_COOLDOWN
                # Limite horaire globale
                if not hasattr(self, '_trend_momentum_trades_hour'):
                    self._trend_momentum_trades_hour = []
                self._trend_momentum_trades_hour = [t for t in self._trend_momentum_trades_hour if now - t < 3600]
                _tm_hourly_ok = len(self._trend_momentum_trades_hour) < TREND_MOMENTUM_MAX_PER_HOUR
                if _tm_cd_ok and _tm_hourly_ok and change_1 >= TREND_MOMENTUM_MIN_CHANGE_1 and change_5 >= TREND_MOMENTUM_MIN_CHANGE_5:
                    _tm_detected = False
                    _tm_strength = 0
                    _tm_win = 0
                    if change_130 >= TREND_MOMENTUM_MIN_CHANGE_130 and len(history) >= 131:
                        _tm_detected = True
                        _tm_strength = change_130
                        _tm_win = 130
                    elif change_85 >= TREND_MOMENTUM_MIN_CHANGE_85:
                        _tm_detected = True
                        _tm_strength = change_85
                        _tm_win = 85
                    # Vérification MONOTONICITÉ: la hausse doit être régulière (pas un V-bounce)
                    if _tm_detected:
                        _quarter = max(_tm_win // 4, 1)
                        _rising_quarters = 0
                        for _q in range(4):
                            _idx_start = -(_tm_win + 1) + _q * _quarter
                            _idx_end = _idx_start + _quarter
                            if _idx_end >= 0:
                                _idx_end = -1
                            if abs(_idx_start) <= len(history) and abs(_idx_end) <= len(history):
                                _p_start = history[_idx_start][1]
                                _p_end = history[_idx_end][1]
                                if _p_end > _p_start:
                                    _rising_quarters += 1
                        # Exiger au moins 3/4 quartiles en hausse
                        if _rising_quarters >= 3:
                            is_surge = True
                            surge_type = "TREND_MOMENTUM_SURGE"
                            surge_strength = _tm_strength
                            self.cooldown[_tm_cd_key] = now
                            self._trend_momentum_trades_hour.append(now)

            # TYPE 4 — LONG_TREND: hausse graduelle sur 36-72min ≥ +10%
            # 🔴 DÉSACTIVÉ 11/04: crash-test 10j montre WR=11%, -4.55$ net sur 38 trades
            # Problème fondamental: entrer après +10% = acheter un sommet → retournement 1min
            # Ré-activer quand une entrée sur pullback (repli -2% dans tendance) sera implémentée
            if False and not is_surge:
                _lt_snaps = self._lt_snapshots[symbol]
                if len(_lt_snaps) >= LONG_TREND_MIN_SNAPSHOTS:
                    _lt_oldest = _lt_snaps[0]
                    if _lt_oldest > 0:
                        _lt_rise = (price - _lt_oldest) / _lt_oldest * 100
                        # Conditions: hausse ≥ 10%, dernier scan encore positif (+0.1%), tendance monotone
                        if _lt_rise >= LONG_TREND_MIN_RISE and change_1 >= 0.1:
                            _lt_pos = sum(1 for _i in range(1, len(_lt_snaps)) if _lt_snaps[_i] > _lt_snaps[_i-1])
                            _lt_monotone = _lt_pos >= max(1, len(_lt_snaps) - 1) * 0.6
                            if _lt_monotone:
                                self._lt_trades_hour = [_t for _t in self._lt_trades_hour if now - _t < 3600]
                                if len(self._lt_trades_hour) < LONG_TREND_MAX_PER_HOUR:
                                    _lt_cd_key = f"_lt_{symbol}"
                                    if now - self.cooldown.get(_lt_cd_key, 0) >= LONG_TREND_COOLDOWN:
                                        is_surge = True
                                        surge_type = "LONG_TREND_SURGE"
                                        surge_strength = round(_lt_rise, 2)
                                        self.cooldown[_lt_cd_key] = now
                                        self._lt_trades_hour.append(now)

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

        # 🆕 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 = (ema9_val - _ema7_prev) / _ema7_prev * 100 if _ema7_prev > 0 else 0.0
        else:
            ema7_slope_pct = 0.0
        ema7_declining = ema7_slope_pct < -0.15  # Déclin > -0.15% sur 3 bougies = tendance baissière réelle

        rsi_now = _rsi(closes)
        
        # 15-minute context: momentum + % bougies rouges
        mom_15m = ((closes[-1] - closes[0]) / closes[0]) * 100 if len(closes) >= 15 else 0
        red_bars_15 = sum(1 for i in range(1, min(len(closes), 16)) 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
        
        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,
        }
        
        # 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)
        is_trending = surge_data.get('surge_type') in ('TRENDING_SURGE', 'SLOW_PUMP', 'LONG_TREND_SURGE', 'TREND_MOMENTUM_SURGE') and not ema_bearish_now  # EMA9>EMA21 = tendance confirmée
        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 = surge_data.get('surge_type') == 'MOMENTUM_SURGE'  # 🆕 04/04: Hausse graduelle détectée sur 2-5 min
        is_trend_momentum = surge_data.get('surge_type') == 'TREND_MOMENTUM_SURGE'  # 🆕 20/04: Tendance soutenue 10-15 min

        # 🆕 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 and not is_momentum and not is_trend_momentum:
            # 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:
            vol_threshold = 0.8  # 🔧 TEST 31/03: 1.0→0.8 — FLASH fort = le surge EST la preuve, volume partiel OK
        elif is_momentum:
            vol_threshold = 0.8  # 🆕 04/04: MOMENTUM = hausse graduelle, volume distribué sur plusieurs bougies → ratio naturellement bas
        elif is_trend_momentum:
            vol_threshold = 0.6  # 🆕 20/04: TREND_MOMENTUM = tendance 10-15min, volume très distribué → ratio encore plus bas
        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)")
        
        # 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 or is_momentum) else 2)  # 🔧 FIX 14/03: extreme flash = kline partielle | 🆕 04/04: momentum = hausse graduelle, 1 bougie verte suffit
        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 and not is_momentum:
                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_momentum:
            # 🆕 04/04: MOMENTUM = hausse graduelle soutenue, le momentum 3m est la PREUVE du signal
            # Seuil bas: 0.1% suffit (la montée est distribuée sur plusieurs minutes)
            if mom_3m < 0.1:
                confirmed = False
                reasons.append(f"mom_weak_momentum({mom_3m:.2f}%<0.1%)")
            # Sécurité: EMA bearish + RSI élevé = fin de course, pas un bon entry
            elif ema_bearish_now and rsi_now > 70:
                confirmed = False
                reasons.append(f"momentum_exhausted(EMA_bear+RSI={rsi_now:.0f}>70)")
        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 and not is_momentum:
            # 🔧 FIX: TRENDING_SURGE/MOMENTUM 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 < 35% 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}%<35%,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

        # 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% peut être un vrai renversement même en zone oversold
        if rsi_now <= 35 and ema_bearish_now and not is_extreme_flash:
            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_now 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
        # Ne bloque pas FLASH_SURGE fort (≥1.5%) ni EXTREME: ces signaux sont leur propre preuve
        elif ema7_declining and not is_extreme_flash and not (is_strong_flash and surge_strength >= 1.5):
            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 15/04 (révisé): FLASH_SURGE modéré en plein contexte EMA baissier
        # = potentiel dead cat bounce — SAUF si le momentum 5min (Δ5) est fort.
        # BIO 11:51: Δ5=+3.96% → vraie reprise, pas dead cat. INSTANT_REVERSAL était le vrai problème.
        # Règle: bloquer uniquement si EMA bearish + Δ5 faible (< 2.0%) + surge < 2.5%
        # Si Δ5 ≥ 2.0% : la tendance 5min confirme la reprise malgré EMA7 < EMA25
        elif ema_bearish_now and is_strong_flash and surge_strength < 2.5 and not is_extreme_flash:
            _c5 = surge_data.get('change_5scan', 0)
            if _c5 < 2.0:
                confirmed = False
                reasons.append(f"flash_ema_bearish(EMA7<EMA25,surge={surge_strength:.2f}%,Δ5={_c5:.2f}%<2.0%)")
                logger.info(f"   🚫 FLASH EMA BEARISH: surge={surge_strength:.2f}% EMA7<EMA25 "
                            f"Δ5={_c5:.2f}%<2.0% → dead cat probable")
            else:
                logger.info(f"   ✅ FLASH EMA BEARISH: surge={surge_strength:.2f}% EMA7<EMA25 "
                            f"mais Δ5={_c5:.2f}%≥2.0% → vraie reprise, achat autorisé")
        
        details['confirmed'] = confirmed
        details['rejection_reasons'] = reasons
        details['market_label'] = mkt_label

        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):
        """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)."""
        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]
            if 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)
            is_timing_loss = (max_pnl is not None and max_pnl < 0.25) and not is_instant_reversal
            if 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_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()
    
    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
                                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 USDT réel sur Binance.
        # Ratio = positionSize / capital_initial. Ex: 900/16000 = 5.625%
        # 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
        _capital_ratio = 0.05625           # 900/16000 = 5.625% du capital par trade
        _current_balance = 0  # 🔧 OPT 10/04: Initialisé ici pour réutilisation hors try
        try:
            _current_balance = self.client.get_balance("USDC")
            _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} USDC → position={position_size:.0f} USDC (base={_base_size:.0f})")
        except Exception:
            position_size = _base_size  # Fallback sur taille fixe

        # 🧠 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
                if buy_spike < 2.0:
                    logger.info(f"   🚫 BEAR régime: FLASH {surge_strength:.2f}% bloqué (buy_spike {buy_spike:.1f}x < 2.0x 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
        logger.info(f"   📊 Régime: {regime_label} → Position: {position_size:.0f} USDT")

        # 🔧 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("USDC")
        if balance < position_size + 5:
            logger.warning(f"   ⚠️ Solde insuffisant ({balance:.2f} USDC)")
            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
                if _retrace_live > 0.25:  # 🔧 OPT 18/03: 0.4 → 0.25% — coupe les mèches plus tôt (max=+0.00%)
                    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)"
                    )
                    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.
        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))
                    if ask_price > 0:
                        slippage_pct = (ask_price - surge_data['price']) / surge_data['price'] * 100
                        if slippage_pct > MAX_BUY_SLIPPAGE_PCT:
                            logger.info(
                                f"   ⚠️ {symbol}: slippage ask trop élevé ({slippage_pct:.2f}% > {MAX_BUY_SLIPPAGE_PCT}%) "
                                f"→ SKIP (ask={ask_price:.6f} vs détection={surge_data['price']:.6f})"
                            )
                            return False
            except Exception:
                pass  # Fallback: continuer si bookTicker indisponible

        # �🔵 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
            
            # 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 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),
            }
            
            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
            
            # 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', {})
            _regime = _mctx.get('regime', 'NEUTRAL')
            _stagnation_minutes = (
                6  if _mctx.get('is_freefall') else
                8  if _regime in ('BEAR', 'CORRECTION') else
                13 if _mctx.get('is_recovery_window') or _regime in ('BULL_STRONG', 'BULL_WEAK') else
                STAGNATION_EXIT_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 bearish (< EMA25) ET pente négative
            #     → signal de vente PRIMAIRE (RÈGLE 0d)
            # 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
                        pos['_ema7_bd_live']     = (_e7 < _e25) and (_slope < -0.10)  # bearish + declining
                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)       # bearish + declining = 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)

            # ═══ 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)
            _ir_threshold = -1.0 if (_delta5_pos >= 2.0 and hold_seconds < 20) else -0.6
            if 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 0b: EARLY SL — faux signal détecté rapidement ═══
            # 🔧 FIX 29/03: EARLY_SL ADAPTATIF selon force du surge
            # Problème 0G: surge +1.0%, EARLY_SL fixe -0.5% → vendu -0.79% après 1min,
            # puis prix remonte. Un -0.79% sur un coin à +1% = volatilité normale.
            # Fix: proportionner le SL à la force du surge + check recovery momentum
            elif hold_seconds >= EARLY_SL_DELAY and pos['max_pnl'] < EARLY_SL_MIN_PNL:
                _surge_str = pos.get('surge_strength', 1.0)
                # SL adaptatif: max(base, 80% du surge), plafonné à 90% du hard SL
                _effective_early_sl = max(EARLY_SL_PCT, _surge_str * 0.8)
                _effective_early_sl = min(_effective_early_sl, HARD_STOP_LOSS_PCT * 0.9)
                # Recovery check: si les 3 derniers ticks montrent une remontée, patienter
                _last_p = pos.get('last_prices', [])
                _recovering = len(_last_p) >= 3 and _last_p[-1] > _last_p[-2] > _last_p[-3]
                # 🆕 FIX 15/04: EMA7 protection — si EMA7 montait à l'entrée ET live pas encore
                # calculé (< 90s), donner le bénéfice du doute. Si live confirme hausse → protéger.
                # Ex: ENJ 2h05 — EMA7 clairement haussier, EARLY_SL vendu à -1.4% à tort.
                _ema7_entry_slope = pos.get('ema7_slope_at_entry', 0)
                _ema7_protect = _ema7_rising or (_ema7_rising is None and _ema7_entry_slope > 0.05)
                if pnl_pct < -_effective_early_sl and not _recovering and not _ema7_protect:
                    sell_reason = f"EARLY_SL (max={pos['max_pnl']:+.2f}%, {pnl_pct:+.2f}% after {hold_minutes:.0f}min, SL=-{_effective_early_sl:.1f}%)"

            # ═══ RÈGLE 0c: EARLY_FLAT — surge sans aucun momentum après 90s ═══
            # Pattern identifié (COS 25/03): coin thin-liquidity où le spike est
            # un artefact de carnet d'ordres, pas un vrai momentum.
            # Si après 90s max_pnl < 0.05% → le surge était du bruit, sortir.
            # 🔧 FIX 31/03: Seuil 0.10% → 0.05% — les micro-caps oscillent ±0.10%
            # naturellement, seuls les trades vraiment DEAD (max < 0.05%) sont coupés.
            # Distinct de EARLY_SL (qui cible les -0.4%) et STAGNATION (10min).
            elif 90 <= hold_seconds <= 240 and pos['max_pnl'] < 0.05:
                sell_reason = f"EARLY_FLAT (aucun momentum après {hold_seconds:.0f}s, max={pos['max_pnl']:+.2f}%, now={pnl_pct:+.2f}%)"  # 🆕 25/03

            # ═══ RÈGLE 0d: EMA7_DOWNTREND — tendance baissière EMA7 confirmée (PRIMAIRE) ══
            # EMA7 est le SIGNAL PRINCIPAL de sortie après l'achat.
            # Condition: EMA7 < EMA25 ET pente EMA7 < -0.10%/3 bougies = baisse justifiée.
            # 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)
                sell_reason = (
                    f"EMA7_DOWNTREND (EMA7={_e7:.6f}<EMA25={_e25:.6f}, "
                    f"slope={_slp:+.3f}%, pnl={pnl_pct:+.2f}%)"
                )
                logger.info(f"   📉 EMA7 DOWNTREND {symbol}: baisse confirmée "
                            f"(EMA7<EMA25, slope={_slp:+.3f}%) → vente primaire "
                            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.
            elif pos.get('trailing_stop') and price <= pos['trailing_stop'] and pnl_pct > 0:
                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 (remplace l'ancien TP fixe) ═══
            # Le trailing s'élargit avec les gains: +3% → trail 1%, +10% → trail 4%, +20% → trail 5%
            # PAS DE PLAFOND = les gains peuvent être illimités!
            elif pnl_pct >= TRAILING_ACTIVATION:
                surge_str = pos.get('surge_strength', 20)
                trail_pct = get_dynamic_trailing(surge_str, pnl_pct)
                # 🔧 FIX 14/03: Serrer le trailing si RSI saturé à l'entrée (>= 85)
                rsi_entry = pos.get('rsi_at_entry', 50)
                if rsi_entry >= 85 and 1.5 <= pnl_pct < 5.0:
                    trail_pct = min(trail_pct, 1.0)
                # 🆕 FIX 15/04: EMA7 EN HAUSSE → trailing large, sortie primaire = EMA7_DOWNTREND
                # Quand EMA7 monte: laisser courir la position jusqu'à retournement EMA7.
                # Le trailing est un filet de sécurité large, pas une cible de sortie.
                # Bypass du ratchet: TS toujours recalculé depuis max_price courant (pas verrouillé).
                is_uptrend = _detect_uptrend(pos)
                # 🔧 FIX 20/04: Seuil abaissé de 2.0% à TRAILING_ACTIVATION (1.0%)
                # Bug GUN: à +2.74% EMA7 protège (trail 2.52%), mais quand PnL retombe à +1.71% (<2.0%)
                # la protection se désactive, le trail se resserre à 1.0% → vente prématurée
                # alors que EMA7 monte encore. Fix: protéger dès le seuil d'activation trailing.
                _ema7_trail_active = _ema7_rising and pnl_pct >= TRAILING_ACTIVATION
                if _ema7_trail_active:
                    _ema7_slp = pos.get('_ema7_slope', 0)
                    _ema7_mult = 1.6 if _ema7_slp >= 0.3 else 1.4  # Forte pente EMA7 → plus de marge
                    trail_pct = trail_pct * _ema7_mult
                    trailing_sl = pos['max_price'] * (1 - trail_pct / 100)
                    # Bypass ratchet: stocker le TS large pour que Rule 1b l'utilise aussi
                    pos['trailing_stop'] = trailing_sl
                    pos['_ema7_trail_wide'] = True
                else:
                    # 🔧 FIX 29/03: Élargir le trailing si tendance haussière confirmée (higher-lows)
                    if is_uptrend and pnl_pct >= 1.0:
                        trail_pct = trail_pct * 1.3  # 30% plus large en tendance
                        pos['_uptrend_detected'] = True
                    trailing_sl = pos['max_price'] * (1 - trail_pct / 100)
                    # 🔧 FIX 31/03: RATCHET — le trailing stop ne doit JAMAIS baisser
                    # Bug: quand uptrend bascule ON/OFF, le trail s'élargit/rétrécit
                    # et le TS peut BAISSER, perdant la protection acquise.
                    # Ex STO: TS 0.136040 (uptrend off) → 0.133892 (uptrend on) = -1.6% de protection perdue!
                    old_ts = pos.get('trailing_stop') or 0
                    if trailing_sl > old_ts:
                        pos['trailing_stop'] = trailing_sl
                    else:
                        trailing_sl = old_ts  # Garder le TS le plus haut
                if price <= trailing_sl:
                    trend_tag = ' [ema7↑]' if _ema7_trail_active else (' [uptrend]' if is_uptrend else '')
                    sell_reason = f"TRAILING (max={pos['max_pnl']:+.2f}%, now={pnl_pct:+.2f}%, trail={trail_pct:.1f}%{trend_tag})"
            
            # ═══ RÈGLE 3: DURÉE MAX (seulement si PAS en profit significatif) ═══
            # Si le token est à +5% après 2h, on ne vend PAS sur timeout!
            # 🆕 EMA7: si EMA7 monte encore → doubler le délai (tendance justifie le hold)
            elif hold_minutes >= MAX_HOLD_MINUTES:
                _max_hold_eff = MAX_HOLD_MINUTES * 2 if _ema7_rising else MAX_HOLD_MINUTES
                _pnl_thr = 5.0 if _ema7_rising else 3.0
                if hold_minutes >= _max_hold_eff and pnl_pct < _pnl_thr:
                    _tag = '×EMA7↑' if _ema7_rising else ''
                    sell_reason = f"MAX_HOLD_{_max_hold_eff:.0f}min{_tag} ({pnl_pct:+.2f}%)"
            
            # ═══ RÈGLE 3b: STAGNATION — gain insuffisant après N minutes → sortir ═══
            # 🆕 Adaptatif selon contexte marché:
            #   FREEFALL      → 6 min  (pumps s'inversent vite, sortir rapidement)
            #   BEAR/CORRECTION → 8 min
            #   NEUTRAL       → 10 min (défaut)
            #   REBOND/BULL   → 13 min (laisser respirer, le gain peut arriver plus tard)
            # 🆕 EMA7: si EMA7 monte → pas de stagnation justifiée, on attend la tendance
            elif hold_minutes >= _stagnation_minutes and pnl_pct < 0.5 and pnl_pct > -EARLY_SL_PCT and not _ema7_bullish:
                # 🔧 FIX 29/03: En uptrend, rallonger la stagnation de 50%
                # Le prix consolide avant de repartir → ne pas couper trop tôt
                _effective_stag = _stagnation_minutes
                if _detect_uptrend(pos) and pnl_pct > 0:
                    _effective_stag = int(_stagnation_minutes * 1.5)
                # 🔧 FIX 31/03: CONSOLIDATION AFTER PUMP — ne pas confondre avec stagnation
                # KERNEL: max_pnl=+2% puis oscille 0-0.6% pendant 8min → vendu -0.32%
                # mais ensuite +10%. Si max_pnl >= 1.5% ET pnl > 0 → c'est une
                # consolidation post-pump, pas une stagnation. Le prix attend avant de repartir.
                # Rallonger le timer x2 (ex: 8min → 16min) pour laisser le breakout arriver.
                if pos['max_pnl'] >= 1.5 and pnl_pct > 0:
                    _effective_stag = int(_stagnation_minutes * 2.0)
                    if hold_minutes < _effective_stag:
                        sell_reason = None  # Pas encore stagnation
                # Même si max_pnl < 1.5, si pnl > 0 → consolide au-dessus de l'entrée, +30%
                elif pnl_pct > 0 and pos['max_pnl'] >= 0.5:
                    _effective_stag = int(_stagnation_minutes * 1.3)
                if hold_minutes >= _effective_stag and sell_reason is None:
                    sell_reason = f"STAGNATION ({pnl_pct:+.2f}% after {hold_minutes:.0f}min, seuil={_effective_stag}min)"
            
            # ═══ RÈGLE 4: MOMENTUM EXIT (baisses consécutives en FAIBLE profit) ═══
            # Si gain > 5%, ignorer les petites baisses (volatil normal)
            # 🔧 FIX 29/03: Désactivé en uptrend confirmée — les micro-dips sont normaux
            # dans une montée. Les 3 drops consécutifs n'ont pas la même signification
            # si le prix fait des higher-lows.
            # 🆕 EMA7: si EMA7 monte → les baisses consécutives sont du bruit, ne pas vendre
            elif len(last_prices) >= 3 and pnl_pct < 5.0 and not _ema7_rising:
                is_uptrend_mom = _detect_uptrend(pos)
                if last_prices[-1] < last_prices[-2] < last_prices[-3]:
                    pos['consecutive_drops'] = pos.get('consecutive_drops', 0) + 1
                    # En uptrend: exiger 5 drops au lieu de 3, ET pnl en négatif
                    _min_drops = 5 if is_uptrend_mom else MOMENTUM_EXIT_CANDLES
                    if pos['consecutive_drops'] >= _min_drops and (pnl_pct > 0 or pos['max_pnl'] >= 0.10):
                        # En uptrend avec pnl positif: ne pas couper, le trailing gère
                        if is_uptrend_mom and pnl_pct > 0.5:
                            pass  # Laisser le trailing gérer
                        else:
                            sell_reason = f"MOMENTUM_EXIT ({pos['consecutive_drops']} drops, {pnl_pct:+.2f}%)"  # 🔧 29/03
                else:
                    pos['consecutive_drops'] = 0
            else:
                pos['consecutive_drops'] = 0
            
            # ═══ RÈGLE 5: REVERSAL (avait +3%, redescendu sous +1%) ═══
            # Ne s'applique que pour les PETITS gains.
            # Si max_pnl était +15% et on est à +10%, le trailing gère déjà.
            # 🔧 FIX 14/03: Pour les entrées RSI chaud (>= 85) → seuils plus stricts
            # car le retournement est plus rapide et violent après RSI=100
            _rev_peak_min  = 2.0 if pos.get('rsi_at_entry', 50) >= 85 else 3.0
            _rev_exit_pnl  = 0.5 if pos.get('rsi_at_entry', 50) >= 85 else 1.0
            # 🆕 EMA7: si EMA7 monte → le recul est temporaire, ne pas vendre sur REVERSAL
            if sell_reason is None and not _ema7_rising and pos['max_pnl'] >= _rev_peak_min and pos['max_pnl'] < 8.0 and pnl_pct < _rev_exit_pnl:
                sell_reason = f"REVERSAL (peak={pos['max_pnl']:+.2f}%, now={pnl_pct:+.2f}%)"

            # ═══ RÈGLE 5b: VOLUME ROUGE — signal d'épuisement (SECONDAIRE après EMA7) ═══
            # Si klines consécutives ont volume ROUGE dominant (sell > 62%) → exit
            # C'est le signal que les baleines vendent dans le pump = retournement imminent
            # 🔧 FIX 31/03: RESPIRATION — ne pas couper les trades profitables
            #   - Si pnl >= TRAILING_ACTIVATION: trailing gère → SKIP VOLUME_ROUGE
            #   - Si pnl > 0: exiger 3 klines rouges au lieu de 2 (confirmation forte)
            #   - Min hold 120s (was 20s): laisser le trade se développer
            # 🆕 EMA7 (SECONDAIRE): si EMA7 monte, supprimer — la pression vendeuse
            #   n'est pas justifiée tant que la tendance EMA7 est haussière.
            #   Ne s'active que si EMA7 est flat/baissier (not _ema7_rising).
            if sell_reason is None and hold_seconds >= 120 and pnl_pct < TRAILING_ACTIVATION and not _ema7_rising:
                try:
                    klines_vol_exit = self.client.public_get(
                        f"{SCAN_API}/api/v3/klines",
                        {"symbol": symbol, "interval": "1m", "limit": 4}
                    )
                    if klines_vol_exit and len(klines_vol_exit) >= 3:
                        # Adapter le nombre de klines à vérifier selon le PnL
                        _check_count = 3 if pnl_pct > 0 else 2  # + exigeant en profit
                        _sell_dominated = 0
                        for k in klines_vol_exit[-_check_count:]:
                            _total = float(k[7])
                            _buy   = float(k[10]) if len(k) > 10 else _total * 0.5
                            _r = _buy / _total if _total > 0 else 0.5
                            if _r < VOLUME_EXIT_SELL_RATIO:  # < 38% acheteurs = vendeurs dominent
                                _sell_dominated += 1
                        if _sell_dominated >= _check_count:
                            _sell_pct = round((1 - _r) * 100)
                            sell_reason = f"VOLUME_ROUGE ({_sell_pct}% vendeurs × {_check_count} klines, pnl={pnl_pct:+.2f}%)"
                            logger.info(f"   🔴📉 {symbol}: VOLUME ROUGE × {_check_count} → sortie (épuisement acheteurs)")
                except Exception:
                    pass

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

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

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

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

        # 🔧 FIX -2010 PHANTOM: balance == 0 → testnet ne détient pas réellement l'asset
        # 🔧 FIX 07/03: Tenter QUAND MÊME un ordre réel avant de faire une vente virtuelle.
        # L'ancien code faisait une vente virtuelle immédiate → supprimait espion_trades.json
        # mais le crypto RESTAIT sur Binance → orphelin récurrent au prochain reset.
        if balance_ok and real_balance == 0:
            # Tenter l'ordre réel d'abord (quantité stockée)
            logger.warning(f"   ⚠️ {symbol}: balance=0 — tentative ordre réel avant vente virtuelle")
            try:
                _order_test = self.client.market_sell(symbol, quantity, step)
                if _order_test:
                    sell_price = float(_order_test.get('fills', [{}])[0].get('price', current_price))
                    actual_pnl = ((sell_price - pos['entry_price']) / pos['entry_price']) * 100
                    emoji = "✅" if actual_pnl > 0 else "❌"
                    logger.info(f"   {emoji} VENDU (balance-0-fix) {symbol} @ {sell_price} | PnL: {actual_pnl:+.2f}%")
                    self._archive(symbol, pos, sell_price, actual_pnl, reason, hold_time)
                    del self.positions[symbol]
                    self._save_positions()
                    self._remove_from_main(symbol)
                    return
            except Exception as _e:
                logger.debug(f"   ℹ️ Ordre réel échoué ({_e}), fallback vente virtuelle")
            # Fallback: vente virtuelle (testnet asset non crédité réellement)
            actual_pnl = ((current_price - pos['entry_price']) / pos['entry_price']) * 100
            emoji = "✅" if actual_pnl > 0 else "❌"
            logger.warning(f"   ⚠️ VENTE VIRTUELLE {symbol} (balance=0 confirmé): {reason}")
            logger.info(f"   {emoji} CLÔTURE VIRTUELLE {symbol} @ {current_price} | "
                       f"PnL: {actual_pnl:+.2f}% | Hold: {hold_time/60:.1f}min")
            self._archive(symbol, pos, current_price, actual_pnl, reason + " [VIRTUAL]", hold_time)
            del self.positions[symbol]
            self._save_positions()
            self._remove_from_main(symbol)
            return

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

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

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

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

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

                self._archive(symbol, pos, sell_price, actual_pnl, reason, hold_time)
                del self.positions[symbol]
                self._save_positions()
                self._remove_from_main(symbol)
            else:
                logger.error(f"   ❌ Vente échouée {symbol}")
        except Exception as e:
            # 🔧 FIX NOTIONAL: -1013 = valeur trop petite → clôture dust (évite boucle infinie)
            if '-1013' in str(e) or 'NOTIONAL' in str(e).upper():
                actual_pnl = ((current_price - pos['entry_price']) / pos['entry_price']) * 100
                emoji = "✅" if actual_pnl > 0 else "❌"
                logger.warning(f"   ⚠️ NOTIONAL trop faible {symbol} — clôture dust forcée")
                logger.info(f"   {emoji} CLÔTURE DUST {symbol} @ {current_price} | PnL: {actual_pnl:+.2f}%")
                self._archive(symbol, pos, current_price, actual_pnl, reason + " [DUST-NOTIONAL]", hold_time)
                del self.positions[symbol]
                self._save_positions()
                self._remove_from_main(symbol)
            else:
                logger.error(f"   ❌ Erreur vente {symbol}: {e}")
                traceback.print_exc()
    
    def _archive(self, symbol, pos, sell_price, pnl_pct, reason, hold_time):
        """Archive le trade dans l'historique"""
        try:
            history = []
            if os.path.exists(SPY_HISTORY_FILE):
                with open(SPY_HISTORY_FILE, 'r', encoding='utf-8') as f:
                    history = json.load(f)
            
            history.append({
                'symbol': symbol,
                'entry_price': pos['entry_price'],
                'sell_price': sell_price,
                'quantity': pos['quantity'],
                'pnl_pct': round(pnl_pct, 3),
                'pnl_usdt': round(pos['quantity'] * (sell_price - pos['entry_price']), 4),
                'max_pnl': round(pos.get('max_pnl', 0), 3),
                'hold_seconds': round(hold_time, 0),
                'hold_minutes': round(hold_time / 60, 1),
                'surge_type': pos.get('surge_type', ''),
                'surge_strength': pos.get('surge_strength', 0),
                'exit_reason': reason,
                'entry_time': pos.get('timestamp', ''),
                'exit_time': datetime.now(timezone.utc).isoformat(),
            })
            
            # PnL est calculé sur tout l'historique — ne pas tronquer
            # La limite d'affichage (200) est appliquée côté API/dashboard

            # Mise à jour circuit breaker
            pnl_usdt_val = round(pos['quantity'] * (sell_price - pos['entry_price']), 4)
            self._update_coin_score(symbol, pnl_pct, pnl_usdt_val)

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

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

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

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

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


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

class MarketSpy:
    """
    Scanner ultrarapide: scan toutes les 12s, détecte les surges,
    achète immédiatement, vend dès essoufflement.
    """
    
    def __init__(self, dry_run=False):
        self.dry_run = dry_run
        self.client = SpyApiClient()
        self.detector = SurgeDetector()
        self.positions = SpyPositionManager(self.client)
        self.positions._parent_detector = self.detector  # 🆕 FIX 14/02: Lien pour cooldown adaptatif
        self.positions._parent_spy = None  # Set after __init__ completes
        self.watchlist = self._load_watchlist()
        self._market_ctx = {'regime': 'NEUTRAL', 'is_freefall': False,
                            'is_recovery_window': False, 'btc_mom_3h': 0.0,
                            'btc_mom_5h': 0.0, 'bullish_pct': 50.0}  # 🆕 Contexte macro

        self.scan_count = 0
        self.surges_detected = 0
        self.surges_confirmed = 0
        self.trades_executed = 0
        self.session_pnl = 0.0
        self.session_wins = 0
        self.session_losses = 0
        self.trades_this_hour = 0
        self.hour_start = time.time()
        self.start_time = time.time()
        self.last_scan_time = None
        self.last_scan_duration = 0
        self.last_eligible_count = 0
        self.last_surges = []  # Liste cumulative des derniers surges avec timestamps
        self.current_phase = 'INITIALIZING'
        self.errors_count = 0
        self.scan_times = []  # Historique des durées de scan
        self.last_watchlist_auto_update = 0  # 🆕 Timestamp dernier rafraîchissement auto watchlist
        
        # 🧠 Détecteur comportemental — qualifie le comportement humain des participants
        self.behavior = get_behavior_detector() if _BEHAVIOR_AVAILABLE else None
        # Surges en attente de vérification follow-through (symbol → {price, timestamp})
        self._pending_ft_checks = {}
        
        # 🤖 ML Signal Classifier (TESTNET uniquement)
        self.ml_classifier = None
        if TESTNET_MODE and _ML_CLASSIFIER_AVAILABLE:
            try:
                self.ml_classifier = SignalClassifier.load()
                _auc = self.ml_classifier.training_stats.get('test_auc') or self.ml_classifier.training_stats.get('metrics', {}).get('auc_roc', 0)
                logger.info(f"   🤖 ML Classifier chargé (seuil={self.ml_classifier.optimal_threshold:.2f}, "
                           f"AUC={_auc:.4f})")
            except Exception as e:
                logger.warning(f"   ⚠️ ML Classifier indisponible: {e}")
        elif TESTNET_MODE and not _ML_CLASSIFIER_AVAILABLE:
            logger.warning(f"   ⚠️ ML Classifier: imports manquants ({_ml_err if '_ml_err' in dir() else 'unknown'})")

        logger.info("═" * 60)
        logger.info("🕵️ MARKET SPY v3 - Pump Catcher")
        logger.info("═" * 60)
        logger.info(f"   Mode: {'DRY-RUN' if dry_run else 'LIVE'}")
        logger.info(f"   Scan: every {SCAN_INTERVAL}s")
        logger.info(f"   Surge: +{SURGE_MIN_PRICE_CHANGE}%/{SCAN_INTERVAL}s | "
                   f"Vol: {SURGE_MIN_VOLUME_RATIO}x")
        logger.info(f"   Position: {_load_position_size():.0f} USDC base (scaling auto ×{0.05625*100:.1f}% du solde) | Max: {SPY_MAX_POSITIONS}")
        logger.info(f"   Exit: Trailing -{TRAILING_STOP_PCT}%/{TRAILING_STOP_WIDE}%/{TRAILING_STOP_ULTRA}% (dyn) | SL -{HARD_STOP_LOSS_PCT}% | "
                   f"NO TP CAP (trailing only) | Max {MAX_HOLD_MINUTES}min")
        if self.behavior:
            logger.info(f"   🧠 {self.behavior.get_status_line()}")
        if self.ml_classifier:
            logger.info(f"   🤖 ML Filter: ACTIF (TESTNET) — seuil {self.ml_classifier.optimal_threshold:.2f}")
        logger.info("═" * 60)
        # Lien retour pour que le position manager puisse alimenter le behavior
        self.positions._parent_spy = self
    
    def _load_watchlist(self):
        """Charge la watchlist complète pour le spy: manuels + auto-ajoutés + spy_injected.
        
        🔧 FIX 29/03: Le spy scanne symbols (dashboard/bot) + auto_added (spy only).
        Les auto_added ne sont PAS dans symbols[] pour ne pas polluer le dashboard.
        🔧 FIX 10/04: Inclure spy_injected — survit aux redémarrages (était perdu en mémoire seulement).
        """
        try:
            if os.path.exists(WATCHLIST_FILE):
                with open(WATCHLIST_FILE, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                manual = set(data.get('symbols', []))
                auto = set(data.get('auto_added', {}).keys())
                injected = set(data.get('spy_injected', {}).keys())
                return manual | auto | injected
        except Exception:
            pass
        return set()
    
    def _inject_to_watchlist(self, symbol, surge, details):
        """🆕 FIX 27/02: Injecte un symbole confirmé dans la watchlist du bot.
        
        🔧 FIX 29/03: Injection via spy_injected{} seulement (pas dans symbols[]).
        Le bot recharge la watchlist toutes les ~30s et lit spy_injected.
        Le symbole est marqué avec un TTL de 24h.
        """
        try:
            # Ne pas injecter les symboles invalides sur cet exchange/mode
            if symbol in SpyApiClient._invalid_symbols:
                return  # Silencieux — évite les order_failed répétés
            # 🔧 FIX 09/04: Ne pas injecter les coins bloqués par circuit breaker
            _cb_blocked, _cb_reason = self.positions._is_coin_blocked(symbol)
            if _cb_blocked:
                return  # Silencieux pour éviter la pollution de logs
            watchlist_data = {'symbols': [], 'spy_injected': {}}
            if os.path.exists(WATCHLIST_FILE):
                with open(WATCHLIST_FILE, 'r', encoding='utf-8') as f:
                    watchlist_data = json.load(f)
            
            symbols = set(watchlist_data.get('symbols', []))
            spy_injected = watchlist_data.get('spy_injected', {})
            
            # Si déjà dans les symboles manuels du dashboard, juste noter
            if symbol in symbols:
                if symbol in spy_injected:
                    spy_injected[symbol]['last_surge'] = now_paris().isoformat()
                    spy_injected[symbol]['surge_count'] = spy_injected[symbol].get('surge_count', 0) + 1
                    watchlist_data['spy_injected'] = spy_injected
                    with open(WATCHLIST_FILE, 'w', encoding='utf-8') as f:
                        json.dump(watchlist_data, f, indent=2)
                    logger.info(f"      📡 {symbol}: TTL spy renouvelé (surge #{spy_injected[symbol]['surge_count']})")
                return
            
            # Ajouter dans spy_injected (PAS dans symbols[] = flux dashboard)
            spy_injected[symbol] = {
                'added_at': now_paris().isoformat(),
                'last_surge': now_paris().isoformat(),
                'surge_type': surge.get('surge_type', ''),
                'surge_strength': round(surge.get('surge_strength', 0), 2),
                'vol_ratio': round(details.get('vol_ratio', 0), 1),
                'surge_count': 1,
                'ttl_hours': 24
            }
            
            watchlist_data['spy_injected'] = spy_injected
            watchlist_data['updated_at'] = now_paris().isoformat()
            
            with open(WATCHLIST_FILE, 'w', encoding='utf-8') as f:
                json.dump(watchlist_data, f, indent=2)
            
            self.watchlist.add(symbol)
            
            logger.info(f"      📡 {symbol} INJECTÉ (spy_injected)! "
                       f"(surge={surge.get('surge_type')}, strength={surge.get('surge_strength', 0):.1f}%, "
                       f"vol={details.get('vol_ratio', 0):.1f}x)")
            logger.info(f"      → Le bot IA l'analysera au prochain cycle (~30s)")
            
        except Exception as e:
            logger.error(f"      ⚠️ Erreur injection watchlist: {e}")
    
    def _cleanup_expired_spy_symbols(self):
        """Retire de la watchlist les symboles injectés par le spy dont le TTL est expiré.
        
        Un symbole expiré = aucun nouveau surge depuis 'ttl_hours' heures.
        Appelé toutes les ~50 scans (~25min) pour garder la watchlist propre.
        """
        try:
            if not os.path.exists(WATCHLIST_FILE):
                return
            with open(WATCHLIST_FILE, 'r', encoding='utf-8') as f:
                watchlist_data = json.load(f)

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

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

            if not expired:
                return

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

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

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

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

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

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

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

        # Stablecoins, gold et tokens synthétiques à ne jamais ajouter
        EXCLUDE_EXACT = {
            'RLUSDUSDC', 'USD1USDC', 'XUSDUSDC', 'USDPUSDC', 'TUSDUSDC',
            'BUSDUSDC', 'USDTUSDC', 'FDUSDUSDC', 'DAIUSDC', 'PAXGUSDC',
            'EURUSDC', 'GBPUSDC', 'BFUSDUSDC', 'WBTCUSDC',
            'USDEUSDC', 'CUSDC', 'WLFIUSDC',
        }
        EXCLUDE_KEYWORDS = ['USDT', 'TUSD', 'BUSD', 'FDUSD', 'PAXG', 'XAUT']
        # Majors trop liquides pour pump-catching
        EXCLUDE_MAJORS = {
            'BTCUSDC', 'ETHUSDC', 'BNBUSDC', 'XRPUSDC', 'SOLUSDC',
            'ADAUSDC', 'DOGEUSDC', 'TRXUSDC', 'DOTUSDC', 'LTCUSDC',
        }
        try:
            logger.info("🔄 Mise à jour symboles spy depuis Binance...")
            response = self.client.public_get(
                f"{PRODUCTION_API}/api/v3/ticker/24hr"
            )
            if not response or not isinstance(response, list):
                logger.warning("   ⚠️ Réponse Binance vide — skip mise à jour")
                return

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    def _load_bot_positions(self):
        try:
            if os.path.exists(POSITIONS_FILE):
                with open(POSITIONS_FILE, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    return {k for k, v in data.items() if isinstance(v, dict) and 'entry_price' in v}
        except Exception:
            pass
        return set()
    
    # ─── SCAN PRINCIPAL ────────────────────────────────────────────────────
    
    def run_scan(self):
        """
        Un cycle de scan ultra-rapide:
        1. Fetch tous les tickers (1 appel API)
        2. Détecter les surges
        3. Confirmer avec klines 1m
        4. Acheter si confirmé
        5. Vérifier/vendre les positions existantes
        """
        self.scan_count += 1
        scan_start = time.time()
        self.current_phase = 'SCANNING'
        
        # Reset compteur horaire
        if time.time() - self.hour_start >= 3600:
            self.trades_this_hour = 0
            self.hour_start = time.time()
        
        # ═══ FETCH TICKERS ═══
        self.current_phase = 'FETCHING_TICKERS'
        tickers = self.client.public_get(f"{SCAN_API}/api/v3/ticker/24hr")
        if not tickers:
            self.current_phase = 'WAITING'
            self._write_status()
            return
        
        # Filtrer éligibles
        eligible = []
        tickers_dict = {}
        
        for t in tickers:
            sym = t.get('symbol', '')
            try:
                tickers_dict[sym] = float(t['lastPrice'])
            except (ValueError, KeyError):
                continue
            
            if not sym.endswith('USDC'):
                continue
            if EXCLUDE_STABLECOINS and sym in STABLECOINS:
                continue
            try:
                price = float(t.get('lastPrice', 0))
                vol = float(t.get('quoteVolume', 0))
            except (ValueError, TypeError):
                continue
            # 🆕 FIX 25/03: Les coins déjà dans la watchlist du bot principal
            # bénéficient de seuils assouplis (micro-caps suivis intentionnellement)
            in_watchlist = sym in self.watchlist
            effective_min_price = 0.00005 if in_watchlist else MIN_PRICE   # 0.0005 → 0.00005
            effective_min_vol   = 1_000_000 if in_watchlist else MIN_VOLUME_USDT  # watchlist: 1M exception | spy-auto: 5M
            # 🔧 FIX 11/04: Bypass MAX_PRICE pour les coins watchlist (ex: BTCUSDC à 84k, ETHUSDC)
            # BTC/ETH sont dans watchlist intentionnellement — MAX_PRICE les bloquerait sinon
            effective_max_price = float('inf') if in_watchlist else MAX_PRICE
            if price < effective_min_price or price > effective_max_price or vol < effective_min_vol:
                continue
            if sym in self.positions.positions:
                continue
            
            eligible.append(t)
        
        # ═══ DÉTECTER LES SURGES ═══
        self.current_phase = 'DETECTING_SURGES'
        surges = self.detector.update_prices(eligible)
        self.last_eligible_count = len(eligible)

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

        if not surges:
            # Nettoyer les surges de plus de 30 minutes
            self.last_surges = [s for s in self.last_surges if (time.time() - s.get('timestamp', 0)) < 1800]
            self.current_phase = 'WAITING'
            self._write_status()
            # 🆕 FIX: Refresh contexte macro même sans surge (toutes les 5 scans ~35s)
            # Avant: contexte jamais mis à jour si pas de surge → restart = NEUTRAL indéfini
            # Maintenant: detect_regime() utilise son cache 30s — pas d'API call supplémentaire
            if self.scan_count % 5 == 1:
                try:
                    self._market_ctx = get_btc_market_context()
                    self.positions._market_ctx = self._market_ctx
                    ctx = self._market_ctx
                    if ctx.get('regime') in ('BEAR', 'CORRECTION'):
                        logger.info(f"   🌡️ Régime: {ctx['regime']} (hors surge)")
                except Exception as e:
                    logger.debug(f"Contexte marché indisponible: {e}")
            if self.scan_count % 10 == 0:
                bh_line = f" | {self.behavior.get_status_line()}" if self.behavior else ''
                pnl_emoji = '📈' if self.session_pnl >= 0 else '📉'
                pnl_str = f" | {pnl_emoji} {self.session_pnl:+.1f}$ ({self.session_wins}W/{self.session_losses}L)" if (self.session_wins + self.session_losses) > 0 else ''
                logger.info(f"   💓 #{self.scan_count} | {len(eligible)} paires | "
                          f"Pos spy: {self.positions.count} | "
                          f"Surges total: {self.surges_detected}{pnl_str}{bh_line}")
            return
        
        # ═══ TRAITER LES SURGES ═══
        self.current_phase = 'PROCESSING_SURGES'
        self.surges_detected += len(surges)
        surges.sort(key=lambda x: x['surge_strength'], reverse=True)
        # Ajouter les nouveaux surges avec timestamp
        now = time.time()
        for s in surges:
            self.last_surges.append({
                'symbol': s['symbol'],
                'strength': s['surge_strength'],
                'type': s['surge_type'],
                'timestamp': now
            })
        # Garder max 10 surges, les plus récents, de moins de 30 minutes
        self.last_surges = [s for s in self.last_surges if (now - s.get('timestamp', 0)) < 1800]
        self.last_surges = self.last_surges[-10:]
        
        # 🆕 Refresh contexte macro toutes les 5 scans (~50s) pour adapter les seuils
        if self.scan_count % 5 == 1:
            try:
                self._market_ctx = get_btc_market_context()
                self.positions._market_ctx = self._market_ctx  # Partagé avec position manager
                ctx = self._market_ctx
                if ctx['is_freefall']:
                    logger.info(f"   🔴 MARCHÉ EN CHUTE LIBRE: BTC mom3h={ctx['btc_mom_3h']:.1f}% | "
                               f"Alts haussiers={ctx['bullish_pct']:.0f}% → seuils durcis")
                elif ctx['is_recovery_window']:
                    logger.info(f"   🟢 FENÊTRE DE REBOND: BTC mom5h={ctx['btc_mom_5h']:+.2f}% "
                               f"mom3h={ctx['btc_mom_3h']:+.2f}% → seuils assouplis")
            except Exception as e:
                logger.debug(f"Contexte marché indisponible: {e}")

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

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

        for surge in surges:
            symbol = surge['symbol']
            surge_type = surge['surge_type']
            surge_strength = surge['surge_strength']

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

            logger.info(f"\n   ⚡ SURGE: {symbol} | {surge_type} | "
                       f"+{surge_strength:.2f}% | "
                       f"Δ1={surge['change_1scan']:+.2f}% "
                       f"Δ2={surge['change_2scan']:+.2f}% "
                       f"Δ5={surge['change_5scan']:+.2f}%")

            # 🔧 FIX 02/03: Le SPY est INDÉPENDANT — acheter même si le symbole
            # est dans la watchlist. Seul bloquer si le BOT a déjà une position ouverte.
            # Avant: skip si watchlist → 80% des surges ignorés (64 symboles = quasi tout)
            if symbol in bot_positions:
                logger.info(f"      → Skip (bot position active)")
                self.detector.set_cooldown(symbol)
                continue
            if self.trades_this_hour >= SPY_MAX_TRADES_PER_HOUR:
                logger.info(f"      → Skip (max trades/h)")
                continue

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

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

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

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

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

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

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

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

            # ═══ CONFIRMER ═══
            is_strong = surge_type == 'FLASH_SURGE' and surge_strength >= 1.2
            mkt_hint = ''
            if market_ctx.get('is_recovery_window'):
                mkt_hint = ' [REBOND]'
            elif market_ctx.get('is_freefall'):
                mkt_hint = ' [FREEFALL]'
            logger.info(f"      🔍 Confirmation klines 1m{mkt_hint}..."
                       f"{' (mode FLASH adaptatif)' if is_strong else ''}")
            confirmed, details = self.detector.confirm_surge(self.client, symbol, surge, market_ctx)
            
            if confirmed:
                self.surges_confirmed += 1
                buy_pct = round(details.get('buy_ratio', 0.5) * 100)
                buy_icon = '🟢' if details.get('strong_buy_pressure') else ('🟡' if buy_pct >= 50 else '🔴')
                logger.info(f"      ✅ CONFIRMÉ! Vol={details['vol_ratio']:.1f}x | "
                          f"BuyVol={buy_icon}{buy_pct}% (spike {details.get('buy_vol_spike', 1.0):.1f}x) | "
                          f"Green={details['green_count_3']}/3 | "
                          f"Mom={details['mom_3m']:+.2f}%")
                
                # 🤖 ML SIGNAL CLASSIFIER — filtre TESTNET uniquement
                # Fetch 120 klines 1m, compute features, prédire si le signal est rentable
                ml_blocked = False
                ml_result = None
                if self.ml_classifier:
                    try:
                        ml_start = time.time()
                        # Fetch 120 klines 1m pour le feature engineering
                        raw_klines = self.client.public_get(
                            f"{SCAN_API}/api/v3/klines",
                            {"symbol": symbol, "interval": "1m", "limit": 120}
                        )
                        if raw_klines and len(raw_klines) >= 30:
                            # Construire DataFrame au format attendu par feature_engineering
                            kl_data = []
                            for k in raw_klines:
                                kl_data.append({
                                    'open_time': int(k[0]),
                                    'open': float(k[1]),
                                    'high': float(k[2]),
                                    'low': float(k[3]),
                                    'close': float(k[4]),
                                    'volume': float(k[5]),
                                    'quote_volume': float(k[7]),
                                    'num_trades': int(k[8]),
                                    'taker_buy_quote_vol': float(k[10]),
                                })
                            klines_df = pd.DataFrame(kl_data)
                            timestamp_ms = int(raw_klines[-1][0])

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

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

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

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

                # 🆕 FIX 27/02: Injecter le symbole dans la watchlist du bot
                self._inject_to_watchlist(symbol, surge, details)
                
                if self.dry_run:
                    logger.info(f"      🏜️ DRY-RUN: Achat simulé {symbol}")
                    self.detector.set_cooldown(symbol)
                    self._log_opportunity(surge, details, executed=False, reason="dry-run")
                else:
                    success = self.positions.open_position(symbol, surge, details,
                                                            cached_regime=market_ctx.get('regime', 'NEUTRAL'))
                    if success:
                        self.trades_executed += 1
                        self.trades_this_hour += 1
                        self.detector.set_cooldown(symbol)
                        self._log_opportunity(surge, details, executed=True)
                    else:
                        # 🔧 FIX 09/04: Toujours mettre un cooldown pour éviter les tentatives immédiates
                        self.detector.set_cooldown(symbol)
                        self._log_opportunity(surge, details, executed=False, reason="order_failed")
            else:
                logger.info(f"      ❌ Rejeté: {', '.join(details.get('rejection_reasons', []))}")
                self.detector.set_cooldown(symbol)
                self._log_opportunity(surge, details, executed=False,
                                    reason=', '.join(details.get('rejection_reasons', [])))
        
        self.current_phase = 'WAITING'
        self._write_status()
    
    def _write_status(self):
        """Écrit le fichier de statut temps réel pour le dashboard"""
        try:
            uptime = time.time() - self.start_time
            avg_scan_time = sum(self.scan_times) / len(self.scan_times) if self.scan_times else 0
            scans_per_min = (self.scan_count / uptime * 60) if uptime > 0 else 0
            
            # Positions enrichies
            positions_info = []
            for sym, pos in self.positions.positions.items():
                hold_min = (time.time() - pos.get('entry_time', time.time())) / 60
                positions_info.append({
                    'symbol': sym,
                    'pnl_pct': round(pos.get('max_pnl', 0), 2),
                    'hold_minutes': round(hold_min, 1),
                    'surge_type': pos.get('surge_type', ''),
                    'trailing_active': pos.get('trailing_stop') is not None,
                })
            
            status = {
                'running': True,
                'phase': self.current_phase,
                'mode': 'DRY-RUN' if self.dry_run else 'LIVE',
                'uptime_seconds': round(uptime, 0),
                'scan_count': self.scan_count,
                'scan_interval': SCAN_INTERVAL,
                'last_scan_time': self.last_scan_time,
                'last_scan_duration': round(self.last_scan_duration, 3),
                'avg_scan_duration': round(avg_scan_time, 3),
                'scans_per_minute': round(scans_per_min, 1),
                'pairs_monitored': self.last_eligible_count,
                'watchlist_count': len(self.watchlist),  # 🆕 FIX 25/03: total watchlist pour affichage X/Y
                'surges_detected': self.surges_detected,
                'surges_confirmed': self.surges_confirmed,
                'trades_executed': self.trades_executed,
                'trades_this_hour': self.trades_this_hour,
                'active_positions': self.positions.count,
                'positions_detail': positions_info,
                'last_surges': self.last_surges,
                'errors_count': self.errors_count,
                'config': {
                    'surge_min_change': SURGE_MIN_PRICE_CHANGE,
                    'volume_ratio_min': SURGE_MIN_VOLUME_RATIO,
                    'position_size': SPY_POSITION_SIZE,
                    'max_positions': SPY_MAX_POSITIONS,
                    'trailing_stop': f"{TRAILING_STOP_PCT}/{TRAILING_STOP_WIDE}",
                    'hard_sl': HARD_STOP_LOSS_PCT,
                    'take_profit': 'unlimited (trailing only)',
                    'max_hold_min': MAX_HOLD_MINUTES,
                },
                'timestamp': now_paris().isoformat(),
                'pid': os.getpid(),
            }

            # 🧠 Ajouter le régime comportemental au statut
            if self.behavior:
                bh = self.behavior.get_regime()
                status['behavior'] = {
                    'regime': bh['regime'],
                    'regime_duration_min': bh['regime_duration_min'],
                    'ftr': bh['metrics']['ftr'],
                    'irr': bh['metrics']['irr'],
                    'surge_ft_rate': bh['metrics']['surge_ft_rate'],
                    'sample_size': bh['metrics']['sample_size'],
                    'should_trade': bh['should_trade'],
                    'position_multiplier': bh['position_multiplier'],
                }

            with open(SPY_STATUS_FILE, 'w', encoding='utf-8') as f:
                json.dump(status, f, indent=2, default=str)
        except Exception:
            pass
    
    def _log_opportunity(self, surge, details, executed, reason=None):
        try:
            history = []
            if os.path.exists(SPY_LOG_FILE):
                with open(SPY_LOG_FILE, 'r', encoding='utf-8') as f:
                    history = json.load(f)
            
            history.append({
                'timestamp': now_paris().isoformat(),
                'symbol': surge['symbol'],
                'pattern': surge['surge_type'],
                'score': round(surge['surge_strength'] * 20, 1),
                'price': surge['price'],
                'price_change_24h': surge.get('price_change_24h', 0),
                'volume_usdt': surge['volume_24h'],
                'indicators': {
                    'rsi': 0,
                    'bb_position': 0,
                    'momentum_3': details.get('mom_3m', 0),
                    'volume_spike': details.get('vol_ratio', 0),
                    'buy_ratio': details.get('buy_ratio', 0),
                    'buy_vol_spike': details.get('buy_vol_spike', 1.0),
                    'strong_buy_pressure': details.get('strong_buy_pressure', False),
                    'sell_pressure': details.get('sell_pressure', False),
                    'surge_strength': surge['surge_strength'],
                    'change_1scan': surge['change_1scan'],
                    'change_2scan': surge['change_2scan'],
                },
                'signals': [surge['surge_type'], f"VOL_{details.get('vol_ratio', 0):.1f}x", f"BUY_{round(details.get('buy_ratio', 0.5)*100)}%"],
                'executed': executed,
                'reason': reason
            })
            
            if len(history) > 500:
                history = history[-500:]
            
            with open(SPY_LOG_FILE, 'w', encoding='utf-8') as f:
                json.dump(history, f, indent=2, default=str)
        except Exception:
            pass
    
    # ─── BOUCLE CONTINUE ───────────────────────────────────────────────────
    
    def run_continuous(self, interval=SCAN_INTERVAL):
        logger.info(f"\n🔄 Mode continu - Scan toutes les {interval}s (Ctrl+C pour arrêter)")
        
        try:
            while True:
                try:
                    t0 = time.time()
                    self.run_scan()

                    # Mise à jour auto watchlist toutes les 20 min (+ au 1er scan)
                    # 🔧 FIX 16/04: 1h→20min — pompes rapides (+30% en 45min) manquées sinon
                    if self.scan_count == 1 or (time.time() - self.last_watchlist_auto_update) >= 1200:
                        self._auto_update_watchlist()
                        self.last_watchlist_auto_update = time.time()

                    if self.scan_count % 50 == 0:
                        self._print_summary()
                        self._cleanup_expired_spy_symbols()
                    
                    wait = max(0, interval - (time.time() - t0))
                    if wait > 0:
                        time.sleep(wait)
                    
                except KeyboardInterrupt:
                    raise
                except Exception as e:
                    self.errors_count += 1
                    self.current_phase = 'ERROR'
                    logger.error(f"❌ Erreur: {e}")
                    logger.error(traceback.format_exc())
                    self._write_status()
                    time.sleep(5)
        
        except KeyboardInterrupt:
            logger.info("\n\n🛑 Arrêt du Spy...")
            self.current_phase = 'STOPPED'
            self._write_status()
            self._print_summary()
    
    def _print_summary(self):
        logger.info(f"\n{'═' * 55}")
        logger.info(f"📊 RÉSUMÉ SPY v3 - Pump Catcher")
        logger.info(f"{'═' * 55}")
        logger.info(f"   Scans: {self.scan_count} | Surges: {self.surges_detected} "
                    f"({self.surges_confirmed} confirmés)")
        logger.info(f"   Trades: {self.trades_executed} | Positions: {self.positions.count}")
        
        for sym, pos in self.positions.positions.items():
            hold = (time.time() - pos.get('entry_time', time.time())) / 60
            logger.info(f"   📌 {sym}: Entry={pos['entry_price']} | "
                       f"MaxPnL={pos['max_pnl']:+.2f}% | Hold={hold:.1f}min")
        
        try:
            if os.path.exists(SPY_HISTORY_FILE):
                with open(SPY_HISTORY_FILE, 'r', encoding='utf-8') as f:
                    hist = json.load(f)
                if hist:
                    wins = sum(1 for t in hist if t['pnl_pct'] > 0)
                    total_pnl = sum(t.get('pnl_usdt', 0) for t in hist)
                    avg_pnl = np.mean([t['pnl_pct'] for t in hist])
                    avg_hold = np.mean([t.get('hold_minutes', 0) for t in hist])
                    logger.info(f"   📈 {wins}/{len(hist)} wins ({wins/len(hist)*100:.0f}%) | "
                              f"PnL: {total_pnl:+.4f} USDT | "
                              f"Avg: {avg_pnl:+.2f}% | Hold: {avg_hold:.1f}min")
        except Exception:
            pass
        if self.behavior:
            logger.info(f"   {self.behavior.get_status_line()}")
        logger.info(f"{'═' * 55}")


# ═══════════════════════════════════════════════════════════════════════════════
# POINT D'ENTRÉE
# ═══════════════════════════════════════════════════════════════════════════════

def main():
    parser = argparse.ArgumentParser(description="🕵️ Market Spy v3 - Pump Catcher")
    parser.add_argument('--once', action='store_true', help='Un seul cycle (2 scans)')
    parser.add_argument('--dry-run', action='store_true', help='Mode simulation')
    parser.add_argument('--interval', type=int, default=SCAN_INTERVAL,
                       help=f'Intervalle entre scans (défaut: {SCAN_INTERVAL}s)')
    
    args = parser.parse_args()
    
    spy = MarketSpy(dry_run=args.dry_run)
    
    if args.once:
        logger.info("📸 Scan 1: snapshot de référence...")
        spy.run_scan()
        logger.info(f"⏳ Attente {args.interval}s...")
        time.sleep(args.interval)
        logger.info("📸 Scan 2: détection des surges...")
        spy.run_scan()
        spy._print_summary()
        return
    
    spy.run_continuous(interval=args.interval)


if __name__ == "__main__":
    main()
