                    k = 2.0 / (period + 1)
                    e = sum(vals[:period]) / period
                    for v in vals[period:]:
                        e = v * k + e * (1 - k)
                    return e

                _fw_ema7  = _fw_ema(_fw_closes, 7)
                _fw_ema25 = _fw_ema(_fw_closes, 25) if len(_fw_closes) >= 25 else _fw_ema(_fw_closes, len(_fw_closes))
                _fw_ema_bearish = _fw_ema7 < _fw_ema25
                _fw_ema7_prev = _fw_ema(_fw_closes[:-3], 7)
                _fw_ema7_slope = (_fw_ema7 - _fw_ema7_prev) / _fw_ema7_prev * 100 if _fw_ema7_prev > 0 else 0.0
                _fw_declining = _fw_ema7_slope < -0.10

                if _fw_ema_bearish or _fw_declining:
                    trend_blocked = True
                    logger.info(
                        f"      📉 [{_entry_tag}] {symbol}: tendance défavorable "
                        f"(EMA7{'<' if _fw_ema_bearish else '>='}EMA25, pente={_fw_ema7_slope:.2f}%/3bougies) "
                        f"→ SKIP (cooldown 5min)"
                    )

            if trend_blocked:
                self._framework_cooldowns[symbol] = _now_ts
                continue

            # ML Signal Classifier (même logique que pour les surges) — réutilise _kdf déjà chargé
            ml_blocked = False
            if self.ml_classifier and _kdf is not None and _raw_klines:
                try:
                    _ml_start = time.time()
                    if len(_raw_klines) >= 30:
                        _ts_ms = int(_raw_klines[-1][0])
                        _feats = compute_features_at_timestamp(_kdf, _ts_ms, lookback_minutes=120)
                        if _feats:
                            _feats['surge_strength'] = 2.0
                            _feats['surge_type'] = _entry_tag
                            _ml_res = self.ml_classifier.predict(_feats, _kdf, _ts_ms)
                            _ml_elapsed = (time.time() - _ml_start) * 1000
                            if _ml_res['signal'] == 'SKIP':
                                ml_blocked = True
                                logger.info(
                                    f"      🤖 ML SKIP: prob={_ml_res['probability']:.3f} "
                                    f"< {_ml_res['threshold']:.2f} | {_ml_res['model_type']} | {_ml_elapsed:.0f}ms"
                                )
                            else:
                                logger.info(
                                    f"      🤖 ML BUY: prob={_ml_res['probability']:.3f} "
                                    f">= {_ml_res['threshold']:.2f} | {_ml_res['model_type']} | {_ml_elapsed:.0f}ms"
                                )
                except Exception as _ml_e:
                    logger.debug(f"      🤖 [FRAMEWORK] ML erreur: {_ml_e} → passthrough")

            if ml_blocked:
                logger.info(f"      ❌ [{_entry_tag}] {symbol}: bloqué par ML → cooldown 5min")
                self._framework_cooldowns[symbol] = _now_ts
                continue

            # Garde-fou microstructure pour FRAMEWORK:
            # exiger un minimum de pression acheteuse réelle sur les 2 dernières
            # bougies, sinon le signal BUY_NOW est souvent trop tardif.
            _fw_best_buy_ratio = 0.60
            _fw_best_vol_ratio = 1.5
            _fw_buy_spike = 1.2
            if _raw_klines and len(_raw_klines) >= 4:
                try:
                    _qv = [float(k[7]) for k in _raw_klines[-30:]]
                    _tb = [float(k[10]) for k in _raw_klines[-30:]]
                    _avg_qv = float(np.mean(_qv[:-1])) if len(_qv) > 1 else (_qv[-1] if _qv else 1.0)
                    if _avg_qv <= 0:
                        _avg_qv = 1.0
                    _cur_vr = _qv[-1] / _avg_qv if _qv else 0.0
                    _prev_vr = _qv[-2] / _avg_qv if len(_qv) > 1 else _cur_vr
                    _fw_best_vol_ratio = max(_cur_vr, _prev_vr)

                    _cur_br = (_tb[-1] / _qv[-1]) if (_qv and _qv[-1] > 0) else 0.5
                    _prev_br = (_tb[-2] / _qv[-2]) if (len(_qv) > 1 and _qv[-2] > 0) else _cur_br
                    _fw_best_buy_ratio = max(_cur_br, _prev_br)

                    _avg_tb = float(np.mean(_tb[:-1])) if len(_tb) > 1 else (_tb[-1] if _tb else 1.0)
                    if _avg_tb <= 0:
                        _avg_tb = 1.0
                    _fw_buy_spike = (_tb[-1] / _avg_tb) if _tb else 1.0
                except Exception:
                    pass

            if _fw_best_buy_ratio < 0.52 or _fw_best_vol_ratio < 1.10:
                logger.info(
                    f"      🚫 [{_entry_tag}] {symbol}: microstructure faible "
                    f"(buy={_fw_best_buy_ratio:.0%}<52% ou vol={_fw_best_vol_ratio:.1f}x<1.1x) → SKIP"
                )
                self._framework_cooldowns[symbol] = _now_ts
                continue

            # confirm_details synthétique
            _fw_confirm = {
                'confirmed':           True,
                'reason':              'framework_buy_now',
                'vol_ratio':           round(_fw_best_vol_ratio, 2),
                'buy_ratio':           round(_fw_best_buy_ratio, 2),
                'buy_vol_pct':         round(_fw_best_buy_ratio * 100, 1),
                'buy_vol_spike':       round(_fw_buy_spike, 2),
                'green_count_3':       2,
                'mom_3m':              0.5,
                'strong_buy_pressure': _fw_best_buy_ratio >= 0.68,
                'rejection_reasons':   [],
            }

            if self.dry_run:
                logger.info(f"      🏜️ DRY-RUN: Achat simulé {symbol} [{_entry_tag} score={fw_score:.0f}]{_size_note}")
                self._framework_cooldowns[symbol] = _now_ts
            else:
                # Early entry → régime CORRECTION (~65% de la position normale)
                _regime = 'CORRECTION' if is_early_entry else market_ctx.get('regime', 'NEUTRAL')
                _ok = self.positions.open_position(
                    symbol, _fw_surge, _fw_confirm,
                    cached_regime=_regime
                )
                if _ok:
                    self.trades_executed += 1
                    self.trades_this_hour += 1
                    logger.info(f"      ✅ [{_entry_tag}] {symbol} position ouverte! score={fw_score:.0f}/100{_size_note}")
                else:
                    logger.info(f"      ❌ [{_entry_tag}] {symbol}: open_position refusé")
                self._framework_cooldowns[symbol] = _now_ts

    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
                entry_p  = pos.get('entry_price', 0)
                cur_p    = pos.get('_current_price_live', entry_p)
                sl_p     = pos.get('stop_loss', 0)
                tp_p     = pos.get('take_profit', 0)
                pnl_pct_live = ((cur_p - entry_p) / entry_p * 100) if entry_p else 0
                pnl_usdt_live = (cur_p - entry_p) * pos.get('quantity', 0)
                sl_pct   = round((sl_p / entry_p - 1) * 100, 2) if entry_p and sl_p else None
                tp_pct   = round((tp_p / entry_p - 1) * 100, 2) if entry_p and tp_p else None
                positions_info.append({
                    'symbol':          sym,
                    'pnl_pct':         round(pnl_pct_live, 3),
                    'pnl_usdt':        round(pnl_usdt_live, 2),
                    'max_pnl':         round(pos.get('max_pnl', 0), 3),
                    'hold_min':        round(hold_min, 1),
                    'hold_minutes':    round(hold_min, 1),
                    'entry_price':     entry_p,
                    'current_price':   round(cur_p, 6) if cur_p else None,
                    'stop_loss':       sl_p,
                    'take_profit':     tp_p,
                    'sl_pct':          sl_pct,
                    'tp_pct':          tp_pct,
                    'quantity':        pos.get('quantity', 0),
                    'surge_type':      pos.get('surge_type', ''),
                    'trailing_active': pos.get('trailing_stop') is not None,
                    'trailing_stop':   pos.get('trailing_stop'),
                    'ema7_bullish':    not pos.get('_ema_bearish_live', True),
                    'ema7_slope':      pos.get('_ema7_slope'),
                })
            
            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,
                    # 📋 Filtres actifs (Fix 15 — 05/05/2026)
                    'filters': {
                        'entry_filter_ia':   {'surge_min': 0.634, 'ema7_min': 0.526, 'wick_max': 0.373, 'rsi_min': 74.2, 'vol_min': 2.02, 'block_on': '<=3/5'},
                        'spread_max_pct':    0.8,
                        'slippage_max_pct':  MAX_BUY_SLIPPAGE_PCT,
                        'bid_retrait_pct':   0.4,
                        'vol_new_listing':   500,
                        'entry_peak_buy':    True,
                        'anti_fast_spiker':  True,
                        'blacklist_global':  list(SPY_SYMBOL_BLACKLIST),
                        'flash_blacklist':   list(FLASH_SURGE_BLACKLIST),
                        'flash_strict':      list(FLASH_SURGE_STRICT.keys()),
                        'websocket_active':  _WS_AVAILABLE,
                        'profit_lock_arm':   PROFIT_LOCK_ARM_PNL,
                        'profit_lock_floor': PROFIT_LOCK_MIN_FLOOR,
                        'profit_lock_retrace': PROFIT_LOCK_RETRACE,
                        'ema_soft_retrace':  PROFIT_LOCK_RETRACE_EMA_SOFT,
                        'fast_loss_cooldown_h': 6,
                        'streak_cooldown_h': 3,
                        'max_trades_per_h':  SPY_MAX_TRADES_PER_HOUR,
                        'patterns': {
                            'FLASH_SURGE':         {'active': True,  'note': f'≥{SURGE_MIN_PRICE_CHANGE}%/scan'},
                            'BREAKOUT_SURGE':      {'active': True,  'note': f'≥{SURGE_MIN_PRICE_CHANGE_2}%/2scans + ≥0.5%'},
                            'MOMENTUM_SURGE':      {'active': False, 'note': 'désactivé 02/05 — WR=51% 38% HARD_SL'},
                            'TREND_MOMENTUM_SURGE':{'active': False, 'note': 'désactivé 02/05 — WR=50% bruit stat'},
                            'LONG_TREND_SURGE':    {'active': False, 'note': 'en veille — attente filtre pullback'},
                        },
                    },
                },
                'timestamp': now_paris().isoformat(),
                'pid': os.getpid(),
            }

            # 🌐 Ajouter le régime macro BTC (BULL_STRONG / NEUTRAL / BEAR …)
            _mctx = getattr(self, '_market_ctx', {}) or {}
            if _mctx.get('regime'):
                status['market_regime'] = _mctx.get('regime', 'NEUTRAL')

            # 🧠 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'],
                    'signal_explosive_score': details.get('signal_explosive_score', surge.get('_signal_explosive_score', 0)),
                    'signal_setup': details.get('signal_setup', surge.get('_signal_setup', '')),
                    'signal_trap_risk': details.get('signal_trap_risk', surge.get('_signal_trap_risk', 'LOW')),
                },
                '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)")
        logger.info("   ⏱️ Synchronisation scans: ticks muraux alignés")
        
        try:
            while True:
                try:
                    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()

                    # 🔧 FIX 05/05: aligner les scans sur une grille temporelle absolue
                    # (0, 7, 14, 21, ... secondes) pour réduire les divergences testnet/prod
                    # liées à la dérive de boucle et aux temps d'exécution variables.
                    now_ts = time.time()
                    next_tick = ((int(now_ts) // interval) + 1) * interval
                    wait = max(0.0, next_tick - now_ts)
                    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()
