#!/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 re
import time
import hmac
import hashlib
import base64
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 threading
import asyncio

import requests
import numpy as np

try:
    import websockets as _websockets_lib
    _WS_AVAILABLE = True
except ImportError:
    _WS_AVAILABLE = False

# ─── 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

# ─── Logging d'exécution (slippage / spread diagnostic) ───
try:
    from execution_logger import ExecutionLogger
    _EXEC_LOGGER_AVAILABLE = True
except ImportError:
    _EXEC_LOGGER_AVAILABLE = False

# ─── Module signaux macro (observation mode) ───
try:
    from signals_consumer import get_signal_snapshot, signal_log_entry
    _SIGNALS_AVAILABLE = True
except ImportError:
    _SIGNALS_AVAILABLE = False
    def get_signal_snapshot(): return None
    def signal_log_entry(s): return 'N/A'

# ─── 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

# ─── Couche 7 — Détection de contexte de marché (per-token) ───
_COUCHE7_AVAILABLE = False
try:
    from market_context import (
        get_market_context_detector,
        get_combined_signal,
        log_couche7_decision,
        COUCHE7_ENABLED,
        COUCHE7_SHADOW_MODE,
    )
    _COUCHE7_AVAILABLE = True
except ImportError:
    _COUCHE7_AVAILABLE = False
    COUCHE7_ENABLED = False
    COUCHE7_SHADOW_MODE = True
    def get_market_context_detector(): return None
    def get_combined_signal(macro, token): return 'LONG'
    def log_couche7_decision(*a, **kw): pass

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

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

