#!/usr/bin/env python3
"""
spy_crashtest_30d.py  —  Crash-test complet du Market SPY sur 30 jours réels
═══════════════════════════════════════════════════════════════════════════════

Télécharge les klines 1m Binance Production pour les N paires les plus actives,
simule EXACTEMENT la logique de market_spy.py (détection surge, confirmation,
gestion de position) et calcule le P&L total sur la période.

Permet de :
  - Comparer paramètres ANCIENS vs ACTUELS (FIX 15/03)
  - Identifier les types de surges les plus rentables
  - Optimiser les seuils (--optimize)
  - Mesurer l'impact de chaque correctif (anti-mèche, instant-reversal, etc.)

Usage:
  python spy_crashtest_30d.py                  # 30 jours, top 30 paires
  python spy_crashtest_30d.py --days 7         # 7 jours uniquement
  python spy_crashtest_30d.py --pairs 50       # Top 50 paires
  python spy_crashtest_30d.py --no-cache       # Re-télécharger les données
  python spy_crashtest_30d.py --optimize       # Recherche params optimaux
  python spy_crashtest_30d.py --verbose        # Afficher chaque trade simulé
═══════════════════════════════════════════════════════════════════════════════
"""

import os, sys, json, time, pickle, argparse, math
import requests
import numpy as np
from datetime import datetime, timedelta
from collections import defaultdict

# Fix encodage Windows (cp1252 ne supporte pas les caractères Unicode fancy)
if sys.stdout.encoding and sys.stdout.encoding.lower() in ('cp1252', 'cp850', 'ascii'):
    sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    sys.stderr.reconfigure(encoding='utf-8', errors='replace')

SCRIPT_DIR  = os.path.dirname(os.path.abspath(__file__))
CACHE_FILE  = os.path.join(SCRIPT_DIR, "spy_crash_cache.pkl")
REPORT_FILE = os.path.join(SCRIPT_DIR, "spy_crash_report.json")
PROD_API    = "https://api.binance.com"

STABLECOINS = {"BUSDUSDT","TUSDUSDT","USDCUSDT","DAIUSDT","FDUSDUSDT",
               "EURUSDT","GBPUSDT","USDPUSDT","USTCUSDT","PYUSDUSDT"}
BLACKLIST   = {"ZECUSDT","BANANAUSDT"}

# ═══════════════════════════════════════════════════════════════════════════════
# PARAMÈTRES DE SIMULATION
# ═══════════════════════════════════════════════════════════════════════════════

# Trailing tiers: liste de (seuil_pnl_pct, trail_distance_pct)
# Si pnl_actuel < seuil → appliquer ce trail
_TRAIL_TIERS = [
    (0.5,  0.3),   # PnL < 0.5%  → trail -0.3%
    (1.0,  0.5),   # PnL 0.5-1%  → trail -0.5%
    (2.0,  0.7),   # PnL 1-2%    → trail -0.7%
    (3.0,  1.5),   # PnL 2-3%    → trail -1.5%
    (5.0,  1.8),   # PnL 3-5%    → trail -1.8%
    (10.0, 2.5),   # PnL 5-10%   → trail -2.5%
    (15.0, 4.0),   # PnL 10-15%  → trail -4.0%
    (999,  5.0),   # PnL > 15%   → trail -5.0%
]

# ── Paramètres ACTUELS (après fixes FIX 15/03) ─────────────────────────────
PARAMS_CURRENT = {
    # Détection de surge
    'surge_flash_pct':   0.8,   # FLASH: hausse min close-to-close (%)
    'surge_build_pct':   1.3,   # BUILDING: hausse min sur 2 candles (%)
    'surge_trend_pct':   1.0,   # TRENDING: hausse min sur 8 candles (%)
    'surge_accel_pct':   1.0,   # ACCELERATING: min sur 2 candles (FIX 15/03: 0.5→1.0)
    'flash_spike_fac':   1.0,   # Facteur: high/close[i-1] ≥ surge_flash * factor → spike intra-candle
    # Confirmation
    'vol_ratio_min':     1.5,   # Vol ratio minimum
    'green_min':         2,     # Bougies vertes min sur 3
    'mom_min':           0.1,   # Momentum 3 candles minimum (%)
    # Anti-mèche (FIX 15/03 — désactivé dans PARAMS_OLD)
    'wick_max_pct':      0.6,   # Mèche haute max depuis high (%) → wick_trap
    'live_retrace_pct':  0.4,   # Retrace depuis detect_price jusqu'à entry_price (%)
    # Stop Loss
    'hard_sl_pct':       1.0,   # Hard SL (FIX 15/03: 1.5→1.0)
    'early_sl_pct':      0.6,   # Early SL seuil (FIX 15/03: 0.8→0.6)
    'early_sl_sec':      120,   # Délai early SL en secondes (FIX 15/03: 180→120)
    'early_sl_maxpnl':   0.2,   # Max PnL avant trigger early SL (FIX 15/03: 0.3→0.2)
    # Instant reversal (FIX 15/03 — nouveau)
    'instant_rev_sec':   45,    # Fenêtre d'instant reversal (s)
    'instant_rev_trig':  -0.5,  # PnL seuil déclencheur (%)
    # Trailing stop (inchangé)
    'trail_activation':  0.5,   # Activer trailing après X% gain
    'trail_tiers':       _TRAIL_TIERS,
    # Holding
    'max_hold_min':      20,    # Max holding time (minutes)
    'stag_min':          10,    # Stagnation exit (minutes)
    'stag_pnl_min':      0.5,   # PnL min pour ne pas être "en stagnation"
    # Position
    'position_usdt':     500,
}

