#!/usr/bin/env python3
"""
Crypto Backtesting Script
Strategy: 9 EMA crosses above 21 EMA AND RSI(14) < 55
Exit: 9 EMA crosses below 21 EMA OR take profit OR stop loss
"""

import requests
import pandas as pd
import time
import json
import os
import sys
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
import ta
import warnings
warnings.filterwarnings("ignore")

# ── Configuration ────────────────────────────────────────────────────────────

STRATEGIES = [
    {"pair": "ETH-USD", "granularity": 900, "label": "ETH 15min", "stop_loss": 0.02, "take_profit": 0.04},
    {"pair": "ETH-USD", "granularity": 300, "label": "ETH 5min",  "stop_loss": 0.02, "take_profit": 0.04},
    {"pair": "BTC-USD", "granularity": 900, "label": "BTC 15min", "stop_loss": 0.02, "take_profit": 0.04},
    {"pair": "BTC-USD", "granularity": 300, "label": "BTC 5min",  "stop_loss": 0.02, "take_profit": 0.04},
    {"pair": "SOL-USD", "granularity": 900, "label": "SOL 15min", "stop_loss": 0.03, "take_profit": 0.06},
    {"pair": "SOL-USD", "granularity": 300, "label": "SOL 5min",  "stop_loss": 0.03, "take_profit": 0.06},
]

STARTING_BALANCE  = 1000.0
POSITION_SIZE_PCT = 0.10
MAX_POSITIONS     = 2
RSI_PERIOD        = 14
EMA_FAST          = 9
EMA_SLOW          = 21
RSI_THRESHOLD     = 55    # entry threshold

BASE_URL                 = "https://api.exchange.coinbase.com"
CANDLES_PER_REQUEST      = 300
MONTHS_BACK              = 6
SLEEP_BETWEEN_REQUESTS   = 0.35
RETRY_ATTEMPTS           = 3
RETRY_DELAY              = 30

OUTPUT_DIR  = "/Users/chip/.openclaw/workspace-cody/backtest"
OUTPUT_HTML = os.path.join(OUTPUT_DIR, "results.html")


# ── Data Fetching ─────────────────────────────────────────────────────────────

def fetch_candles_chunk(
    pair: str,
    granularity: int,
    start_ts: int,
    end_ts: int,
) -> Optional[List[List]]:
    """Fetch a single chunk of candles with retry logic."""
    url    = f"{BASE_URL}/products/{pair}/candles"
    params = {"granularity": granularity, "start": start_ts, "end": end_ts}
    for attempt_num in range(RETRY_ATTEMPTS):
        try:
            resp = requests.get(url, params=params, timeout=30)
            if resp.status_code == 200:
                return resp.json()
            elif resp.status_code == 429:
                wait = RETRY_DELAY * (attempt_num + 1)
                print(f"\n  Rate limited, waiting {wait}s...")
                time.sleep(wait)
            else:
                print(f"\n  HTTP {resp.status_code}, retrying...")
                time.sleep(RETRY_DELAY)
        except Exception as e:
            print(f"\n  Error: {e}, retrying...")
            time.sleep(RETRY_DELAY)
    return None


def fetch_all_candles(pair: str, granularity: int, label: str) -> pd.DataFrame:
    """Fetch 6 months of candles by paginating backwards from now."""
    now_ts     = int(time.time())
    start_ts   = now_ts - (MONTHS_BACK * 30 * 24 * 3600)
    chunk_size = CANDLES_PER_REQUEST * granularity

    total_seconds = now_ts - start_ts
    total_chunks  = (total_seconds + chunk_size - 1) // chunk_size

    all_candles = []
    chunk_end   = now_ts
    chunk_num   = 0

    while chunk_end > start_ts:
        chunk_start = max(chunk_end - chunk_size, start_ts)
        chunk_num  += 1
        print(f"  Fetching {label}... chunk {chunk_num}/{total_chunks}", end="\r", flush=True)
        data = fetch_candles_chunk(pair, granularity, chunk_start, chunk_end)
        if data:
            all_candles.extend(data)
        chunk_end = chunk_start
        time.sleep(SLEEP_BETWEEN_REQUESTS)

    print(f"  Fetching {label}... done! Got {len(all_candles)} candles        ")

    if not all_candles:
        return pd.DataFrame()

    df = pd.DataFrame(all_candles, columns=["timestamp", "low", "high", "open", "close", "volume"])
    df["timestamp"] = pd.to_datetime(df["timestamp"], unit="s", utc=True)
    df = df.sort_values("timestamp").drop_duplicates("timestamp").reset_index(drop=True)
    return df