sys.path.insert(0, SCRIPT_DIR)
try:
    from config import (
        KRAKEN_API_KEY, KRAKEN_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 — migration Binance→Kraken (07/08/2026) ───
# Kraken n'a pas de testnet spot public : TRADING_API/SCAN_API pointent toujours
# sur l'API réelle (lecture de vraies données). TESTNET_MODE=True bascule
# uniquement SpyApiClient en dry-run (ordres simulés localement, jamais envoyés
# à Kraken — voir market_buy/market_sell).
KRAKEN_BASE_URL = "https://api.kraken.com"
PRODUCTION_API = KRAKEN_BASE_URL
TRADING_API = KRAKEN_BASE_URL
SCAN_API = KRAKEN_BASE_URL

# ─── Correspondance symboles Binance (ex: BTCUSDC) ↔ Kraken (altname ex: XBTUSD) ───
# Watchlist migrée quote USDC → USD (catalogue Kraken /USDC limité à 46 paires,
# beaucoup de coins surveillés par le spy n'existent pas du tout en /USDC sur Kraken).
_KRAKEN_BASE_ALIAS = {'BTC': 'XBT', 'DOGE': 'XDG'}
_KRAKEN_BASE_ALIAS_REV = {v: k for k, v in _KRAKEN_BASE_ALIAS.items()}
_KRAKEN_QUOTE = "USD"
_kraken_pairs_cache: dict = {}          # altname ('XBTUSD') → info AssetPairs
_kraken_internal_to_altname: dict = {}  # clé interne Kraken ('XXBTZUSD') → altname
_kraken_wsname_to_altname: dict = {}    # 'XBT/USD' → 'XBTUSD'
_kraken_assets_altname: dict = {}       # code Kraken ('ZUSD','XXBT') → altname ('USD','XBT')


def _load_kraken_pairs():
    """Charge et cache /0/public/AssetPairs (une fois par process)."""
    global _kraken_pairs_cache, _kraken_internal_to_altname, _kraken_wsname_to_altname
    if _kraken_pairs_cache:
        return
    try:
        r = requests.get(f"{KRAKEN_BASE_URL}/0/public/AssetPairs", timeout=10).json()
        for internal_key, info in r.get('result', {}).items():
            altname = info.get('altname')
            wsname = info.get('wsname')
            if altname:
                _kraken_pairs_cache[altname] = info
                _kraken_internal_to_altname[internal_key] = altname
            if wsname:
                _kraken_wsname_to_altname[wsname] = altname
    except Exception as e:
        logger.error(f"[Kraken] Impossible de charger AssetPairs: {e}")


def _load_kraken_assets():
    """Charge et cache /0/public/Assets (une fois par process)."""
    global _kraken_assets_altname
    if _kraken_assets_altname:
        return
    try:
        r = requests.get(f"{KRAKEN_BASE_URL}/0/public/Assets", timeout=10).json()
        for code, info in r.get('result', {}).items():
            _kraken_assets_altname[code] = info.get('altname', code)
    except Exception as e:
        logger.error(f"[Kraken] Impossible de charger Assets: {e}")


def binance_symbol_to_kraken_altname(symbol: str):
    """'TONUSDC'/'BTCUSDT' → altname Kraken ('TONUSD'/'XBTUSD'), None si absent du catalogue."""
    _load_kraken_pairs()
    base = symbol
    for q in ('USDC', 'USDT', 'BUSD', 'FDUSD', 'USD'):
        if symbol.endswith(q):
            base = symbol[:-len(q)]
            break
    base_kraken = _KRAKEN_BASE_ALIAS.get(base, base)
    return _kraken_wsname_to_altname.get(f"{base_kraken}/{_KRAKEN_QUOTE}")


def kraken_altname_to_binance_symbol(altname: str):
    """altname Kraken ('XBTUSD') → symbole façon Binance ('BTCUSD'), None si format inattendu."""
    info = _kraken_pairs_cache.get(altname)
    wsname = info.get('wsname') if info else None
    if not wsname or '/' not in wsname:
        return None
    base, quote = wsname.split('/', 1)
    base = _KRAKEN_BASE_ALIAS_REV.get(base, base)
    return f"{base}{quote}"


def kraken_asset_to_binance(code: str) -> str:
    """Code devise Kraken ('ZUSD','XXBT') → nom façon Binance ('USD','BTC')."""
    _load_kraken_assets()
    altname = _kraken_assets_altname.get(code, code)
    return _KRAKEN_BASE_ALIAS_REV.get(altname, altname)

# ─── 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)
WATCHLIST_MIN_VOLUME = 500_000    # 🔧 FIX 01/05: Seuil scan pour coins EN watchlist = MIN_AUTO_VOLUME (500K). Cohérence: auto_added avec vol>=500K doit aussi être scannable à 500K
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 — DÉSACTIVÉ 02/05/2026 (38% HARD_SL, entrée tardive) ───
# MOMENTUM_MIN_CHANGE_20 = 4.0
# MOMENTUM_MIN_CHANGE_40 = 6.0
# MOMENTUM_MIN_CHANGE_1 = 0.3
# MOMENTUM_COOLDOWN = 600
# MOMENTUM_MAX_PER_HOUR = 3
# ─── TYPE 5: TREND_MOMENTUM_SURGE — DÉSACTIVÉ 02/05/2026 (médiane +0.01%, bruit) ───
# TREND_MOMENTUM_MIN_CHANGE_85 = 2.5
# TREND_MOMENTUM_MIN_CHANGE_130 = 3.5
# TREND_MOMENTUM_MIN_CHANGE_1 = 0.15
# TREND_MOMENTUM_MIN_CHANGE_5 = 0.20
# TREND_MOMENTUM_COOLDOWN = 900
# TREND_MOMENTUM_MAX_PER_HOUR = 2
# ─── TYPE 4: LONG_TREND_SURGE — EN VEILLE 02/05/2026 (données insuffisantes) ───
LONG_TREND_MIN_RISE = 10.0
LONG_TREND_SNAPSHOT_INTERVAL = 360  # Gardé défini même si désactivé (référencé dans update_prices)
LONG_TREND_MIN_SNAPSHOTS = 6
LONG_TREND_MAX_SNAPSHOTS = 12
LONG_TREND_COOLDOWN = 3600
LONG_TREND_MAX_PER_HOUR = 2
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 = {
    # Vide — coins réhabilités après validation testnet (29/04)
}
# ─── BLACKLIST DYNAMIQUE ──────────────────────────────────────────────────────
# SPY_SYMBOL_BLACKLIST est calculé à chaque démarrage par _init_dynamic_blacklist()
# • Permanent  : jamais relâché (market maker prouvé, manipulation systématique)
# • Temporaire : ban N jours → graduation FLASH_SURGE_STRICT → surveillance
# • Auto-reban : 2+ pertes après graduation → ban permanent automatique
# 🔧 Migration Kraken 07/08/2026: symboles renommés USDC→USD (quote Kraken). Historique
# des trades/raisons ci-dessous reste celui observé sur Binance en USDC.
_BL_PERMANENT = {'NOTUSD'}   # 🔧 05/05: 0/4 trades avg -1.60% — market maker confirmé

# Données initiales Fix 06/05 — seed pour spy_dynamic_blacklist.json si absent
# Durée: 14 jours → graduation FLASH_SURGE_STRICT ≥2.0% (retentée sur vrais breakouts)
_BL_TEMP_SEED = [
    {'symbol': 'TSTUSD',   'banned_at': '2026-05-06T16:49:00', 'duration_days': 1,
     'reason': 'WR=20% 5T PnL=-0.81U', 'graduate_strict': True, 'strict_thresh': 2.0},
    {'symbol': 'CFGUSD',   'banned_at': '2026-05-06T16:49:00', 'duration_days': 1,
     'reason': 'WR=0% 2T PnL=-0.63U',  'graduate_strict': True, 'strict_thresh': 2.0},
    {'symbol': 'TONUSD',   'banned_at': '2026-05-06T16:49:00', 'duration_days': 1,
     'reason': 'WR=0% 3T PnL=-0.41U',  'graduate_strict': True, 'strict_thresh': 2.0},
    {'symbol': 'NEIROUSD', 'banned_at': '2026-05-06T16:49:00', 'duration_days': 1,
     'reason': 'WR=0% 2T PnL=-0.39U',  'graduate_strict': True, 'strict_thresh': 2.0},
    {'symbol': 'ENJUSD',   'banned_at': '2026-05-06T16:49:00', 'duration_days': 1,
     'reason': 'WR=0% 2T PnL=-0.39U',  'graduate_strict': True, 'strict_thresh': 2.0},
    {'symbol': 'ORDIUSD',  'banned_at': '2026-05-06T16:49:00', 'duration_days': 1,
     'reason': 'WR=0% 3T PnL=-0.39U',  'graduate_strict': True, 'strict_thresh': 2.0},
]
_BL_JSON_FILE = os.path.join(SCRIPT_DIR, 'spy_dynamic_blacklist.json')
# Initialisé aux permanents uniquement — mis à jour par _init_dynamic_blacklist() après logger
SPY_SYMBOL_BLACKLIST = set(_BL_PERMANENT)
# 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 = {
    'NOMUSD':         2.0,  # 🔧 29/04: sorti blacklist — 5T 100%WR +43USDT testnet — seuil ≥2% sur FLASH
    '0GUSD':          2.0,  # 🔧 29/04: sorti blacklist — 1T 100%WR +86USDT testnet — seuil ≥2% sur FLASH
    # 'ENJUSD': supprimé 06/05: déplacé en blacklist globale (WR=0%, 2 trades)
    'GIGGLEUSD':      2.5,  # 🔧 15/04: 13T 54%WR +12USDT — spike très court, besoin d'impulsion forte
    # 'TSTUSD': supprimé 06/05: déplacé en blacklist globale (WR=20% insuffisant, 5 trades)
    'BROCCOLI714USD': 2.0,  # 🔧 15/04: 7T 43%WR +22USDT — seuil x2
    'HIVEUSD':        2.0,  # 🔧 05/05: fast-spiker récurrent — buy au sommet sur vol<1.0x → HARD_SL systématique
    'OSMOUSD':        2.0,  # 🔧 17/05: INSTANT_REVERSAL 1T -2.62U + 15+ faux pumps 72h (1.0-1.5% → retrace immédiat) — PUMP_TERMINAL c7=85%
}
# 🔧 FIX 19/05: Coins prioritaires — scannés TOUJOURS peu importe le volume.
# Problème: sur testnet, ALGOUSD avait un volume simulé <500K → absent de l'univers de scan.
# La hausse +3.98% (15:00-15:25) a été manquée car ALGO n'était pas dans eligible[].
# Il n'a été détecté qu'à 15:49 (auto-add sur volume spike) → trop tard, retrace déjà amorcé.
# Ces coins ont un volume réel suffisant en prod mais testnet les sous-représente.
# L'ENTRY_FILTER reste actif → pas de risque d'acheter sur faux signal faible volume.
PRIORITY_SCAN_USD = {
    'ALGOUSD',   # Top-50 coin, volume testnet souvent <500K mais moves réels importants
    'LAYERUSD',  # 🔧 FIX 19/05: LAYERUSDC existe sur prod (0.093 USDC) mais volume testnet faible.
                  # Framework 7 couches détecte des WATCH +2.76→3.54% sur LAYERUSDT.
                  # La paire USD est la seule tradable par le spy sur Kraken — sans ce flag, elle est invisible.
}
# 🔧 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)