# ── Paramètres ANCIENS (avant FIX 15/03) — référence de comparaison ─────────
PARAMS_OLD = {k: v for k, v in PARAMS_CURRENT.items()}
PARAMS_OLD.update({
    # Surge
    'surge_flash_pct':   0.9,   # Plus strict (ratait BUILDING → BANANAS31)
    'surge_build_pct':   1.5,
    'surge_accel_pct':   0.5,   # Laissait passer CFX à 0.774% → -5.53€
    # Confirmation: pas de filtre anti-mèche
    'wick_max_pct':      999.0,
    'live_retrace_pct':  999.0,
    # Stop Loss
    'hard_sl_pct':       1.5,   # Plus large → pertes plus grandes
    'early_sl_pct':      0.8,
    'early_sl_sec':      180,
    'early_sl_maxpnl':   0.3,
    # Instant reversal: désactivé
    'instant_rev_sec':   0,
    'instant_rev_trig':  -999.0,
})


# ═══════════════════════════════════════════════════════════════════════════════
# TÉLÉCHARGEMENT ET CACHE
# ═══════════════════════════════════════════════════════════════════════════════

def get_top_symbols(n=30):
    """Récupère les N meilleures paires USDT par volume 24h."""
    print(f"  📋 Récupération top {n} paires USDT...")
    resp = requests.get(f"{PROD_API}/api/v3/ticker/24hr", timeout=20)
    resp.raise_for_status()
    tickers = resp.json()

    filtered = [
        t for t in tickers
        if (t['symbol'].endswith('USDT')
            and t['symbol'] not in STABLECOINS
            and t['symbol'] not in BLACKLIST
            and float(t.get('quoteVolume', 0)) >= 5_000_000
            and float(t.get('lastPrice', 0)) >= 0.0005
            and float(t.get('lastPrice', 0)) <= 50_000)
    ]
    filtered.sort(key=lambda x: float(x.get('quoteVolume', 0)), reverse=True)
    symbols = [t['symbol'] for t in filtered[:n]]
    print(f"  ✅ {len(symbols)} paires: {', '.join(symbols[:8])}...")
    return symbols


def fetch_klines_1m(symbol, start_dt, end_dt, session):
    """
    Télécharge les klines 1m pour un symbole sur la période demandée.
    Retourne un np.array [N, 7]: [open_time, open, high, low, close, volume, taker_buy_vol]
    """
    start_ms = int(start_dt.timestamp() * 1000)
    end_ms   = int(end_dt.timestamp()   * 1000)
    rows = []
    cur  = start_ms

    while cur < end_ms:
        params = {
            "symbol":    symbol,
            "interval":  "1m",
            "startTime": cur,
            "endTime":   min(cur + 999 * 60_000, end_ms),
            "limit":     1000,
        }
        try:
            r = session.get(f"{PROD_API}/api/v3/klines", params=params, timeout=20)
            data = r.json()
            if not data or isinstance(data, dict):
                break
            rows.extend(data)
            cur = int(data[-1][0]) + 60_000
            time.sleep(0.12)        # Respect rate limit
        except Exception as e:
            print(f"\n    ⚠️ {symbol}: {e}")
            time.sleep(2)
            break

    if not rows:
        return np.empty((0, 7), dtype=np.float64)

    arr = np.array(
        [[float(k[0]), float(k[1]), float(k[2]), float(k[3]),
          float(k[4]), float(k[5]), float(k[10])]
         for k in rows],
        dtype=np.float64
    )
    return arr


def load_or_download(symbols, days, use_cache=True):
    """
    Charge depuis le cache pickle ou télécharge depuis Binance.
    Retourne: dict {symbol: np.array [N, 7]}
    """
    # Vérifier si le cache est valide
    if use_cache and os.path.exists(CACHE_FILE):
        try:
            with open(CACHE_FILE, 'rb') as f:
                cached = pickle.load(f)
            meta = cached.get('meta', {})
            if meta.get('symbols') == symbols and meta.get('days') == days:
                n_sym = len(cached['data'])
                total = sum(len(v) for v in cached['data'].values())
                print(f"  ✅ Cache chargé: {n_sym} symboles, {total:,} candles ({days}j)")
                return cached['data']
            else:
                print("  ℹ️ Cache invalide (différent de la config demandée) → re-téléchargement")
        except Exception as e:
            print(f"  ⚠️ Erreur lecture cache: {e} → re-téléchargement")

    print(f"\n  📡 Téléchargement {len(symbols)} × {days}j de klines 1m...")
    print(f"     Durée estimée: ~{len(symbols) * days // 5} secondes\n")

    end_dt   = datetime.utcnow().replace(second=0, microsecond=0)
    start_dt = end_dt - timedelta(days=days)

    session  = requests.Session()
    session.headers.update({'User-Agent': 'SpyCrashtest/1.0'})

    data           = {}
    total_candles  = 0

    for i, symbol in enumerate(symbols, 1):
        print(f"  [{i:02d}/{len(symbols)}] {symbol:15}", end=' ', flush=True)
        arr = fetch_klines_1m(symbol, start_dt, end_dt, session)
        if len(arr):
            data[symbol]   = arr
            total_candles += len(arr)
            print(f"✅  {len(arr):6,} candles")
        else:
            print("❌  pas de données")

    mb = total_candles * 7 * 8 / 1e6
    print(f"\n  📊 Total: {total_candles:,} candles ({mb:.1f} MB)")

    # Sauvegarder
    try:
        with open(CACHE_FILE, 'wb') as f:
            pickle.dump({'meta': {'symbols': symbols, 'days': days}, 'data': data}, f, protocol=4)
        print(f"  💾 Cache sauvegardé → {os.path.basename(CACHE_FILE)}")
    except Exception as e:
        print(f"  ⚠️ Impossible de sauvegarder le cache: {e}")

    return data


# ═══════════════════════════════════════════════════════════════════════════════
# DÉTECTION DE SURGES (simule les scans 10s avec klines 1m)
# ═══════════════════════════════════════════════════════════════════════════════