# ── Indicators ────────────────────────────────────────────────────────────────

def add_indicators(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()
    df["ema_fast"]     = ta.trend.ema_indicator(df["close"], window=EMA_FAST)
    df["ema_slow"]     = ta.trend.ema_indicator(df["close"], window=EMA_SLOW)
    df["rsi"]          = ta.momentum.rsi(df["close"], window=RSI_PERIOD)
    df["ema_cross_up"] = (
        (df["ema_fast"] > df["ema_slow"]) &
        (df["ema_fast"].shift(1) <= df["ema_slow"].shift(1))
    )
    df["ema_cross_down"] = (
        (df["ema_fast"] < df["ema_slow"]) &
        (df["ema_fast"].shift(1) >= df["ema_slow"].shift(1))
    )
    return df


# ── Backtesting Engine ────────────────────────────────────────────────────────

class Position:
    def __init__(
        self,
        entry_price: float,
        size_usd: float,
        stop_loss_pct: float,
        take_profit_pct: float,
        entry_time: Any,
    ):
        self.entry_price       = entry_price
        self.size_usd          = size_usd
        self.qty               = size_usd / entry_price
        self.stop_loss_price   = entry_price * (1 - stop_loss_pct)
        self.take_profit_price = entry_price * (1 + take_profit_pct)
        self.entry_time        = entry_time

    def current_value(self, price: float) -> float:
        return self.qty * price


def run_backtest(
    df: pd.DataFrame,
    stop_loss_pct: float,
    take_profit_pct: float,
    rsi_threshold: float,
) -> Dict[str, Any]:
    """Run the backtest simulation. Returns stats + equity curve."""
    balance       = STARTING_BALANCE
    positions: List[Position] = []
    closed_trades: List[Dict] = []
    equity_curve:  List[Dict] = []

    sample_every = max(1, len(df) // 2000)

    for i, row in enumerate(df.itertuples()):
        # ── Check exits ────────────────────────────────────────────────────
        to_close = []
        for pos in positions:
            exit_price  = None
            exit_reason = None

            if float(row.low) <= pos.stop_loss_price:
                exit_price  = pos.stop_loss_price
                exit_reason = "stop_loss"
            elif float(row.high) >= pos.take_profit_price:
                exit_price  = pos.take_profit_price
                exit_reason = "take_profit"
            elif bool(row.ema_cross_down):
                exit_price  = float(row.close)
                exit_reason = "ema_cross"

            if exit_reason:
                pnl    = (exit_price - pos.entry_price) * pos.qty
                pnl_pct = pnl / pos.size_usd * 100
                balance += pos.size_usd + pnl
                closed_trades.append({
                    "entry_time":  pos.entry_time,
                    "exit_time":   row.timestamp,
                    "entry_price": pos.entry_price,
                    "exit_price":  exit_price,
                    "pnl":         pnl,
                    "pnl_pct":     pnl_pct,
                    "reason":      exit_reason,
                })
                to_close.append(pos)

        for pos in to_close:
            positions.remove(pos)

        # ── Check entry ─────────────────────────────────────────────────────
        rsi_val = float(row.rsi) if not pd.isna(row.rsi) else 999.0
        if (
            bool(row.ema_cross_up)
            and rsi_val < rsi_threshold
            and len(positions) < MAX_POSITIONS
            and balance >= 1.0
        ):
            pos_size = balance * POSITION_SIZE_PCT
            balance -= pos_size
            positions.append(Position(
                entry_price     = float(row.close),
                size_usd        = pos_size,
                stop_loss_pct   = stop_loss_pct,
                take_profit_pct = take_profit_pct,
                entry_time      = row.timestamp,
            ))

        # ── Record equity ───────────────────────────────────────────────────
        if i % sample_every == 0:
            open_val     = sum(p.current_value(float(row.close)) for p in positions)
            total_equity = balance + open_val
            equity_curve.append({
                "time":   row.timestamp.isoformat(),
                "equity": round(total_equity, 2),
            })

    # Close remaining positions at last price
    if len(df) > 0:
        last = df.iloc[-1]
        for pos in positions:
            ep  = float(last["close"])
            pnl = (ep - pos.entry_price) * pos.qty
            balance += pos.size_usd + pnl
            closed_trades.append({
                "entry_time":  pos.entry_time,
                "exit_time":   last["timestamp"],
                "entry_price": pos.entry_price,
                "exit_price":  ep,
                "pnl":         pnl,
                "pnl_pct":     pnl / pos.size_usd * 100,
                "reason":      "end_of_data",
            })
        positions.clear()

    final_balance = balance
    total_trades  = len(closed_trades)
    wins          = [t for t in closed_trades if t["pnl"] > 0]
    win_rate      = len(wins) / total_trades * 100 if total_trades > 0 else 0
    best_trade    = max((t["pnl_pct"] for t in closed_trades), default=0.0)
    worst_trade   = min((t["pnl_pct"] for t in closed_trades), default=0.0)
    total_return  = (final_balance - STARTING_BALANCE) / STARTING_BALANCE * 100

    max_drawdown = 0.0
    if equity_curve:
        equities = [e["equity"] for e in equity_curve]
        peak = equities[0]
        for eq in equities:
            if eq > peak:
                peak = eq
            dd = (peak - eq) / peak * 100 if peak > 0 else 0
            if dd > max_drawdown:
                max_drawdown = dd

    return {
        "final_balance":    round(final_balance, 2),
        "total_return_pct": round(total_return, 2),
        "total_trades":     total_trades,
        "win_rate":         round(win_rate, 1),
        "max_drawdown":     round(max_drawdown, 2),
        "best_trade":       round(best_trade, 2),
        "worst_trade":      round(worst_trade, 2),
        "equity_curve":     equity_curve,
        "trades":           closed_trades,
    }


def compute_signal_stats(df: pd.DataFrame) -> Dict[str, Any]:
    """Compute signal statistics: how often EMA crosses up and what RSI was."""
    cross_ups      = df[df["ema_cross_up"]].copy()
    cross_ups_thr  = cross_ups[cross_ups["rsi"] < RSI_THRESHOLD]

    rsi_at_crossups = cross_ups["rsi"].dropna()

    return {
        "total_cross_ups":       len(cross_ups),
        "cross_ups_rsi_lt_thr":  len(cross_ups_thr),
        "min_rsi_at_crossup":    round(float(rsi_at_crossups.min()), 1) if len(rsi_at_crossups) else None,
        "avg_rsi_at_crossup":    round(float(rsi_at_crossups.mean()), 1) if len(rsi_at_crossups) else None,
        "rsi_histogram":         compute_rsi_histogram(rsi_at_crossups),
    }


def compute_rsi_histogram(rsi_series: pd.Series) -> List[Dict]:
    """Bucket RSI values at cross-up events into bins for the report."""
    if rsi_series.empty:
        return []
    bins   = [0, 30, 35, 40, 45, 50, 55, 60, 70, 100]
    labels = ["<30", "30-35", "35-40", "40-45", "45-50", "50-55", "55-60", "60-70", "70+"]
    counts = pd.cut(rsi_series, bins=bins, labels=labels).value_counts().reindex(labels, fill_value=0)
    return [{"bucket": k, "count": int(v)} for k, v in counts.items()]


# ── HTML Report ───────────────────────────────────────────────────────────────

COLOR_MAP = {
    "ETH 15min": {"color": "#4e9af1", "dash": False},
    "ETH 5min":  {"color": "#4e9af1", "dash": True},
    "BTC 15min": {"color": "#f7931a", "dash": False},
    "BTC 5min":  {"color": "#f7931a", "dash": True},
    "SOL 15min": {"color": "#9945ff", "dash": False},
    "SOL 5min":  {"color": "#9945ff", "dash": True},
}

def make_datasets(results: List[Dict], color_map: Dict, suffix: str = "") -> List[Dict]:
    datasets = []
    for r in results:
        label = r["label"] + suffix
        info  = color_map.get(r["label"], {"color": "#aaaaaa", "dash": False})
        color = info["color"]
        is_dash = info["dash"]
        dataset: Dict[str, Any] = {
            "label":           label,
            "data":            [{"x": e["time"], "y": e["equity"]} for e in r["equity_curve"]],
            "borderColor":     color,
            "backgroundColor": color + "22",
            "borderWidth":     2,
            "pointRadius":     0,
            "tension":         0.1,
            "fill":            False,
        }
        if is_dash:
            dataset["borderDash"] = [6, 4]
        datasets.append(dataset)
    return datasets


def table_rows_html(results: List[Dict]) -> str:
    rows = ""
    for r in results:
        label     = r["label"]
        info      = COLOR_MAP.get(label, {"color": "#aaaaaa"})
        color     = info["color"]
        ret       = r["total_return_pct"]
        ret_color = "#4ade80" if ret > 0 else ("#f87171" if ret < 0 else "#8b949e")
        win_color = "#4ade80" if r["win_rate"] >= 50 else ("#fbbf24" if r["win_rate"] > 0 else "#8b949e")

        rows += f"""
        <tr>
          <td><span class="dot" style="background:{color}"></span>{label}</td>
          <td>${r['final_balance']:,.2f}</td>
          <td style="color:{ret_color};font-weight:600">{ret:+.2f}%</td>
          <td>{r['total_trades']}</td>
          <td style="color:{win_color}">{r['win_rate']:.1f}%</td>
          <td style="color:#f87171">-{r['max_drawdown']:.2f}%</td>
          <td style="color:#4ade80">{'+' if r['best_trade'] > 0 else ''}{r['best_trade']:.2f}%</td>
          <td style="color:#f87171">{r['worst_trade']:.2f}%</td>
        </tr>"""
    return rows


def signal_stats_table(all_stats: List[Dict]) -> str:
    rows = ""
    for s in all_stats:
        label = s["label"]
        st    = s["signal_stats"]
        rows += f"""
        <tr>
          <td>{label}</td>
          <td>{st['total_cross_ups']:,}</td>
          <td style="color:#4ade80">{st['cross_ups_rsi_lt_thr']}</td>
          <td>{st['min_rsi_at_crossup'] if st['min_rsi_at_crossup'] else 'N/A'}</td>
          <td>{st['avg_rsi_at_crossup'] if st['avg_rsi_at_crossup'] else 'N/A'}</td>
        </tr>"""
    return rows


def rsi_histogram_js(all_stats: List[Dict]) -> str:
    """Build Chart.js data for the RSI histogram (just the first strategy as representative)."""
    if not all_stats:
        return "{labels:[], datasets:[]}"
    # Aggregate across all strategies
    combined: Dict[str, int] = {}
    for s in all_stats:
        for b in s["signal_stats"]["rsi_histogram"]:
            combined[b["bucket"]] = combined.get(b["bucket"], 0) + b["count"]

    labels = list(combined.keys())
    counts = list(combined.values())

    colors = []
    for lbl in labels:
        try:
            val = float(lbl.split("-")[0].replace("<", "").replace("+", ""))
        except Exception:
            val = 50
        if val < 40:
            colors.append("rgba(248,113,113,0.7)")   # red — threshold zone
        elif val < 55:
            colors.append("rgba(251,191,36,0.7)")    # yellow — near threshold
        else:
            colors.append("rgba(78,154,241,0.5)")    # blue — above threshold

    return json.dumps({
        "labels": labels,
        "datasets": [{
            "label": "EMA Cross-Up Events (all strategies combined)",
            "data": counts,
            "backgroundColor": colors,
            "borderWidth": 0,
        }]
    })


def generate_html(
    results:          List[Dict],
    all_signal_stats: List[Dict],
) -> str:
    run_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    # Chart datasets
    datasets_json = json.dumps(make_datasets(results, COLOR_MAP))

    # RSI histogram
    rsi_hist_data = rsi_histogram_js(all_signal_stats)

    # Table rows
    perf_rows = table_rows_html(results)
    sig_rows  = signal_stats_table(all_signal_stats)

    html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crypto Backtest Results</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
  *{{ margin:0; padding:0; box-sizing:border-box; }}
  body{{
    background:#0f1117; color:#e1e4e8;
    font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
    min-height:100vh; padding:24px;
  }}
  h1{{ font-size:1.5rem; font-weight:700; color:#f0f6fc; margin-bottom:4px; }}
  .subtitle{{ color:#8b949e; font-size:0.85rem; margin-bottom:20px; }}
  .card{{
    background:#161b22; border:1px solid #30363d; border-radius:10px;
    padding:18px; margin-bottom:18px;
  }}
  .card h2{{
    font-size:0.85rem; font-weight:600; color:#8b949e;
    text-transform:uppercase; letter-spacing:0.06em; margin-bottom:14px;
  }}
  .chart-wrap{{ position:relative; height:420px; }}
  .chart-wrap-sm{{ position:relative; height:260px; }}
  table{{ width:100%; border-collapse:collapse; font-size:0.82rem; }}
  thead tr{{ border-bottom:1px solid #30363d; }}
  th{{
    text-align:left; padding:7px 10px; color:#8b949e;
    font-weight:600; font-size:0.75rem; text-transform:uppercase;
    letter-spacing:0.04em; white-space:nowrap;
  }}
  td{{
    padding:9px 10px; border-bottom:1px solid #21262d;
    font-variant-numeric:tabular-nums; white-space:nowrap;
  }}
  tbody tr:hover{{ background:#1c2128; }}
  tbody tr:last-child td{{ border-bottom:none; }}
  .dot{{
    display:inline-block; width:9px; height:9px;
    border-radius:50%; margin-right:7px; vertical-align:middle;
  }}
  .legend-note{{ font-size:0.75rem; color:#8b949e; margin-top:8px; }}
  .footer{{ text-align:center; color:#484f58; font-size:0.75rem; margin-top:6px; }}
  .grid-2{{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }}
  @media(max-width:900px){{ .grid-2{{ grid-template-columns:1fr; }} }}
  .section-label{{
    display:inline-block; padding:2px 8px; border-radius:4px;
    font-size:0.7rem; font-weight:700; text-transform:uppercase;
    letter-spacing:0.07em; margin-bottom:10px;
  }}
  .label-rsi{{ background:#1f2937; color:#4ade80; border:1px solid #4ade80; }}
</style>
</head>
<body>

<h1>📊 Crypto Backtest Results — 9/21 EMA + RSI &lt; 55</h1>
<p class="subtitle">6 strategies × last 6 months of real Coinbase data &nbsp;·&nbsp; Generated {run_time}</p>

<!-- Combined Equity Curves -->
<div class="card">
  <h2>Equity Curves — All Strategies</h2>
  <div class="chart-wrap">
    <canvas id="equityChart"></canvas>
  </div>
  <p class="legend-note">
    Solid lines = 15-min &nbsp;·&nbsp; Dashed = 5-min &nbsp;·&nbsp; Grey dashed = $1,000 baseline
  </p>
</div>

<!-- Performance Table -->
<div class="card">
  <h2>Performance — RSI &lt; 55 Entry Threshold</h2>
  <span class="section-label label-rsi">RSI &lt; 55</span>
  <table>
    <thead>
      <tr>
        <th>Strategy</th><th>Final $</th><th>Return</th><th>Trades</th>
        <th>Win%</th><th>Max DD</th><th>Best</th><th>Worst</th>
      </tr>
    </thead>
    <tbody>{perf_rows}</tbody>
  </table>
</div>

<!-- Signal Analysis -->
<div class="grid-2">
  <div class="card">
    <h2>Signal Analysis — EMA Cross-Up Events</h2>
    <table>
      <thead>
        <tr>
          <th>Strategy</th>
          <th>Total Cross-Ups</th>
          <th style="color:#4ade80">RSI&lt;55</th>
          <th>Min RSI</th>
          <th>Avg RSI</th>
        </tr>
      </thead>
      <tbody>{sig_rows}</tbody>
    </table>
  </div>
  <div class="card">
    <h2>RSI Distribution at EMA Cross-Up Events (All Strategies)</h2>
    <div class="chart-wrap-sm">
      <canvas id="histChart"></canvas>
    </div>
    <p class="legend-note">
      Yellow = RSI 40–55 (entry zone) &nbsp;·&nbsp; Blue = RSI &gt; 55 (filtered out)
    </p>
  </div>
</div>

<p class="footer">
  Paper trading simulation · No fees · Position size 10% of balance · Max 2 concurrent positions · Starting balance $1,000 per strategy
</p>

<script>
// ── Equity Chart ─────────────────────────────────────────────────────────────
const allDatasets = {datasets_json};
const ctx = document.getElementById('equityChart').getContext('2d');
new Chart(ctx, {{
  type: 'line',
  data: {{ datasets: allDatasets }},
  options: {{
    responsive: true,
    maintainAspectRatio: false,
    interaction: {{ mode: 'index', intersect: false }},
    plugins: {{
      legend: {{
        position: 'top',
        labels: {{ color:'#8b949e', usePointStyle:true, padding:12, font:{{size:11}} }}
      }},
      tooltip: {{
        backgroundColor:'#161b22', borderColor:'#30363d', borderWidth:1,
        titleColor:'#8b949e', bodyColor:'#e1e4e8',
        callbacks: {{
          title: items => {{
            if (!items.length) return '';
            const d = new Date(items[0].parsed.x);
            return d.toLocaleDateString('en-US', {{month:'short',day:'numeric',year:'numeric'}});
          }},
          label: item => ` ${{item.dataset.label}}: $${{item.parsed.y.toFixed(2)}}`
        }}
      }}
    }},
    scales: {{
      x: {{
        type:'time', time:{{ unit:'week', displayFormats:{{week:'MMM d'}} }},
        grid:{{color:'#21262d'}}, ticks:{{color:'#8b949e',maxRotation:0}}
      }},
      y: {{
        grid:{{color:'#21262d'}},
        ticks:{{ color:'#8b949e', callback: v => '$'+v.toFixed(0) }}
      }}
    }},
    elements: {{ point:{{radius:0}} }}
  }},
  plugins: [{{
    id: 'baselineLine',
    afterDraw(chart) {{
      const {{ctx, chartArea, scales}} = chart;
      const y = scales.y.getPixelForValue(1000);
      if (y < chartArea.top || y > chartArea.bottom) return;
      ctx.save();
      ctx.beginPath();
      ctx.setLineDash([5,5]);
      ctx.strokeStyle = '#484f58';
      ctx.lineWidth = 1;
      ctx.moveTo(chartArea.left, y);
      ctx.lineTo(chartArea.right, y);
      ctx.stroke();
      ctx.restore();
    }}
  }}]
}});

// ── RSI Histogram ─────────────────────────────────────────────────────────────
const histData = {rsi_hist_data};
const hctx = document.getElementById('histChart').getContext('2d');
new Chart(hctx, {{
  type: 'bar',
  data: histData,
  options: {{
    responsive: true,
    maintainAspectRatio: false,
    plugins: {{
      legend: {{ display: false }},
      tooltip: {{
        backgroundColor:'#161b22', borderColor:'#30363d', borderWidth:1,
        titleColor:'#8b949e', bodyColor:'#e1e4e8',
      }}
    }},
    scales: {{
      x: {{ grid:{{color:'#21262d'}}, ticks:{{color:'#8b949e'}} }},
      y: {{
        grid:{{color:'#21262d'}}, ticks:{{color:'#8b949e'}},
        title:{{ display:true, text:'# of Cross-Up Events', color:'#8b949e' }}
      }}
    }}
  }}
}});
</script>
</body>
</html>"""
    return html


# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    print("=" * 62)
    print("  Crypto Backtest — 9/21 EMA + RSI Entry Strategy")
    print("  6 strategies × 6 months of historical data")
    print("=" * 62)

    results          = []
    all_signal_stats = []

    for strat in STRATEGIES:
        pair        = strat["pair"]
        granularity = strat["granularity"]
        label       = strat["label"]
        sl          = strat["stop_loss"]
        tp          = strat["take_profit"]

        print(f"\n{'─'*52}")
        print(f"  {label}  |  SL={sl*100:.0f}%  TP={tp*100:.0f}%")
        print(f"{'─'*52}")

        df = fetch_all_candles(pair, granularity, label)
        if df.empty:
            print(f"  ⚠️  No data for {label}, skipping.")
            continue

        print(f"  Adding indicators...")
        df = add_indicators(df)
        df = df.dropna(subset=["ema_fast", "ema_slow", "rsi"]).reset_index(drop=True)
        print(f"  Running backtest on {len(df):,} candles...")

        # Backtest with RSI < 55
        r = run_backtest(df, sl, tp, RSI_THRESHOLD)
        r["label"] = label
        results.append(r)

        # Signal stats
        sig_stats = compute_signal_stats(df)
        all_signal_stats.append({"label": label, "signal_stats": sig_stats})

        print(f"  ✅ Done!")
        print(f"     [RSI<{RSI_THRESHOLD}] Trades: {r['total_trades']}  |  Return: {r['total_return_pct']:+.2f}%  "
              f"|  Win: {r['win_rate']:.1f}%")
        print(f"     Cross-ups: {sig_stats['total_cross_ups']}  "
              f"|  Min RSI at cross: {sig_stats['min_rsi_at_crossup']}")

    if not results:
        print("\n❌ No results to report.")
        sys.exit(1)

    # Generate HTML
    print(f"\n{'='*62}")
    print("  Generating HTML report...")
    html = generate_html(results, all_signal_stats)

    os.makedirs(OUTPUT_DIR, exist_ok=True)
    with open(OUTPUT_HTML, "w") as f:
        f.write(html)

    print(f"  ✅ Report saved!")
    print(f"\n{'='*62}")
    print(f"  📊 Open your results:")
    print(f"  {OUTPUT_HTML}")
    print(f"{'='*62}\n")


if __name__ == "__main__":
    main()