def _load_settings():
    """Charge tous les paramètres 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:
                return json.load(f)
    except Exception:
        pass
    return {}

_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_settings = _load_settings()
SPY_MAX_POSITIONS = int(_spy_settings.get('maxPositions', 3))  # Configurable via bot_settings.json
SPY_MAX_TRADES_PER_HOUR = 6       # 🔧 FIX 14/02: Limiter les trades par heure (5→6)

# ─── SIGNAL SURVEILLANCE BOOST (TESTNET) ───
# En testnet, on privilégie les setups macro "explosifs" pour être prêt à capter
# des moves rapides. En prod, ce boost reste neutre/conservateur.
TESTNET_SIGNAL_PRIORITY = True
TESTNET_SIGNAL_MIN_EXPLOSIVE = 6.0   # Seuil pour activer les assouplissements entrée

# ─── 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.8          # 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)
ENTRY_MIN_BUY_RATIO = 0.45        # 🔧 MOD6 02/05 (calibré 167 trades): 0.55→0.45 — zone 0.45-0.60 = WR 75-100%, HARD_SL concentrés sur buy_ratio≥0.80 (FOMO inversé). sell_pressure existant (buy<45%+vol>1.5x) couvre déjà les pires cas
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 = 90               # 🔧 OPT 15/03: 1 minute (was 2min) — faux signaux visibles encore plus vite (optimisé crash-test 30j)
EARLY_SL_MIN_PNL = 0.15            # ✂️ FIX 15/03: Si max_pnl < 0.2% après 2min → faux signal confirmé (was 0.3%)
MAX_HOLD_MINUTES = 12             # 🔧 02/05/2026: 15→12min — simplifié, sans bypass EMA7/PnL (TREND_MOMENTUM désactivé)
STAGNATION_EXIT_MINUTES = 12      # 🆕 FIX 14/02: Si après 10min gain < 0.5% → sortir (conservé mais inutilisé après simplif)
MOMENTUM_EXIT_CANDLES = 4         # Vendre si 3 baisses consécutives (réactivité)
VOLUME_EXIT_SELL_RATIO = 0.38     # 🔧 FIX 29/03: Constante manquante — seuil vente rouge (< 38% acheteurs)

# 🔒 Anti-perte pré-trailing (zone 0.4%→1.0%): protège les petits gains qui retombent.
# Ne s'applique PAS aux trades qui explosent (>= TRAILING_ACTIVATION), donc n'entrave pas les gros winners.
PROFIT_LOCK_ARM_PNL = 0.40        # Armer le verrou quand max_pnl a atteint +0.40%
PROFIT_LOCK_MIN_FLOOR = -0.10     # Plancher minimal toléré après armement
PROFIT_LOCK_RETRACE = 0.45        # 🔧 OPT 19/05: 0.55→0.45 — garde plus des petits gains (frais 0.35% absorbés plus facilement)
PROFIT_LOCK_RETRACE_EMA_SOFT = 0.40  # 🔧 OPT 19/05: 0.50→0.40 — cohérent avec le resserrement PROFIT_LOCK
PROFIT_LOCK_EMA_SOFT_SLOPE = -0.02   # EMA7 <= -0.02%/3 bougies => resserrer le verrou

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 5 paliers — Chandelier-style adaptatif (08/05/2026).

    Diagnostic avant 08/05 (3 paliers): capture moyenne 40% du peak sur 441 wins
    FLASH avec peak > 1%. Cause: le palier ≥3.0% à 2.0% coupait les vrais runners.
    Les peaks ≥ 2% ne dégénèrent JAMAIS en perte (0% sur BREAKOUT, 2.5% sur FLASH)
    → quand on a atteint 2%+, on peut élargir significativement.

    Paliers:
    - PnL ≥ 8.0%  → trailing -3.5% (gros runner: laisser respirer 4-5×)
    - PnL ≥ 5.0%  → trailing -2.8%
    - PnL ≥ 3.0%  → trailing -2.0% (mouvement confirmé)
    - PnL ≥ 2.0%  → trailing -1.4% (resserrer après confirmation 2%)
    - PnL ≥ 1.0%  → trailing -1.0% (pump en cours, légèrement resserré pour
                     mieux protéger les petits gains 1-2% qui finissent souvent en HARD_SL)
    - PnL < 1.0%  → trailing -0.5% (fallback — jamais atteint car bloc trailing
                     protégé par `elif pnl_pct >= TRAILING_ACTIVATION`)
    """
    if current_pnl >= 8.0:
        return 3.5    # gros runner — laisser courir
    if current_pnl >= 5.0:
        return 2.8
    if current_pnl >= 3.0:
        return 2.0    # mouvement confirmé
    if current_pnl >= 2.0:
        return 1.4    # confirmation 2% — resserrer
    if current_pnl >= 1.0:
        return 1.0    # pump en cours
    return 0.5        # fallback (non atteignable depuis _evaluate_exit)


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
HOTLIST_FILE         = os.path.join(SCRIPT_DIR, "spy_hotlist.json")       # Coins en haute surveillance (CoinGecko/Binance news)