def detect_surges(arr, params):
    """
    Simule la détection de surges à partir de klines 1m.

    Chaque kline 1m ≈ 6 scans de 10s. On détecte:
      FLASH       : close[i]/close[i-1] ≥ surge_flash%   (hausse rapide close-to-close)
      FLASH_SPIKE : high[i]/close[i-1]  ≥ surge_flash%   (pic intra-candle, close peut retomber)
      BUILDING    : close[i]/close[i-2] ≥ surge_build%   avec close[i] > close[i-1]
      ACCELERATING: hausse successive sur 2 deltas, minimum accel_min%
      TRENDING    : hausse soutenue sur 8 candles (≥1%), majorité upward

    Prix d'entrée = open[i+1]
    (après détection + confirmation, on achète à l'ouverture de la bougie suivante)
    """
    if len(arr) < 25:
        return []

    closes = arr[:, 4]
    highs  = arr[:, 2]
    opens  = arr[:, 1]

    surge_flash = params['surge_flash_pct'] / 100
    surge_build = params['surge_build_pct'] / 100
    surge_accel = params['surge_accel_pct'] / 100

    surges         = []
    last_surge_end = -999    # Cooldown: pas deux surges à moins de 4 candles

    for i in range(20, len(arr) - 2):    # -2 pour avoir entry = opens[i+1]
        if i - last_surge_end < 4:        # Cooldown 4 min (≈ cooldown 240s du spy)
            continue

        c0, c1  = closes[i], closes[i-1]
        c2       = closes[i-2]
        h0       = highs[i]

        if c1 <= 0 or c2 <= 0:
            continue

        surge_type     = None
        surge_strength = 0.0

        # ── FLASH (close-to-close) ──────────────────────────────────────────
        flash_cc = (c0 / c1 - 1) * 100
        if flash_cc >= params['surge_flash_pct']:
            surge_type     = "FLASH"
            surge_strength = flash_cc

        # ── FLASH_SPIKE (high intra-candle) ─────────────────────────────────
        elif (h0 / c1 - 1) * 100 >= params['surge_flash_pct']:
            surge_type     = "FLASH_SPIKE"
            surge_strength = (h0 / c1 - 1) * 100

        # ── BUILDING (hausse sur 2 candles) ─────────────────────────────────
        elif (c0 / c2 - 1) * 100 >= params['surge_build_pct'] and flash_cc > 0.2:
            surge_type     = "BUILDING"
            surge_strength = (c0 / c2 - 1) * 100

        # ── ACCELERATING ─────────────────────────────────────────────────────
        elif i >= 3:
            c3 = closes[i-3]
            if c3 > 0:
                delta_now  = (c0 / c2 - 1) * 100
                delta_prev = (c1 / c3 - 1) * 100
                if (delta_now >= params['surge_accel_pct']
                        and delta_prev > 0.15
                        and flash_cc > 0.3
                        and delta_now > delta_prev):   # Véritablement en accélération
                    surge_type     = "ACCELERATING"
                    surge_strength = delta_now

        # ── TRENDING (hausse sur 8 candles) ─────────────────────────────────
        if surge_type is None and i >= 8:
            c8 = closes[i-8]
            if c8 > 0:
                change_8 = (c0 / c8 - 1) * 100
                if change_8 >= params['surge_trend_pct'] and flash_cc > 0:
                    ups = sum(1 for j in range(i-5, i+1) if closes[j] >= closes[j-1])
                    if ups >= 4:
                        surge_type     = "TRENDING"
                        surge_strength = change_8

        if surge_type is not None:
            surges.append({
                'idx':           i,
                'time_ms':       arr[i, 0],
                'surge_type':    surge_type,
                'surge_strength': round(surge_strength, 3),
                'detect_price':  float(c0),     # Prix de fermeture de la candle de détection
                'spike_price':   float(h0),     # High (pour anti-wick)
                'entry_price':   float(opens[i+1]),  # On achète à l'open de la bougie suivante
            })
            last_surge_end = i

    return surges


# ═══════════════════════════════════════════════════════════════════════════════
# CONFIRMATION DU SURGE
# ═══════════════════════════════════════════════════════════════════════════════

def confirm_surge(arr, surge, params):
    """
    Simule confirm_surge() de market_spy.py.
    Vérifie: volume, bougies vertes, momentum, taker buy, anti-mèche, V-bounce.
    Retourne (confirmed: bool, details: dict)
    """
    i = surge['idx']
    if i < 5 or i >= len(arr):
        return False, {'rejection': ['insufficient_data']}

    # Colonnes: 0=time, 1=open, 2=high, 3=low, 4=close, 5=vol, 6=buy_vol
    slice_c = arr[max(0, i-4):i+1]
    closes = slice_c[:, 4]
    opens  = slice_c[:, 1]
    highs  = slice_c[:, 2]
    lows   = slice_c[:, 3]
    vols   = slice_c[:, 5]
    bvols  = slice_c[:, 6]

    # Volume ratio
    current_vol = vols[-1]
    prev_vol    = vols[-2] if len(vols) > 1 else current_vol
    avg_vol     = np.mean(vols[:-1]) if len(vols) > 1 else current_vol
    if avg_vol <= 0:
        avg_vol = 1.0
    vol_ratio      = current_vol / avg_vol
    prev_vol_ratio = prev_vol    / avg_vol
    best_vol_ratio = max(vol_ratio, prev_vol_ratio)

    # Taker buy ratio
    cur_buy  = bvols[-1] / vols[-1] if vols[-1] > 0 else 0.5
    prev_buy = bvols[-2] / vols[-2] if len(vols) > 1 and vols[-2] > 0 else 0.5
    best_buy = max(cur_buy, prev_buy)
    strong_buy  = best_buy >= 0.62
    sell_press  = best_buy < 0.35 and best_vol_ratio > 1.5

    # Bougies vertes (3 dernières)
    green_3 = sum(1 for j in range(-3, 0) if closes[j] > opens[j]) if len(closes) >= 3 else 0

    # Momentum 3 candles
    mom_3 = (closes[-1] / closes[-4] - 1) * 100 if len(closes) >= 4 and closes[-4] > 0 else 0.0

    # Anti-mèche (FIX 15/03)
    spike_price  = surge['spike_price']
    detect_price = surge['detect_price']
    entry_price  = surge['entry_price']

    wick_pct       = (spike_price - closes[-1]) / spike_price * 100 if spike_price > 0 else 0.0
    live_retrace   = (detect_price - entry_price) / detect_price * 100 if detect_price > 0 else 0.0

    # V-bounce
    min_low  = min(lows[-5:-1]) if len(lows) >= 5 else lows[-1]
    max_old  = max(highs[:-3])   if len(highs) > 3 else highs[-1]
    v_drop   = (min_low / max_old - 1) * 100 if max_old > 0 else 0.0

    is_strong   = surge['surge_type'] in ('FLASH', 'FLASH_SPIKE') and surge['surge_strength'] >= 1.0
    is_very_str = surge['surge_type'] in ('FLASH', 'FLASH_SPIKE') and surge['surge_strength'] >= 2.0

    reasons_rej = []

    # 1. Volume
    vol_thresh = 1.0 if is_strong else params['vol_ratio_min']
    if best_vol_ratio < vol_thresh:
        if not (strong_buy and best_vol_ratio >= vol_thresh * 0.7):
            reasons_rej.append(f"vol_low({best_vol_ratio:.1f}x<{vol_thresh}x)")

    # 2. Bougies vertes
    min_green = 1 if is_strong else params['green_min']
    if green_3 < min_green:
        reasons_rej.append(f"not_bullish({green_3}/3)")

    # 3. Momentum
    if not is_strong and mom_3 < params['mom_min']:
        reasons_rej.append(f"mom_weak({mom_3:.2f}%<{params['mom_min']}%)")
    elif is_strong and mom_3 < -1.0:
        reasons_rej.append(f"mom_crash({mom_3:.2f}%)")

    # 4. Sell pressure
    if sell_press and not is_strong:
        reasons_rej.append(f"sell_press(buy={best_buy:.0%})")

    # 5. V-bounce (>3% de chute récente)
    v_thresh = -6.0 if surge['surge_type'] == 'TRENDING' else -3.0
    if v_drop < v_thresh and not is_strong:
        reasons_rej.append(f"v_bounce(drop={v_drop:.1f}%)")

    # 6. Anti-mèche (FIX 15/03) — actif si wick_max_pct < 99
    if params.get('wick_max_pct', 999) < 99 and not is_very_str:
        if wick_pct > params['wick_max_pct']:
            reasons_rej.append(f"wick_trap(mèche={wick_pct:.1f}%>{params['wick_max_pct']}%)")

    # 7. Retrace prix live avant ordre (FIX 15/03)
    if params.get('live_retrace_pct', 999) < 99:
        if live_retrace > params['live_retrace_pct']:
            reasons_rej.append(f"live_retrace({live_retrace:.1f}%>{params['live_retrace_pct']}%)")

    confirmed = len(reasons_rej) == 0
    return confirmed, {
        'vol_ratio':    round(best_vol_ratio, 2),
        'green_3':      green_3,
        'mom_3':        round(mom_3, 3),
        'buy_ratio':    round(best_buy, 2),
        'wick_pct':     round(wick_pct, 3),
        'live_retrace': round(live_retrace, 3),
        'v_drop':       round(v_drop, 2),
        'rejection':    reasons_rej,
        'confirmed':    confirmed,
    }


# ═══════════════════════════════════════════════════════════════════════════════
# SIMULATION DE POSITION
# ═══════════════════════════════════════════════════════════════════════════════

def _get_trail(max_pnl, tiers):
    """Retourne le % de trail à appliquer selon le P&L max atteint."""
    for threshold, trail in tiers:
        if max_pnl < threshold:
            return trail
    return tiers[-1][1]


def simulate_position(arr, start_idx, entry_price, params):
    """
    Simule une position ouverte au prix entry_price à partir de start_idx.
    Applique dans l'ordre: INSTANT_REVERSAL → HARD_SL → EARLY_SL → TRAILING → MAX_HOLD → STAGNATION → REVERSAL

    Retourne un dict avec pnl_pct, max_pnl, hold_min, exit_reason, pnl_usdt, missed_gain_pct.
    """
    if start_idx >= len(arr) or entry_price <= 0:
        return None

    closes = arr[:, 4]
    highs  = arr[:, 2]
    lows   = arr[:, 3]

    max_price       = entry_price
    max_pnl         = 0.0
    trailing_active = False
    trailing_stop   = None
    consec_drops    = 0
    prev_close      = entry_price

    hard_sl_price   = entry_price * (1 - params['hard_sl_pct'] / 100)
    trail_tiers      = params['trail_tiers']

    # On scan jusqu'à max_hold_min candles (1 candle = 1 minute)
    max_idx = min(start_idx + params['max_hold_min'] + 30, len(arr))

    for j in range(start_idx, max_idx):
        high    = highs[j]
        low     = lows[j]
        close   = closes[j]
        hold_m  = j - start_idx          # minutes
        hold_s  = hold_m * 60            # secondes

        pnl_h = (high  / entry_price - 1) * 100
        pnl_l = (low   / entry_price - 1) * 100
        pnl_c = (close / entry_price - 1) * 100

        # Mettre à jour max
        if high > max_price:
            max_price = high
        if pnl_h > max_pnl:
            max_pnl = pnl_h

        # ── INSTANT REVERSAL (FIX 15/03) ──────────────────────────────────
        ir_sec  = params.get('instant_rev_sec',  0)
        ir_trig = params.get('instant_rev_trig', -999)
        if ir_sec > 0 and hold_s <= ir_sec and max_pnl < 0.05 and pnl_l <= ir_trig:
            return _mk(ir_trig, max_pnl, hold_m, "INSTANT_REVERSAL", entry_price, params)

        # ── HARD STOP LOSS ─────────────────────────────────────────────────
        if low <= hard_sl_price:
            return _mk(-params['hard_sl_pct'], max_pnl, hold_m, "HARD_SL", entry_price, params)

        # ── EARLY SL ───────────────────────────────────────────────────────
        if (hold_s   >= params['early_sl_sec']
                and max_pnl < params['early_sl_maxpnl']
                and pnl_c   < -params['early_sl_pct']):
            return _mk(pnl_c, max_pnl, hold_m, "EARLY_SL", entry_price, params)

        # ── TRAILING STOP ──────────────────────────────────────────────────
        if max_pnl >= params['trail_activation']:
            trailing_active = True

        if trailing_active:
            trail_pct     = _get_trail(max_pnl, trail_tiers)
            trailing_stop = max_price * (1 - trail_pct / 100)
            if low <= trailing_stop:
                exit_pnl = max_pnl - trail_pct
                return _mk(exit_pnl, max_pnl, hold_m, f"TRAILING(-{trail_pct}%)", entry_price, params)

        # ── MAX HOLD ───────────────────────────────────────────────────────
        if hold_m >= params['max_hold_min'] and pnl_c < 3.0:
            return _mk(pnl_c, max_pnl, hold_m, "MAX_HOLD", entry_price, params)

        # ── STAGNATION ─────────────────────────────────────────────────────
        if (hold_m >= params['stag_min']
                and pnl_c < params['stag_pnl_min']
                and pnl_c > -params['early_sl_pct']):
            return _mk(pnl_c, max_pnl, hold_m, "STAGNATION", entry_price, params)

        # ── MOMENTUM EXIT ─────────────────────────────────────────────────
        if close < prev_close:
            consec_drops += 1
        else:
            consec_drops = 0
        if consec_drops >= 3 and 0 < pnl_c < 5.0:
            return _mk(pnl_c, max_pnl, hold_m, "MOMENTUM_EXIT", entry_price, params)

        # ── REVERSAL (avait +3% puis retombe sous +1%) ────────────────────
        if max_pnl >= 3.0 and max_pnl < 8.0 and pnl_c < 1.0:
            return _mk(pnl_c, max_pnl, hold_m, "REVERSAL", entry_price, params)

        prev_close = close

    # Max hold atteint: sortir au close
    j = min(start_idx + params['max_hold_min'], len(arr) - 1)
    pnl_final = (arr[j, 4] / entry_price - 1) * 100
    return _mk(pnl_final, max_pnl, params['max_hold_min'], "END_MAX_HOLD", entry_price, params)