# 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 = {"BUSDUSD", "TUSDUSD", "USDTUSD", "DAIUSD", "FDUSDUSD",
               "EURUSD", "GBPUSD", "USDPUSD", "USTCUSD"}

# ═══════════════════════════════════════════════════════════════════════════════
# 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%)
      couche7_detector : instance MarketContextDetector (None si indisponible)
    """
    ctx = {
        'regime': 'NEUTRAL',
        'btc_mom_3h': 0.0,
        'btc_mom_5h': 0.0,
        'bullish_pct': 50.0,
        'is_recovery_window': False,
        'is_freefall': False,
        'couche7_detector': None,
    }
    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

    # ─── Couche 7 : attacher le détecteur per-token (shadow mode) ───
    if _COUCHE7_AVAILABLE:
        try:
            ctx['couche7_detector'] = get_market_context_detector()
        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)
_SPY_LABEL = 'SPY-TEST' if TESTNET_MODE else 'SPY-PROD'  # 🔧 FIX 01/05: label distinct testnet vs prod
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(ParisTimeFormatter(f'%(asctime)s [{_SPY_LABEL}] %(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(f'%(asctime)s [{_SPY_LABEL}] %(message)s', datefmt='%H:%M:%S'))
logger.addHandler(file_handler)


# ─── BLACKLIST DYNAMIQUE — Gestion auto-expiry ────────────────────────────────
def _init_dynamic_blacklist():
    """
    Charge et gère la blacklist dynamique (spy_dynamic_blacklist.json).
    Appelé une fois après setup du logger.
    • Crée le fichier si absent (seed _BL_TEMP_SEED)
    • Expire les bans → graduation FLASH_SURGE_STRICT (surveillance ≥N%)
    • Auto-reban si 2+ pertes après graduation → ban permanent
    Met à jour SPY_SYMBOL_BLACKLIST et FLASH_SURGE_STRICT en place.
    """
    now = datetime.utcnow()
    if os.path.exists(_BL_JSON_FILE):
        try:
            with open(_BL_JSON_FILE, 'r', encoding='utf-8') as _f:
                data = json.load(_f)
        except Exception:
            data = {}
    else:
        data = {}

    # Merger les seeds manquants (sans écraser l'état existant)
    existing_syms = {e['symbol'] for e in data.get('entries', [])}
    entries = data.get('entries', [])
    for seed in _BL_TEMP_SEED:
        if seed['symbol'] not in existing_syms:
            entries.append(dict(seed, status='banned'))

    changed = not os.path.exists(_BL_JSON_FILE)

    for entry in entries:
        sym    = entry['symbol']
        status = entry.get('status', 'banned')

        if status == 'permanent':
            SPY_SYMBOL_BLACKLIST.add(sym)
            continue

        if status == 'released':
            continue

        if status == 'graduated_strict':
            # Surveillance active — auto-reban si 2+ pertes depuis graduation
            try:
                _hist_file = os.path.join(SCRIPT_DIR, 'espion_history.json')
                with open(_hist_file, 'r', encoding='utf-8') as _hf:
                    _hist = json.load(_hf)
                _grad_at = datetime.fromisoformat(entry.get('graduated_at', '2000-01-01T00:00:00'))
                _coin_tr = [t for t in _hist if t.get('symbol') == sym]
                _since   = [t for t in _coin_tr
                            if datetime.fromisoformat(
                                t.get('entry_time', '2000-01-01T00:00:00')[:19]) > _grad_at]
                _losses  = sum(1 for t in _since if t.get('pnl_usdt', 0) <= 0)
                if _losses >= 2:
                    entry['status'] = 'permanent'
                    SPY_SYMBOL_BLACKLIST.add(sym)
                    changed = True
                    logger.warning(f"🔴 [BL-DYN] {sym} → BAN PERMANENT ({_losses} pertes post-graduation {_grad_at.date()})")
                    continue
            except Exception:
                pass
            # Ajouter en FLASH_SURGE_STRICT pour surveillance
            thresh = entry.get('strict_thresh', 2.0)
            if sym not in FLASH_SURGE_STRICT:
                FLASH_SURGE_STRICT[sym] = thresh
            logger.info(f"   🔍 [BL-DYN] {sym}: surveillance STRICT ≥{thresh}% (gradué {entry.get('graduated_at','?')[:10]})")
            continue

        # status == 'banned'
        try:
            _banned_at = datetime.fromisoformat(entry.get('banned_at', ''))
            _duration  = entry.get('duration_days', 1)
            _expiry    = _banned_at + timedelta(days=_duration)
            _remaining = (_expiry - now).days
            if now >= _expiry:
                if entry.get('graduate_strict', True):
                    entry['status']      = 'graduated_strict'
                    entry['graduated_at'] = now.isoformat()
                    thresh = entry.get('strict_thresh', 2.0)
                    FLASH_SURGE_STRICT[sym] = thresh
                    changed = True
                    logger.info(f"✅ [BL-DYN] {sym} → RELÂCHÉ après {_duration}j → STRICT ≥{thresh}% (surveillance)")
                else:
                    entry['status'] = 'released'
                    changed = True
                    logger.info(f"✅ [BL-DYN] {sym} → RELÂCHÉ (ban {_duration}j expiré)")
            else:
                SPY_SYMBOL_BLACKLIST.add(sym)
                logger.info(f"   🔒 [BL-DYN] {sym}: {_remaining}j restants (expire {_expiry.date()}) — {entry.get('reason','')}")
        except Exception:
            SPY_SYMBOL_BLACKLIST.add(sym)  # Sécurité: garder blacklisté si erreur parse

    data['entries'] = entries
    if changed:
        try:
            with open(_BL_JSON_FILE, 'w', encoding='utf-8') as _f:
                json.dump(data, _f, indent=2, ensure_ascii=False)
        except Exception as _e:
            logger.warning(f"⚠️ [BL-DYN] Erreur écriture {_BL_JSON_FILE}: {_e}")

    _active     = sorted(SPY_SYMBOL_BLACKLIST - _BL_PERMANENT)
    _surveilled = sorted(k for k in FLASH_SURGE_STRICT
                         if k not in {'NOMUSD', '0GUSD', 'GIGGLEUSD', 'BROCCOLI714USD', 'HIVEUSD'})
    logger.info(f"   📋 [BL-DYN] Permanents: {', '.join(sorted(_BL_PERMANENT))}")
    if _active:
        logger.info(f"   📋 [BL-DYN] Temp actifs: {', '.join(_active)}")
    if _surveilled:
        logger.info(f"   📋 [BL-DYN] Surveillés (STRICT): {', '.join(_surveilled)}")

_init_dynamic_blacklist()


# ───────────────────────────────────────────────────────────────────────────────
# HOTLIST DYNAMIQUE — Haute surveillance pré-détection (CoinGecko trending + Binance listings)
# ───────────────────────────────────────────────────────────────────────────────
# Principe :
#  • Sources objectives : CoinGecko trending (top 7, 12h) + Binance new listings (48h)
#  • Si un coin est “hot” selon ces sources ET qu'un surge est détecté :
#    → ENTRY_FILTER assoupli de ≤3/5 → ≤2/5 (1 règle IA de moins requise)
#    → Log 🔥 [HOTLIST] à chaque surge sur ce coin
#  • Persisté dans spy_hotlist.json (visible sur le dashboard)
#  • Refresh toutes les ~5min (40 scans × 7s) — jamais bloquant (timeout 3s)
# ───────────────────────────────────────────────────────────────────────────────
_HOTLIST: dict = {}   # {symbol_usdc: {source, reason, added_at, expires_at}}

# AI OPPORTUNITIES — scores IA chargés depuis ai_opportunities.json
# ───────────────────────────────────────────────────────────────────────────────
# Clé: symbol (ex: DOGEUSDT), valeur: {score, rank, selected, gain_potential}
# Refresh toutes les ~5min (40 scans), même cadence que la hotlist
# Usage: assouplit ENTRY_FILTER si score ≥ 60 (même effet que hotlist)
# ───────────────────────────────────────────────────────────────────────────────
_AI_OPP_SCORES: dict = {}   # {symbol: {score, rank, gain_potential, selected}}
_AI_OPP_FILE = os.path.join(SCRIPT_DIR, 'ai_opportunities.json')


def _refresh_ai_opp_scores():
    """Charge les scores IA depuis ai_opportunities.json (lecture seule, non bloquant)."""
    global _AI_OPP_SCORES
    try:
        with open(_AI_OPP_FILE, 'r', encoding='utf-8') as _f:
            _data = json.load(_f)
        _opps = _data.get('opportunities', [])
        _new_scores = {}
        for _o in _opps:
            _sym = _o.get('symbol', '')
            if _sym:
                _new_scores[_sym] = {
                    'score':          round(_o.get('opportunity_score', 0), 1),
                    'rank':           _o.get('rank', 99),
                    'gain_potential': round(_o.get('gain_potential', 0), 2),
                    'selected':       _o.get('selected', False),
                    'direction':      _o.get('predictions', {}).get('direction', 'NEUTRAL'),
                    'action':         _o.get('action', 'SKIP'),
                    'fiability':      _o.get('fiability', 0),
                }
        _AI_OPP_SCORES = _new_scores
        _top5 = sorted(_new_scores.items(), key=lambda x: x[1]['score'], reverse=True)[:5]
        if _top5:
            _t = ', '.join(f"{s}={v['score']:.0f}" for s, v in _top5)
            logger.info(f"   🧠 [AI_OPP] {len(_new_scores)} coins chargés — TOP5: {_t}")
    except FileNotFoundError:
        pass  # Fichier pas encore généré
    except Exception as _e:
        logger.debug(f"[AI_OPP] Erreur refresh: {_e}")


def _fetch_hotlist_coins() -> dict:
    """
    Interroge CoinGecko trending et Binance new listings.
    Retourne {XYZUSDC: {source, reason, added_at, expires_at}}.
    Timeouts 3s — jamais bloquant sur le chemin de détection.
    """
    coins = {}
    now = datetime.utcnow()
    now_str = now.isoformat()

    # ── CoinGecko trending (top 7) ───────────────────────────────────────
    try:
        resp = requests.get(
            'https://api.coingecko.com/api/v3/search/trending',
            timeout=3,
            headers={'User-Agent': 'CryptoSpy/1.0', 'Accept': 'application/json'},
        )
        if resp.status_code == 200:
            expires_cg = (now + timedelta(hours=12)).isoformat()
            for i, item in enumerate(resp.json().get('coins', [])[:7], 1):
                sym_raw = item.get('item', {}).get('symbol', '').upper().strip()
                if sym_raw:
                    sym = sym_raw + 'USD'
                    coins[sym] = {
                        'source':     'coingecko_trending',
                        'reason':     f'CoinGecko trending #{i}',
                        'added_at':   now_str,
                        'expires_at': expires_cg,
                        'ef_relax':   True,
                    }
    except Exception:
        pass

    # ── Binance new listings ─────────────────────────────────────────
    # 🔧 Migration Kraken 07/08/2026: DÉSACTIVÉ — source Binance-only (annonces de
    # listing propres à Binance), sans équivalent Kraken direct. Laissé en place
    # (code mort, jamais appelé) pour référence future si un flux Kraken équivalent
    # est identifié.
    if False:
        resp = requests.get(
            'https://www.binance.com/bapi/composite/v1/public/cms/article/catalog/list/query'
            '?catalogId=48&pageNo=1&pageSize=15',
            timeout=3,
            headers={'User-Agent': 'Mozilla/5.0'},
        )
        if resp.status_code == 200:
            articles = (resp.json().get('data') or {}).get('articles') or []
            expires_bl = (now + timedelta(hours=48)).isoformat()
            for art in articles:
                title = art.get('title', '')
                m = re.search(r'(?:Will List|Lists)\s+\S+\s+\(([A-Z0-9]+)\)', title)
                if m:
                    sym = m.group(1) + 'USD'
                    coins[sym] = {
                        'source':     'binance_listing',
                        'reason':     f'Binance new listing: {title[:70]}',
                        'added_at':   now_str,
                        'expires_at': expires_bl,
                        'ef_relax':   True,
                    }