def _mk(pnl_pct, max_pnl, hold_min, reason, entry_price, params):
    """Construire le dict résultat d'une position."""
    pos_usdt  = params.get('position_usdt', 500)
    pnl_usdt  = pos_usdt * pnl_pct / 100
    missed    = max(0.0, max_pnl - pnl_pct)
    return {
        'pnl_pct':        round(float(pnl_pct),  3),
        'max_pnl':        round(float(max_pnl),   3),
        'hold_min':       int(hold_min),
        'exit_reason':    reason,
        'pnl_usdt':       round(float(pnl_usdt),  2),
        'missed_gain_pct': round(float(missed),   3),
    }


# ═══════════════════════════════════════════════════════════════════════════════
# BACKTEST PRINCIPAL
# ═══════════════════════════════════════════════════════════════════════════════

def run_backtest(data, params, verbose=False):
    """
    Lance le backtest complet sur tous les symboles.
    Simule au maximum SPY_MAX_POSITIONS positions simultanées (approximation).

    Retourne: (trades: list, rejected: list)
    """
    trades   = []
    rejected = []

    for symbol, arr in data.items():
        if len(arr) < 30:
            continue

        surges    = detect_surges(arr, params)
        open_until = -1     # Index jusqu'auquel ce symbole est "en position"

        for surge in surges:
            idx = surge['idx']

            # Skip si position ouverte sur ce symbole
            if idx <= open_until:
                continue

            # Confirmation
            confirmed, details = confirm_surge(arr, surge, params)

            if not confirmed:
                rejected.append({
                    'symbol':     symbol,
                    'surge_type': surge['surge_type'],
                    'strength':   surge['surge_strength'],
                    'reasons':    details.get('rejection', []),
                })
                continue

            # Simuler la position (départ à idx+1: open de la candle suivante)
            result = simulate_position(arr, idx + 1, surge['entry_price'], params)
            if result is None:
                continue

            result.update({
                'symbol':         symbol,
                'surge_type':     surge['surge_type'],
                'surge_strength': surge['surge_strength'],
                'entry_idx':      idx,
                'entry_time':     datetime.utcfromtimestamp(
                                      arr[idx, 0] / 1000
                                  ).strftime('%m-%d %H:%M'),
                'vol_ratio':      details.get('vol_ratio', 0),
                'buy_ratio':      details.get('buy_ratio', 0),
                'wick_pct':       details.get('wick_pct', 0),
            })
            trades.append(result)

            # Marquer le symbole "en position" jusqu'à la fin du hold
            open_until = idx + 1 + result['hold_min']

            if verbose:
                emoji = "✅" if result['pnl_pct'] > 0 else "❌"
                print(f"    {emoji} {symbol:12} {surge['surge_type']:12} "
                      f"str={surge['surge_strength']:5.2f}% "
                      f"PnL={result['pnl_pct']:+6.2f}% "
                      f"max={result['max_pnl']:+6.2f}% "
                      f"hold={result['hold_min']:3}m "
                      f"({result['exit_reason']})")

    return trades, rejected


# ═══════════════════════════════════════════════════════════════════════════════
# STATISTIQUES
# ═══════════════════════════════════════════════════════════════════════════════

def compute_stats(trades):
    """Calcule un dict complet de statistiques sur les trades simulés."""
    if not trades:
        return {}

    pnls       = [t['pnl_pct']  for t in trades]
    pnl_usdt   = [t['pnl_usdt'] for t in trades]
    wins       = [t for t in trades if t['pnl_pct'] > 0]
    losses     = [t for t in trades if t['pnl_pct'] <= 0]

    avg_win    = float(np.mean([t['pnl_pct'] for t in wins]))   if wins   else 0.0
    avg_loss   = float(np.mean([t['pnl_pct'] for t in losses])) if losses else 0.0
    pf         = abs(avg_win / avg_loss) if avg_loss != 0 else 99.0

    by_exit    = defaultdict(list)
    by_surge   = defaultdict(list)
    for t in trades:
        key = t['exit_reason'].split('(')[0].strip()
        by_exit[key].append(t['pnl_pct'])
        by_surge[t['surge_type']].append(t['pnl_pct'])

    return {
        'n_trades':        len(trades),
        'n_wins':          len(wins),
        'n_losses':        len(losses),
        'win_rate':        round(len(wins) / len(trades) * 100, 1) if trades else 0,
        'avg_pnl':         round(float(np.mean(pnls)), 3),
        'total_pnl_pct':   round(float(sum(pnls)), 2),
        'total_pnl_usdt':  round(float(sum(pnl_usdt)), 2),
        'avg_win':         round(avg_win,  3),
        'avg_loss':        round(avg_loss, 3),
        'profit_factor':   round(pf, 2),
        'max_pnl_trade':   round(max(pnls), 2),
        'min_pnl_trade':   round(min(pnls), 2),
        'avg_max_pnl':     round(float(np.mean([t['max_pnl'] for t in trades])), 3),
        'avg_missed':      round(float(np.mean([t['missed_gain_pct'] for t in trades])), 3),
        'avg_hold_min':    round(float(np.mean([t['hold_min'] for t in trades])), 1),
        'by_exit':  {k: {'count': len(v), 'avg_pnl': round(float(np.mean(v)), 2)}
                     for k, v in by_exit.items()},
        'by_surge': {k: {'count': len(v),
                         'avg_pnl': round(float(np.mean(v)), 2),
                         'wr_pct':  round(sum(1 for p in v if p > 0) / len(v) * 100, 0)}
                     for k, v in by_surge.items()},
    }


# ═══════════════════════════════════════════════════════════════════════════════
# AFFICHAGE
# ═══════════════════════════════════════════════════════════════════════════════

def print_report(title, trades, rejected, stats, params=None):
    """Affiche un rapport détaillé d'un jeu de résultats."""
    sep = "─" * 68
    print(f"\n{'═'*68}")
    print(f"  {title}")
    print(f"{'═'*68}")

    if not stats:
        print("  ⚠️  Aucun trade simulé pour cette configuration.")
        return

    n_total = len(trades) + len(rejected)
    confirm_rate = len(trades) / n_total * 100 if n_total else 0

    print(f"\n  📊 RÉSUMÉ")
    print(sep)
    print(f"  Surges détectés   : {n_total:>6}")
    print(f"  Surges confirmés  : {len(trades):>6}  ({confirm_rate:.0f}% taux confirmation)")
    print(f"  Surges rejetés    : {len(rejected):>6}  ({100-confirm_rate:.0f}% filtrés)")
    print(f"  Trades simulés    : {stats['n_trades']:>6}  "
          f"({stats['n_wins']}W / {stats['n_losses']}L)")
    print(sep)
    print(f"  Win Rate          : {stats['win_rate']:>6.1f}%")
    print(f"  P&L moyen / trade : {stats['avg_pnl']:>+7.3f}%")
    print(f"  P&L total         : {stats['total_pnl_pct']:>+7.2f}%  "
          f"/ {stats['total_pnl_usdt']:>+8.2f}€")
    print(f"  Meilleur trade    : {stats['max_pnl_trade']:>+7.2f}%")
    print(f"  Pire trade        : {stats['min_pnl_trade']:>+7.2f}%")
    print(f"  Gain moyen (W)    : {stats['avg_win']:>+7.3f}%")
    print(f"  Perte moyenne (L) : {stats['avg_loss']:>+7.3f}%")
    print(f"  Profit Factor     : {stats['profit_factor']:>7.2f}")
    print(f"  Gains manqués     : {stats['avg_missed']:>+7.3f}%  avg/trade")
    print(f"  Hold moyen        : {stats['avg_hold_min']:>6.1f}  min")

    print(f"\n  📉 PAR TYPE DE SORTIE")
    print(sep)
    for reason, d in sorted(stats['by_exit'].items(),
                             key=lambda x: -abs(x[1]['count'])):
        bar = "█" * min(20, d['count'])
        print(f"  {reason:>18}: {d['count']:4}x  avg={d['avg_pnl']:>+6.2f}%  {bar}")

    print(f"\n  ⚡ PAR TYPE DE SURGE")
    print(sep)
    for stype, d in sorted(stats['by_surge'].items(),
                            key=lambda x: -x[1]['count']):
        print(f"  {stype:>14}: {d['count']:4}x  "
              f"avg={d['avg_pnl']:>+6.2f}%  "
              f"WR={d['wr_pct']:3.0f}%")

    # Top 5 meilleures et 5 pires trades
    trades_sorted = sorted(trades, key=lambda t: t['pnl_pct'], reverse=True)
    print(f"\n  🏆 TOP 5 MEILLEURS TRADES")
    print(sep)
    for t in trades_sorted[:5]:
        print(f"  {t['symbol']:12} {t['surge_type']:12}  "
              f"{t['entry_time']}  "
              f"PnL={t['pnl_pct']:>+6.2f}%  "
              f"max={t['max_pnl']:>+6.2f}%  "
              f"({t['exit_reason']})")

    print(f"\n  ⚠️  TOP 5 PIRES TRADES")
    print(sep)
    for t in trades_sorted[-5:]:
        print(f"  {t['symbol']:12} {t['surge_type']:12}  "
              f"{t['entry_time']}  "
              f"PnL={t['pnl_pct']:>+6.2f}%  "
              f"({t['exit_reason']})")


def print_comparison(old_s, cur_s, opt_s=None):
    """Affiche le tableau comparatif final."""
    sep = "─" * 72
    print(f"\n{'═'*72}")
    print("  📊 TABLEAU COMPARATIF FINAL")
    print(f"{'═'*72}")

    def row(label, old_v, cur_v, opt_v=None, fmt="{:>+.2f}", higher_better=True):
        o = fmt.format(old_v) if old_v is not None else "  N/A "
        c = fmt.format(cur_v) if cur_v is not None else "  N/A "
        better = cur_v is not None and old_v is not None and (
            (cur_v > old_v) if higher_better else (cur_v < old_v)
        )
        flag = " ✅" if better else ("" if cur_v == old_v else " ❌")
        line = f"  {label:>22} │ {o:>12} │ {c:>12}{flag}"
        if opt_v is not None:
            best_flag = " 🏆" if opt_v > cur_v else ""
            line += f" │ {fmt.format(opt_v):>12}{best_flag}"
        print(line)

    header = f"  {'Métrique':>22} │ {'ANCIENS':>12} │ {'ACTUELS':>12}"
    if opt_s:
        header += f" │ {'OPTIMISÉS':>12}"
    print(header)
    print(f"  {'─'*22}─┼─{'─'*12}─┼─{'─'*12}", end='')
    print(f"─┼─{'─'*12}" if opt_s else "")

    ov = old_s if old_s else {}
    cv = cur_s if cur_s else {}
    os_ = opt_s

    def g(d, k): return d.get(k, 0) if d else 0

    row("Nb Trades",        g(ov,'n_trades'),       g(cv,'n_trades'),
                            g(os_,'n_trades')       if os_ else None, fmt="{:>+.0f}")
    row("Win Rate (%)",     g(ov,'win_rate'),        g(cv,'win_rate'),
                            g(os_,'win_rate')        if os_ else None, fmt="{:>+.1f}")
    row("P&L Total (%)",    g(ov,'total_pnl_pct'),  g(cv,'total_pnl_pct'),
                            g(os_,'total_pnl_pct')  if os_ else None)
    row("P&L Total (€)",    g(ov,'total_pnl_usdt'), g(cv,'total_pnl_usdt'),
                            g(os_,'total_pnl_usdt') if os_ else None)
    row("Avg P&L (%)",      g(ov,'avg_pnl'),        g(cv,'avg_pnl'),
                            g(os_,'avg_pnl')        if os_ else None, fmt="{:>+.3f}")
    row("Profit Factor",    g(ov,'profit_factor'),  g(cv,'profit_factor'),
                            g(os_,'profit_factor')  if os_ else None, fmt="{:>.2f}")
    row("Gains manqués",    g(ov,'avg_missed'),     g(cv,'avg_missed'),
                            g(os_,'avg_missed')     if os_ else None,
        fmt="{:>+.3f}", higher_better=False)
    row("Avg Hold (min)",   g(ov,'avg_hold_min'),   g(cv,'avg_hold_min'),
                            g(os_,'avg_hold_min')   if os_ else None,
        fmt="{:>.1f}", higher_better=False)


# ═══════════════════════════════════════════════════════════════════════════════
# OPTIMISATION DES PARAMÈTRES
# ═══════════════════════════════════════════════════════════════════════════════

def _score(trades, rejected):
    """Score composite pour classer les configurations."""
    if not trades:
        return -9999.0
    s = compute_stats(trades)
    n_total  = len(trades) + len(rejected)
    filter_r = len(rejected) / n_total if n_total else 0
    return (
          s['total_pnl_usdt'] * 0.2   # P&L € principal
        + s['win_rate']        * 0.4   # Win rate
        + s['profit_factor']   * 1.0   # Qualité des trades
        - s['avg_missed']      * 0.3   # Pénalité gains manqués
        - filter_r             * 10    # Pénalité si on filtre trop (pertes de trades)
    )


def optimize_parameters(data, base_params):
    """
    Optimisation par recherche sur une grille de paramètres.
    Teste chaque paramètre indépendamment, puis affine les meilleurs.
    """
    print(f"\n{'═'*68}")
    print("  🔬 OPTIMISATION DES PARAMÈTRES")
    print(f"{'═'*68}")

    grid = {
        'hard_sl_pct':      [0.7, 0.8, 1.0, 1.2, 1.5],
        'surge_flash_pct':  [0.6, 0.7, 0.8, 1.0, 1.2],
        'surge_accel_pct':  [0.8, 1.0, 1.2, 1.5],
        'early_sl_pct':     [0.4, 0.5, 0.6, 0.8],
        'early_sl_sec':     [60, 90, 120, 150, 180],
        'early_sl_maxpnl':  [0.1, 0.2, 0.3],
        'wick_max_pct':     [0.4, 0.6, 0.8, 999],
        'live_retrace_pct': [0.3, 0.4, 0.5, 999],
        'trail_activation': [0.3, 0.5, 0.8, 1.0],
        'vol_ratio_min':    [1.0, 1.2, 1.5, 2.0],
        'stag_min':         [6, 8, 10, 13],
        'max_hold_min':     [15, 20, 30, 45],
    }

    best_params = base_params.copy()
    improvements = {}

    for param, values in grid.items():
        base_score = _score(*run_backtest(data, best_params))
        best_val   = best_params.get(param)
        best_s     = base_score
        print(f"\n  📐 {param}  (base={best_val})", end='', flush=True)

        for val in values:
            if val == best_params.get(param):
                continue
            test         = best_params.copy()
            test[param]  = val
            t, r         = run_backtest(data, test)
            s            = _score(t, r)
            print(f"  {val}→{s:.1f}", end='', flush=True)
            if s > best_s:
                best_s   = s
                best_val = val

        if best_val != best_params.get(param):
            improvements[param] = (best_params[param], best_val)
            best_params[param]  = best_val
            print(f"  ✅ {best_params.get(param)} → {best_val}")
        else:
            print(f"  ➖ inchangé ({best_val})")

    print(f"\n\n  📋 Paramètres modifiés par l'optimisation:")
    if improvements:
        for p, (old, new) in improvements.items():
            print(f"    {p:>25}: {old} → {new}")
    else:
        print("    Aucun changement — les paramètres actuels sont déjà optimaux.")

    return best_params, improvements


# ═══════════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════════

def main():
    parser = argparse.ArgumentParser(
        description='SPY Crash-test 30 jours — backtest complet Market SPY',
        formatter_class=argparse.RawTextHelpFormatter
    )
    parser.add_argument('--days',     type=int,   default=30,
                        help='Nombre de jours d\'historique (défaut: 30)')
    parser.add_argument('--pairs',    type=int,   default=30,
                        help='Top N paires par volume (défaut: 30)')
    parser.add_argument('--no-cache', action='store_true',
                        help='Forcer re-téléchargement des données')
    parser.add_argument('--optimize', action='store_true',
                        help='Rechercher les paramètres optimaux (lent)')
    parser.add_argument('--verbose',  action='store_true',
                        help='Afficher tous les trades simulés')
    parser.add_argument('--position', type=float, default=500,
                        help='Taille de position USDT (défaut: 500)')
    args = parser.parse_args()

    print("\n" + "═" * 68)
    print("  🧪 SPY CRASH-TEST 30 JOURS — Market Spy Backtester v2")
    print("═" * 68)
    print(f"  Historique  : {args.days} jours")
    print(f"  Paires      : Top {args.pairs} (volume 24h)")
    print(f"  Position    : {args.position} USDT")
    print(f"  Optimisation: {'OUI (lent)' if args.optimize else 'NON'}")
    print(f"  Cache       : {'DÉSACTIVÉ' if args.no_cache else 'ACTIVÉ'}")
    print()

    # Ajuster la taille de position
    PARAMS_CURRENT['position_usdt'] = args.position
    PARAMS_OLD['position_usdt']     = args.position

    # ── 1. Sélectionner les paires ───────────────────────────────────────────
    try:
        symbols = get_top_symbols(args.pairs)
    except Exception as e:
        print(f"  ❌ Impossible d'obtenir les paires: {e}")
        sys.exit(1)

    # ── 2. Télécharger / charger les données ─────────────────────────────────
    data = load_or_download(symbols, args.days, use_cache=not args.no_cache)
    if not data:
        print("  ❌ Aucune donnée chargée.")
        sys.exit(1)

    n_candles = sum(len(v) for v in data.values())
    print(f"\n  ✅ Données: {len(data)} symboles, {n_candles:,} candles 1m\n")

    # ── 3. Backtest ANCIENS paramètres ───────────────────────────────────────
    print("  ⏳ Backtest ANCIENS paramètres (avant FIX 15/03)...")
    old_trades, old_rej = run_backtest(data, PARAMS_OLD, verbose=args.verbose)
    old_stats = compute_stats(old_trades)
    print_report(
        f"📦 ANCIENS PARAMÈTRES — {len(old_trades)} trades / {len(old_rej)} rejetés",
        old_trades, old_rej, old_stats, PARAMS_OLD
    )

    # ── 4. Backtest ACTUELS paramètres ───────────────────────────────────────
    print("\n\n  ⏳ Backtest ACTUELS paramètres (FIX 15/03)...")
    cur_trades, cur_rej = run_backtest(data, PARAMS_CURRENT, verbose=args.verbose)
    cur_stats = compute_stats(cur_trades)
    print_report(
        f"✅ PARAMÈTRES ACTUELS (FIX 15/03) — {len(cur_trades)} trades / {len(cur_rej)} rejetés",
        cur_trades, cur_rej, cur_stats, PARAMS_CURRENT
    )

    # ── 5. Optimisation (optionnelle) ────────────────────────────────────────
    opt_stats  = None
    opt_trades = []
    opt_rej    = []

    if args.optimize:
        print("\n")
        opt_params, improvements = optimize_parameters(data, PARAMS_CURRENT)
        print("\n  ⏳ Backtest OPTIMISÉ...")
        opt_trades, opt_rej = run_backtest(data, opt_params, verbose=args.verbose)
        opt_stats = compute_stats(opt_trades)
        print_report(
            f"🏆 PARAMÈTRES OPTIMISÉS — {len(opt_trades)} trades / {len(opt_rej)} rejetés",
            opt_trades, opt_rej, opt_stats, opt_params
        )

    # ── 6. Tableau comparatif ────────────────────────────────────────────────
    print_comparison(old_stats, cur_stats, opt_stats)

    # ── 7. Analyse des faux positifs rejetés par anti-mèche ─────────────────
    # Quels surges l'ancien code aurait achetés mais que le nouveau filtre ?
    cur_reject_types = defaultdict(int)
    for r in cur_rej:
        for reason in r.get('reasons', []):
            cur_reject_types[reason.split('(')[0]] += 1

    if cur_reject_types:
        print(f"\n  {'═'*68}")
        print("  🔍 ANALYSE DES FILTRES (raisons de rejet FIX 15/03)")
        print(f"  {'─'*68}")
        for reason, count in sorted(cur_reject_types.items(),
                                     key=lambda x: -x[1]):
            print(f"   {reason:>20}: {count:4} surges filtrés")
        print(f"  Total filtré par FIX 15/03: {len(cur_rej)} surges "
              f"(dont {sum(1 for r in cur_rej if any('wick' in x or 'retrace' in x for x in r.get('reasons', [])))} anti-mèches)")

    # ── 8. Rapport JSON ──────────────────────────────────────────────────────
    report = {
        'generated_at': datetime.now().isoformat(),
        'config': {
            'days':         args.days,
            'pairs':        args.pairs,
            'position_usdt': args.position,
            'symbols':      list(data.keys()),
        },
        'old':     old_stats,
        'current': cur_stats,
    }
    if opt_stats:
        report['optimized'] = opt_stats

    try:
        with open(REPORT_FILE, 'w', encoding='utf-8') as f:
            json.dump(report, f, indent=2, default=str)
        print(f"\n  💾 Rapport JSON sauvegardé → {os.path.basename(REPORT_FILE)}")
    except Exception as e:
        print(f"\n  ⚠️ Erreur sauvegarde rapport: {e}")

    print(f"\n{'═'*68}")
    print("  ✅ Crash-test terminé.")
    print(f"{'═'*68}\n")


if __name__ == '__main__':
    main()
