#!/usr/bin/env python3
"""
Hyperliquid Whale Tracker
==========================
Monitors the top perpetual traders on Hyperliquid and alerts when
they open new large directional positions.

Why this is different from Polymarket:
  - These are REAL leveraged positions, not prediction market bets
  - A $500K BTC long on Hyperliquid is a direct market signal
  - Positions are fully public and update in real-time
  - The trader has real PnL at risk — this is skin-in-the-game

Strategy:
  1. Pull leaderboard → top 50 wallets by all-time PnL
  2. Filter for quality: min $100K PnL, min 30-day win rate
  3. Snapshot current positions for each wallet
  4. On each scan, detect NEW positions or significant SIZE increases
  5. Alert with: coin, long/short, size, entry, leverage, liq price
  6. Flag if it's BTC/ETH (directly tradeable on your Binance perps)

Run: python hyperliquid_whale.py          → one-time scan
     python hyperliquid_whale.py --watch  → continuous, every 5 min
     python hyperliquid_whale.py --watch --interval 120  → every 2 min

Output: hl_whale_report.html
        hl_whale_alerts.txt

Requires: pip install requests
"""

import requests, json, time, math, sys, os, argparse
from datetime import datetime, timezone
from collections import defaultdict

# ── CONFIG ─────────────────────────────────────────────────
HL_API    = "https://api.hyperliquid.xyz/info"
HL_LB     = "https://stats-data.hyperliquid.xyz/Mainnet/leaderboard"
HEADERS   = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
TIMEOUT   = 12
SLEEP     = 0.25

# Leaderboard filters — who counts as a "whale" worth tracking
MIN_ALL_TIME_PNL   = 100_000   # min $100K all-time PnL
MIN_30D_PNL        = 5_000     # active recently — min $5K last 30 days
TOP_N_WALLETS      = 60        # track top N wallets from leaderboard

# Position filters — what counts as a signal
MIN_POSITION_USD   = 50_000    # ignore positions under $50K notional
MIN_SIZE_INCREASE  = 0.20      # alert if position grows by 20%+ since last scan
NEW_POSITION_MIN   = 30_000    # min size for a "new position" alert

# Alert log
ALERT_LOG = "hl_whale_alerts.txt"
STATE_FILE   = "hl_whale_state.json"   # persists position snapshots between scans
HISTORY_FILE = "hl_whale_history.jsonl" # append-only scan history for the index chart

# ── TELEGRAM ─────────────────────────────────────────────────
TG_TOKEN   = "8753350406:AAFLvNElVplrvHZ1YNt9pSRwnODvyABUBeE"
TG_CHAT_ID = "5775792943"

# Alert thresholds
# ── Aggregate thresholds (no individual position alerts) ─────────────────────
TG_CONV_THRESHOLD      = 5       # conviction score level that triggers alert
TG_BIAS_COOLDOWN       = 3600    # min seconds between bias-flip alerts
TG_SUMMARY_EVERY_N     = 6       # scan summary every N scans
TG_LONG_CHANGE_USD     = 5_000_000   # $5M change in total BTC long exposure → alert
TG_SHORT_CHANGE_USD    = 5_000_000   # $5M change in total BTC short exposure → alert
TG_RATIO_CHANGE        = 0.25    # 0.25 ratio change in one scan → alert
TG_RATIO_COOLDOWN      = 1800    # 30min between ratio/exposure change alerts

_tg_last_bias      = None    # last BTC bias
_tg_last_score     = None    # last conviction score bucket
_tg_bias_ts        = 0       # ts of last bias alert
_tg_scan_n         = 0       # scan counter
_tg_alerted_liq    = {}      # {liq_price: last_dist_pct}
_tg_prev_bl        = None    # previous scan total BTC long USD
_tg_prev_bs        = None    # previous scan total BTC short USD
_tg_prev_ratio     = None    # previous scan long/short ratio
_tg_agg_alert_ts   = 0       # ts of last aggregate exposure alert

def tg_send(text):
    """Send Telegram message. Silent fail if not configured."""
    if not TG_TOKEN or not TG_CHAT_ID:
        return False
    try:
        r = requests.post(
            f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
            json={"chat_id": TG_CHAT_ID, "text": text, "parse_mode": "HTML"},
            timeout=10
        )
        return r.ok
    except:
        return False

def tg_aggregate_change(change_type, bl_now, bs_now, bl_prev, bs_prev, btc_price, direction):
    """
    Alert when total BTC long or short exposure changes significantly across all 60 whales.
    This replaces individual position alerts — only fires on material aggregate moves.
    """
    ratio_now  = bs_now / bl_now if bl_now > 0 else 0
    ratio_prev = bs_prev / bl_prev if bl_prev > 0 else 0

    if change_type == "LONGS":
        change_usd = bl_now - bl_prev
        emoji      = "🟢" if change_usd > 0 else "🔴"
        direction_str = f"Longs {'increased' if change_usd > 0 else 'decreased'} by {fmt_usd(abs(change_usd))}"
    elif change_type == "SHORTS":
        change_usd = bs_now - bs_prev
        emoji      = "🔴" if change_usd > 0 else "🟢"
        direction_str = f"Shorts {'increased' if change_usd > 0 else 'decreased'} by {fmt_usd(abs(change_usd))}"
    else:  # RATIO
        change_usd = 0
        emoji      = "⚖️"
        direction_str = f"Ratio shifted {ratio_prev:.2f}x → {ratio_now:.2f}x"

    bias_now  = ("BULLISH" if bl_now > bs_now*1.3
                 else "BEARISH" if bs_now > bl_now*1.3
                 else "NEUTRAL")
    bias_emoji = "🐂" if bias_now == "BULLISH" else "🐻" if bias_now == "BEARISH" else "⚪"

    msg = (
        f"{emoji} <b>WHALE BOOK SHIFT — {change_type}</b>\n"
        f"{direction_str}\n"
        f"\n"
        f"Total BTC longs:  {fmt_usd(bl_now)} (was {fmt_usd(bl_prev)})\n"
        f"Total BTC shorts: {fmt_usd(bs_now)} (was {fmt_usd(bs_prev)})\n"
        f"L/S ratio:        {ratio_now:.2f}x (was {ratio_prev:.2f}x)\n"
        f"Bias:             {bias_emoji} {bias_now}\n"
        f"BTC price:        ${btc_price:,.0f}"
    )
    tg_send(msg)


def tg_bias_flip(old_bias, new_bias, btc_long_usd, btc_short_usd, score, btc_price):
    """Alert when BTC whale bias flips direction."""
    direction = "🐂 BULLISH FLIP" if new_bias == "BULLISH" else                 "🐻 BEARISH FLIP" if new_bias == "BEARISH" else "⚪ NEUTRAL"
    arrow = f"{old_bias} → {new_bias}"
    action = ""
    if new_bias == "BULLISH":
        action = "\n\n💡 Whales net long BTC — consider LONG BTCUSDT.P on Binance"
    elif new_bias == "BEARISH":
        action = "\n\n💡 Whales net short BTC — consider SHORT BTCUSDT.P on Binance"

    msg = (
        f"{direction}\n"
        f"Bias: <b>{arrow}</b>\n"
        f"\n"
        f"BTC Longs:  {fmt_usd(btc_long_usd)}\n"
        f"BTC Shorts: {fmt_usd(btc_short_usd)}\n"
        f"Conviction score: {score:+d}/±10\n"
        f"BTC price: ${btc_price:,.0f}{action}"
    )
    return tg_send(msg)

def tg_conviction_alert(score, prev_score, signals, btc_bias, btc_price):
    """Alert when conviction score crosses a key threshold."""
    if score >= 8:
        level = "🔥 EXTREME BULL"
    elif score >= 5:
        level = "🟢 BULLISH CONFLUENCE"
    elif score <= -8:
        level = "🔥 EXTREME BEAR"
    elif score <= -5:
        level = "🔴 BEARISH CONFLUENCE"
    else:
        return False  # shouldn't happen, caller checks

    # Top 3 contributing signals
    scored = [(s[0], s[2]) for s in signals if s[2] != 0]
    scored.sort(key=lambda x: abs(x[1]), reverse=True)
    top3 = "\n".join(f"  • {name}: {pts:+d}" for name, pts in scored[:3])

    msg = (
        f"📊 CONVICTION SCORE: {level}\n"
        f"Score: <b>{score:+d}/±10</b> (was {prev_score:+d})\n"
        f"BTC Whale Bias: {btc_bias}\n"
        f"BTC Price: ${btc_price:,.0f}\n"
        f"\n"
        f"Top signals:\n{top3}"
    )
    return tg_send(msg)

def tg_regime_reversal(hl_ratio, score, btc_price, fall_count):
    """Alert when the regime reversal long condition fires."""
    msg = (
        f"🔄 REGIME REVERSAL SIGNAL\n"
        f"\n"
        f"Condition: HL ratio LOW + FALLING + score OK\n"
        f"HL ratio: <b>{hl_ratio:.2f}x</b> (threshold: &lt;1.35x)\n"
        f"Falling: {fall_count} consecutive scans\n"
        f"Conviction score: {score:+d}\n"
        f"BTC: ${btc_price:,.0f}\n"
        f"\n"
        f"💡 Active strategy signal: LONG BTCUSDT.P on Binance"
    )
    return tg_send(msg)

def tg_emergency(signal_name, detail, score_impact):
    """Alert for emergency signals: stablecoin depeg, cascade risk."""
    emoji = "🚨" if score_impact <= -2 else "⚠️"
    msg = (
        f"{emoji} <b>EMERGENCY SIGNAL</b>\n"
        f"{signal_name}\n"
        f"\n"
        f"{detail}\n"
        f"Score impact: {score_impact:+d}"
    )
    return tg_send(msg)

def tg_scan_summary(scan_n, n_alerts, btc_bias, score, btc_price, n_positions):
    """Periodic scan summary — confirms script is running."""
    bias_emoji = "🐂" if btc_bias == "BULLISH" else "🐻" if btc_bias == "BEARISH" else "⚪"
    score_bar  = "▓" * max(0, score) + "░" * max(0, -score) if score != 0 else "─"
    msg = (
        f"📡 Scan #{scan_n} summary\n"
        f"BTC: ${btc_price:,.0f}\n"
        f"Whale bias: {bias_emoji} {btc_bias}\n"
        f"Conviction: {score:+d}/±10\n"
        f"New alerts: {n_alerts} | Positions tracked: {n_positions}\n"
        f"{now_str()}"
    )
    return tg_send(msg)

# ── HELPERS ────────────────────────────────────────────────

def tg_cluster_alert(coin, positions_this_scan, btc_price):
    """Alert when 3+ wallets open/increase positions in same coin in one scan."""
    n       = len(positions_this_scan)
    sides   = [p["side"] for p in positions_this_scan]
    n_long  = sides.count("LONG")
    n_short = sides.count("SHORT")
    total   = sum(p["size_usd"] for p in positions_this_scan)
    dominant = "LONG" if n_long > n_short else "SHORT" if n_short > n_long else "MIXED"
    emoji    = "🟢" if dominant == "LONG" else "🔴" if dominant == "SHORT" else "⚡"
    sizes    = " / ".join(fmt_usd(p["size_usd"]) for p in sorted(
                   positions_this_scan, key=lambda x: x["size_usd"], reverse=True)[:4])
    msg = (
        f"{emoji} <b>WHALE CLUSTER — {coin}</b>\n"
        f"{n} wallets moving {dominant} simultaneously\n"
        f"\n"
        f"Total exposure: {fmt_usd(total)}\n"
        f"Sizes: {sizes}\n"
        f"Long: {n_long} · Short: {n_short}\n"
        f"BTC: ${btc_price:,.0f}\n"
        f"\n"
        f"💡 Multiple smart wallets piling in — directional conviction signal"
    )
    tg_send(msg)


def fmt_usd(n):
    if not n or (isinstance(n, float) and math.isnan(n)): return "$0"
    n = float(n)
    if abs(n) >= 1e6: return f"${n/1e6:+.2f}M" if n < 0 else f"${n/1e6:.2f}M"
    if abs(n) >= 1e3: return f"${n/1e3:+.1f}K" if n < 0 else f"${n/1e3:.1f}K"
    return f"${n:+.0f}" if n < 0 else f"${n:.0f}"

def esc(s): return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
def now_ts(): return int(datetime.now(timezone.utc).timestamp())
def now_str(): return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")

def log_alert(msg):
    line = f"[{now_str()}] {msg}"
    print(line, flush=True)
    with open(ALERT_LOG, "a", encoding="utf-8") as f:
        f.write(line + "\n")

def hl_post(payload, retries=2):
    for attempt in range(retries + 1):
        try:
            r = requests.post(HL_API, json=payload, headers=HEADERS, timeout=TIMEOUT)
            if r.status_code == 429:
                print("  Rate limited, waiting 5s...", flush=True)
                time.sleep(5); continue
            if r.ok: return r.json()
        except Exception as e:
            if attempt == retries: return None
            time.sleep(1)
    return None

def fetch_all_mids():
    """Fetch current mid prices for all perps. Returns {COIN: float}."""
    data = hl_post({"type": "allMids"})
    if not isinstance(data, dict): return {}
    out = {}
    for k, v in data.items():
        try:
            coin = k.upper().replace("UBTC","BTC").replace("WETH","ETH")
            out[coin] = float(v)
        except: continue
    return out

# ── MULTI-SOURCE DATA FETCHERS ────────────────────────────────
BINANCE_FAPI = "https://fapi.binance.com"
ALT_ME_API   = "https://api.alternative.me"

def _get(url, params=None, timeout=8):
    """Simple GET with silent failure."""
    try:
        r = requests.get(url, params=params,
                         headers={"User-Agent": "Mozilla/5.0"}, timeout=timeout)
        if r.ok: return r.json()
    except: pass
    return None

def _deribit_get(endpoint, params=None, timeout=12):
    """Deribit-specific GET with browser headers to bypass Cloudflare."""
    base = "https://www.deribit.com/api/v2/public/"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
        "Accept": "application/json, text/plain, */*",
        "Accept-Language": "en-US,en;q=0.9",
        "Accept-Encoding": "gzip, deflate, br",
        "Origin": "https://www.deribit.com",
        "Referer": "https://www.deribit.com/",
        "Connection": "keep-alive",
        "Sec-Fetch-Dest": "empty",
        "Sec-Fetch-Mode": "cors",
        "Sec-Fetch-Site": "same-origin",
    }
    try:
        import time as _time
        _time.sleep(0.3)   # small delay — Deribit rate limits aggressive polling
        r = requests.get(base + endpoint, params=params,
                         headers=headers, timeout=timeout)
        if r.ok:
            return r.json()
        else:
            # Try alternate base URL format
            r2 = requests.get(f"https://www.deribit.com/api/v2/public/{endpoint}",
                              params=params, headers=headers, timeout=timeout)
            if r2.ok: return r2.json()
    except: pass
    return None

def fetch_binance_funding():
    """Current BTC funding rate and mark price."""
    d = _get(f"{BINANCE_FAPI}/fapi/v1/premiumIndex", {"symbol": "BTCUSDT"})
    if not d: return {}
    return {
        "funding_rate":    float(d.get("lastFundingRate", 0)),
        "mark_price":      float(d.get("markPrice", 0)),
        "next_funding_ts": int(d.get("nextFundingTime", 0)),
    }

def fetch_binance_ls_ratio():
    """Top-trader and global long/short account ratios (latest 1 period)."""
    top = _get(f"{BINANCE_FAPI}/futures/data/topLongShortAccountRatio",
               {"symbol": "BTCUSDT", "period": "1h", "limit": 1})
    glob = _get(f"{BINANCE_FAPI}/futures/data/globalLongShortAccountRatio",
                {"symbol": "BTCUSDT", "period": "1h", "limit": 1})
    result = {}
    if top and len(top) > 0:
        result["top_long_pct"]  = float(top[-1].get("longAccount", 0.5))
        result["top_short_pct"] = float(top[-1].get("shortAccount", 0.5))
        result["top_ls_ratio"]  = float(top[-1].get("longShortRatio", 1.0))
    if glob and len(glob) > 0:
        result["glob_long_pct"]  = float(glob[-1].get("longAccount", 0.5))
        result["glob_short_pct"] = float(glob[-1].get("shortAccount", 0.5))
        result["glob_ls_ratio"]  = float(glob[-1].get("longShortRatio", 1.0))
    return result

def fetch_binance_oi():
    """Current BTC open interest on Binance."""
    d = _get(f"{BINANCE_FAPI}/fapi/v1/openInterest", {"symbol": "BTCUSDT"})
    if not d: return {}
    # Also get OI history for trend (last 5 periods)
    hist = _get(f"{BINANCE_FAPI}/futures/data/openInterestHist",
                {"symbol": "BTCUSDT", "period": "1h", "limit": 5})
    oi_now = float(d.get("openInterest", 0))
    oi_trend = "flat"
    if hist and len(hist) >= 2:
        oi_prev = float(hist[0].get("sumOpenInterest", oi_now))
        oi_now2 = float(hist[-1].get("sumOpenInterest", oi_now))
        change  = (oi_now2 - oi_prev) / max(oi_prev, 1)
        oi_trend = "rising" if change > 0.005 else "falling" if change < -0.005 else "flat"
    return {"oi_btc": oi_now, "oi_trend": oi_trend}

def fetch_binance_taker_vol():
    """Taker buy vs sell volume ratio (last 4 x 15min = 1h)."""
    d = _get(f"{BINANCE_FAPI}/futures/data/takerbuySelVolume",
             {"symbol": "BTCUSDT", "period": "15m", "limit": 4})
    if not d or len(d) == 0: return {}
    buy  = sum(float(x.get("buySellRatio",1)) for x in d) / len(d)
    buyvol  = sum(float(x.get("buyVol",0)) for x in d)
    sellvol = sum(float(x.get("sellVol",0)) for x in d)
    total   = buyvol + sellvol
    buy_pct = buyvol / total if total > 0 else 0.5
    return {
        "taker_buy_pct":  buy_pct,
        "taker_sell_pct": 1 - buy_pct,
        "taker_ratio":    buy,
    }

def fetch_fear_greed():
    """Fear & Greed index (0=extreme fear, 100=extreme greed)."""
    d = _get(f"{ALT_ME_API}/fng/", {"limit": 1})
    if not d: return {}
    try:
        item = d["data"][0]
        return {
            "score":       int(item["value"]),
            "label":       item["value_classification"],
            "ts":          int(item["timestamp"]),
        }
    except: return {}

def fetch_bybit_funding():
    """Bybit BTC funding rate."""
    d = _get("https://api.bybit.com/v5/market/funding/history",
             {"category": "linear", "symbol": "BTCUSDT", "limit": 1})
    try:
        rate = float(d["result"]["list"][0]["fundingRate"])
        return {"bybit_funding": rate}
    except: return {}

def fetch_okx_funding():
    """OKX BTC funding rate."""
    d = _get("https://www.okx.com/api/v5/public/funding-rate",
             {"instId": "BTC-USDT-SWAP"})
    try:
        rate = float(d["data"][0]["fundingRate"])
        return {"okx_funding": rate}
    except: return {}

def fetch_bybit_ls_ratio():
    """Bybit long/short account ratio for BTC."""
    d = _get("https://api.bybit.com/v5/market/account-ratio",
             {"category": "linear", "symbol": "BTCUSDT", "period": "1h", "limit": 1})
    try:
        item = d["result"]["list"][0]
        bl = float(item["buyRatio"])
        sl = float(item["sellRatio"])
        return {"bybit_long_pct": bl, "bybit_short_pct": sl,
                "bybit_ls_ratio": bl / sl if sl > 0 else 1.0}
    except: return {}

def fetch_deribit_dvol():
    """Deribit BTC implied volatility index (DVOL) — crypto VIX equivalent."""
    import time as _t
    now_ms  = int(_t.time() * 1000)
    ago_ms  = now_ms - 4 * 3600 * 1000   # last 4 hours
    d = _deribit_get("get_volatility_index_data",
                      {"currency": "BTC", "resolution": "3600",
                       "start_timestamp": ago_ms, "end_timestamp": now_ms})
    try:
        rows = d["result"]["data"]   # [[ts, open, high, low, close], ...]
        dvol = float(rows[-1][4])    # last close
        prev = float(rows[-2][4]) if len(rows) >= 2 else dvol
        trend = "rising" if dvol > prev * 1.02 else "falling" if dvol < prev * 0.98 else "flat"
        return {"dvol": dvol, "dvol_trend": trend}
    except: return {}

def fetch_coingecko_dominance():
    """BTC market dominance % via CoinGecko free API."""
    d = _get("https://api.coingecko.com/api/v3/global")
    try:
        dom = float(d["data"]["market_cap_percentage"]["btc"])
        total_mcap = float(d["data"]["total_market_cap"]["usd"])
        return {"btc_dominance": dom, "total_mcap_usd": total_mcap}
    except: return {}

def fetch_coinbase_premium(binance_price):
    """Coinbase premium = Coinbase BTC price vs Binance. Positive = US buyers paying up."""
    if not binance_price: return {}
    d = _get("https://api.coinbase.com/api/v3/brokerage/market/products/BTC-USD")
    try:
        cb_price = float(d["price"])
        premium  = (cb_price - binance_price) / binance_price   # fraction
        return {"cb_price": cb_price, "cb_premium": premium}
    except: return {}

def fetch_btc_200ma_and_rv():
    """Compute BTC 200-day MA and 14-day realised volatility from Binance daily candles."""
    d = _get("https://fapi.binance.com/fapi/v1/klines",
             {"symbol": "BTCUSDT", "interval": "1d", "limit": 215})
    if not d or len(d) < 14: return {}
    try:
        closes = [float(c[4]) for c in d]
        # 200-day MA (or as many as we have)
        ma200 = sum(closes[-200:]) / min(200, len(closes))
        current = closes[-1]
        vs_ma = (current - ma200) / ma200   # positive = above MA

        # 14-day realized vol (annualised)
        import math
        rets = [math.log(closes[i]/closes[i-1]) for i in range(-14, 0)]
        std  = (sum(r**2 for r in rets) / len(rets)) ** 0.5
        rv14 = std * math.sqrt(365) * 100   # annualised %

        return {
            "btc_200ma":  ma200,
            "btc_vs_200ma": vs_ma,      # positive = above MA (bullish regime)
            "rv14":       rv14,          # 14-day realized vol annualised %
            "btc_price":  current,
        }
    except: return {}

# ── WAVE 2 SOURCES ───────────────────────────────────────────

def fetch_mempool():
    """Bitcoin mempool: fee rate and congestion level."""
    fees = _get("https://mempool.space/api/v1/fees/recommended")
    pool = _get("https://mempool.space/api/mempool")
    out  = {}
    if fees:
        out["mempool_fastest_fee"]  = fees.get("fastestFee", 0)
        out["mempool_halfhour_fee"] = fees.get("halfHourFee", 0)
        out["mempool_hour_fee"]     = fees.get("hourFee", 0)
    if pool:
        out["mempool_count"]  = pool.get("count", 0)
        out["mempool_vsize"]  = pool.get("vsize", 0)   # bytes waiting
    # Congestion label: normal <50 sat/vB, elevated 50-200, high >200
    fee = out.get("mempool_fastest_fee", 0)
    out["mempool_level"] = "high" if fee > 200 else "elevated" if fee > 50 else "normal"
    return out

def fetch_wikipedia_views():
    """Bitcoin Wikipedia page views: 7-day avg vs 30-day avg (retail attention proxy)."""
    from datetime import datetime as _dt, timedelta as _td, timezone as _tz
    import math as _m
    end   = _dt.now(_tz.utc)
    start = end - _td(days=35)
    url   = (f"https://wikimedia.org/api/rest_v1/metrics/pageviews/"
             f"per-article/en.wikipedia.org/all-access/all-agents/Bitcoin/daily/"
             f"{start.strftime('%Y%m%d')}/{end.strftime('%Y%m%d')}")
    d = _get(url)
    if not d: return {}
    try:
        items  = d["items"]
        views  = [int(x["views"]) for x in items]
        avg7   = sum(views[-7:]) / 7
        avg30  = sum(views[-30:]) / 30 if len(views) >= 30 else sum(views) / len(views)
        ratio  = avg7 / avg30 if avg30 > 0 else 1.0
        # Spike = 7d avg is 1.5x+ above 30d avg — retail arriving
        # Collapse = 7d avg is <0.6x 30d avg — retail gone
        level  = "spike" if ratio > 1.5 else "collapse" if ratio < 0.6 else "normal"
        return {
            "wiki_avg7":  int(avg7),
            "wiki_avg30": int(avg30),
            "wiki_ratio": round(ratio, 2),
            "wiki_level": level,
        }
    except: return {}

def fetch_deribit_pc_ratio():
    """Deribit BTC options put/call volume ratio — options market sentiment."""
    d = _deribit_get("get_book_summary_by_currency",
                      {"currency": "BTC", "kind": "option"})
    if not d: return {}
    try:
        put_vol  = sum(float(x.get("volume",0)) for x in d["result"] if x.get("instrument_name","").endswith("-P"))
        call_vol = sum(float(x.get("volume",0)) for x in d["result"] if x.get("instrument_name","").endswith("-C"))
        total    = put_vol + call_vol
        if total == 0: return {}
        pc_ratio = put_vol / call_vol if call_vol > 0 else 1.0
        # High P/C (>0.8) = everyone buying insurance = bearish fear
        # Low P/C (<0.3)  = complacency = contrarian bearish (tops often form here)
        # Normal range: 0.3-0.8
        sentiment = ("fear"  if pc_ratio > 0.8
                     else "complacency" if pc_ratio < 0.25
                     else "neutral")
        return {
            "pc_ratio":    round(pc_ratio, 3),
            "pc_put_vol":  round(put_vol, 1),
            "pc_call_vol": round(call_vol, 1),
            "pc_sentiment": sentiment,
        }
    except: return {}

def fetch_futures_basis():
    """BTC quarterly futures basis vs spot = institutional carry demand."""
    # Get list of active quarterly BTC contracts
    meta = _get("https://fapi.binance.com/fapi/v1/exchangeInfo")
    if not meta: return {}
    try:
        # Find quarterly contracts (not perpetual)
        quarterly = [
            s["symbol"] for s in meta.get("symbols", [])
            if s.get("baseAsset") == "BTC"
            and s.get("contractType") == "DELIVERING"  # quarterly
            and s.get("status") == "TRADING"
        ]
        if not quarterly:
            # Fallback: look for BTCUSDT_YYYYMMDD pattern
            quarterly = [
                s["symbol"] for s in meta.get("symbols", [])
                if s["symbol"].startswith("BTCUSDT_") and s.get("status") == "TRADING"
            ]
        if not quarterly: return {}
        # Sort by expiry, take nearest
        quarterly.sort()
        near_sym = quarterly[0]
        # Get price
        ticker = _get("https://fapi.binance.com/fapi/v1/ticker/price", {"symbol": near_sym})
        spot   = _get("https://fapi.binance.com/fapi/v1/ticker/price", {"symbol": "BTCUSDT"})
        if not ticker or not spot: return {}
        fut_px  = float(ticker["price"])
        spot_px = float(spot["price"])
        # Compute days to expiry from symbol name
        expiry_str = near_sym.replace("BTCUSDT_","")
        from datetime import datetime as _dt
        try:
            expiry_dt = _dt.strptime(expiry_str, "%y%m%d")
            days_left = max(1, (expiry_dt - _dt.now(_tz.utc)).days)
        except:
            days_left = 90
        basis_pct  = (fut_px - spot_px) / spot_px
        basis_ann  = basis_pct / days_left * 365 * 100  # annualised %
        structure  = ("contango" if basis_ann > 5
                      else "backwardation" if basis_ann < -1
                      else "flat")
        return {
            "basis_sym":    near_sym,
            "basis_pct":    round(basis_pct * 100, 3),
            "basis_ann":    round(basis_ann, 1),
            "basis_days":   days_left,
            "basis_struct": structure,
        }
    except Exception as e:
        return {}

def fetch_stablecoin_supply():
    """USDT + USDC total supply change over 7 days via CoinGecko."""
    def get_mcap(coin_id):
        d = _get(f"https://api.coingecko.com/api/v3/coins/{coin_id}",
                 {"localization": "false", "tickers": "false",
                  "community_data": "false", "developer_data": "false"})
        if not d: return None
        return d.get("market_data",{}).get("market_cap",{}).get("usd")
    def get_mcap_7d(coin_id):
        d = _get(f"https://api.coingecko.com/api/v3/coins/{coin_id}/market_chart",
                 {"vs_currency": "usd", "days": "7", "interval": "daily"})
        if not d: return None
        mcs = d.get("market_caps")
        if mcs and len(mcs) >= 2:
            return mcs[0][1]  # 7 days ago
        return None

    try:
        usdt_now  = get_mcap("tether")
        usdt_7d   = get_mcap_7d("tether")
        usdc_now  = get_mcap("usd-coin")
        usdc_7d   = get_mcap_7d("usd-coin")

        now_total = (usdt_now or 0) + (usdc_now or 0)
        old_total = (usdt_7d  or 0) + (usdc_7d  or 0)

        if now_total == 0: return {}
        change = now_total - old_total
        change_pct = change / old_total if old_total > 0 else 0
        trend = "expanding" if change_pct > 0.01 else "contracting" if change_pct < -0.01 else "stable"
        return {
            "stable_total_b":   round(now_total / 1e9, 2),   # billions
            "stable_7d_chg_b":  round(change / 1e9, 2),
            "stable_7d_pct":    round(change_pct * 100, 2),
            "stable_trend":     trend,
        }
    except: return {}

def fetch_polymarket_macro():
    """Search Polymarket for macro markets (Fed, inflation, recession) and return key odds."""
    keywords = ["federal reserve", "interest rate", "recession", "inflation cpi"]
    out = {}
    try:
        for kw in keywords:
            d = _get("https://gamma-api.polymarket.com/markets",
                     {"active": "true", "closed": "false", "q": kw, "limit": 3})
            if not d: continue
            for m in d:
                title = m.get("question","")
                # Skip non-US markets and resolved ones
                if not m.get("active"): continue
                # Get YES price as probability
                price = m.get("outcomePrices")
                if isinstance(price, list) and len(price) >= 1:
                    try:
                        yes_prob = float(price[0])
                    except:
                        continue
                elif isinstance(price, str):
                    try:
                        yes_prob = float(price)
                    except:
                        continue
                else:
                    continue
                # Store key markets
                tl = title.lower()
                if "rate cut" in tl or "rate reduce" in tl or "lower rate" in tl:
                    out["pm_rate_cut_prob"] = round(yes_prob, 3)
                    out["pm_rate_cut_title"] = title[:60]
                elif "recession" in tl:
                    out["pm_recession_prob"] = round(yes_prob, 3)
                    out["pm_recession_title"] = title[:60]
                elif "pause" in tl and ("rate" in tl or "fed" in tl):
                    out["pm_rate_pause_prob"] = round(yes_prob, 3)
        return out
    except: return {}

def fetch_etf_flows():
    """Bitcoin ETF proxy — IBIT price/volume trend from Yahoo Finance (free)."""
    try:
        # Yahoo Finance unofficial endpoint — no auth needed
        url = "https://query1.finance.yahoo.com/v8/finance/chart/IBIT"
        d = _get(url, {"interval": "1d", "range": "5d"})
        if not d: return {}
        result = d.get("chart",{}).get("result",[])
        if not result: return {}
        r = result[0]
        closes  = r.get("indicators",{}).get("quote",[{}])[0].get("close",[])
        volumes = r.get("indicators",{}).get("quote",[{}])[0].get("volume",[])
        if not closes or len(closes) < 2: return {}
        # Filter None values
        closes  = [c for c in closes  if c is not None]
        volumes = [v for v in volumes if v is not None]
        if len(closes) < 2: return {}
        # 5-day price change
        price_chg_5d = (closes[-1] - closes[0]) / closes[0]
        # Volume trend (last 2d avg vs prior 3d avg)
        vol_recent = sum(volumes[-2:]) / 2 if len(volumes) >= 2 else 0
        vol_prior  = sum(volumes[-5:-2]) / 3 if len(volumes) >= 5 else vol_recent
        vol_ratio  = vol_recent / vol_prior if vol_prior > 0 else 1.0
        # Estimate flow direction: up price + high volume = likely inflow
        flow_signal = "inflow" if price_chg_5d > 0.01 and vol_ratio > 1.1 else                       "outflow" if price_chg_5d < -0.01 and vol_ratio > 1.1 else "neutral"
        return {
            "etf_price_5d_chg": round(price_chg_5d * 100, 2),
            "etf_vol_ratio":    round(vol_ratio, 2),
            "etf_flow_signal":  flow_signal,
            "etf_latest_close": round(closes[-1], 2),
        }
    except: return {}

# ── WAVE 3: TRADITIONAL FINANCE + ON-CHAIN ───────────────────

def fetch_yahoo(symbol, days=10):
    """Generic Yahoo Finance chart fetch."""
    d = _get(f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}",
             {"interval": "1d", "range": f"{days}d"})
    try:
        r = d["chart"]["result"][0]
        closes = [c for c in r["indicators"]["quote"][0]["close"] if c]
        return closes
    except: return []

def fetch_macro_traditional():
    """DXY, SPX, Gold — traditional finance macro context."""
    out = {}
    try:
        # DXY — US Dollar Index (inverse correlation with BTC)
        dxy = fetch_yahoo("DX-Y.NYB", 10)
        if len(dxy) >= 2:
            out["dxy_current"] = round(dxy[-1], 2)
            out["dxy_5d_chg"]  = round((dxy[-1]-dxy[0])/dxy[0]*100, 2)
            out["dxy_trend"]   = "rising" if dxy[-1] > dxy[-3] else "falling" if dxy[-1] < dxy[-3] else "flat"
    except: pass
    try:
        # SPX — S&P 500 (BTC beta partner)
        spx = fetch_yahoo("%5EGSPC", 10)
        if len(spx) >= 2:
            out["spx_current"] = round(spx[-1], 1)
            out["spx_5d_chg"]  = round((spx[-1]-spx[0])/spx[0]*100, 2)
            out["spx_trend"]   = "rising" if spx[-1] > spx[-3] else "falling" if spx[-1] < spx[-3] else "flat"
    except: pass
    try:
        # Gold — macro fear/safe haven gauge
        gold = fetch_yahoo("GC%3DF", 10)
        if len(gold) >= 2:
            out["gold_current"] = round(gold[-1], 1)
            out["gold_5d_chg"]  = round((gold[-1]-gold[0])/gold[0]*100, 2)
            out["gold_trend"]   = "rising" if gold[-1] > gold[-3] else "falling"
    except: pass
    return out

def fetch_fred(series_id):
    """Fetch latest value from FRED — tries JSON API first, falls back to text."""
    # Method 1: FRED JSON API (most reliable, no key needed for observations)
    try:
        url = f"https://api.stlouisfed.org/fred/series/observations"
        params = {
            "series_id": series_id,
            "api_key": "demo",          # public demo key works for most series
            "file_type": "json",
            "sort_order": "desc",
            "limit": 5,
            "observation_start": "2020-01-01",
        }
        r = requests.get(url, params=params,
                         headers={"User-Agent": "Mozilla/5.0"}, timeout=8)
        if r.ok:
            data = r.json()
            for obs in data.get("observations", []):
                if obs.get("value") != ".":
                    try: return float(obs["value"])
                    except: continue
    except: pass
    # Method 2: Plain text fallback
    try:
        r = requests.get("https://fred.stlouisfed.org/data/" + series_id,
                         headers={"User-Agent": "Mozilla/5.0"}, timeout=8)
        if r.ok:
            for line in reversed(r.text.splitlines()):
                line = line.strip()
                if not line or line.startswith("DATE") or line.startswith("obs"):
                    continue
                parts = line.split()
                if len(parts) >= 2 and parts[1] != ".":
                    try: return float(parts[1])
                    except: continue
    except: pass
    return None


def fetch_treasury_rates():
    """US 10Y yield, 2Y yield, and yield curve spread.
    Tries FRED first, falls back to Yahoo Finance (^TNX, ^TYX).
    """
    out = {}
    # Try FRED first
    y10    = fetch_fred("DGS10")
    y2     = fetch_fred("DGS2")
    spread = fetch_fred("T10Y2Y")
    hy     = fetch_fred("BAMLH0A0HYM2")

    # Yahoo Finance fallback for yield data if FRED fails
    if y10 is None:
        closes = fetch_yahoo("%5ETNX", 5)   # ^TNX = 10Y Treasury
        if closes: y10 = round(closes[-1], 3)
    if y10 is None:
        closes = fetch_yahoo("%5ETYX", 5)   # ^TYX = 30Y fallback
        if closes: y10 = round(closes[-1] * 0.85, 3)  # rough 10Y estimate

    # Compute spread from Yahoo if FRED spread unavailable
    if spread is None and y2 is None:
        # ^IRX = 13-week T-bill * 4 ≈ annual, use as rough 2Y proxy
        irx = fetch_yahoo("%5EIRX", 5)
        if irx and y10:
            y2_proxy = irx[-1] / 100  # IRX is already in percent x 100
            spread = round(y10 - y2_proxy, 3)
    if y10:
        out["yield_10y"] = y10
        # Context: >5% = tight, 3-5% = normal, <3% = loose
        out["yield_10y_regime"] = ("tight" if y10 > 5 else
                                   "elevated" if y10 > 4 else "normal")
    if y2: out["yield_2y"] = y2
    if spread is not None:
        out["yield_curve"] = spread   # negative = inverted
        out["yield_curve_regime"] = ("inverted" if spread < -0.2
                                     else "flat" if spread < 0.3
                                     else "normal")
    if hy:
        out["hy_spread"] = hy   # bps above treasury
        out["hy_regime"] = ("stress" if hy > 600 else
                            "elevated" if hy > 400 else "normal")
    return out

def fetch_onchain_btc():
    """Bitcoin hash rate, difficulty, active addresses via Blockchair (free)."""
    d = _get("https://api.blockchair.com/bitcoin/stats")
    if not d: return {}
    try:
        s = d.get("data", {})
        hr = s.get("hashrate_24h")              # hash/s as string
        diff = s.get("difficulty")
        addr_24h = s.get("transactions_24h")    # proxy for active addresses
        mempool_tx = s.get("mempool_transactions")

        out = {}
        if hr:
            hr_th = float(hr) / 1e12   # convert to TH/s
            out["hashrate_th"] = round(hr_th, 0)
            out["difficulty"]  = diff

        # Also get 30d hash rate trend via Binance BTC price (we have it) vs hashrate
        # Proxy: if we have prior hashrate stored, compute change
        # For now just store current; trend computed next scan
        if addr_24h:
            out["btc_txns_24h"] = addr_24h

        return out
    except: return {}

def fetch_deribit_maxpain():
    """Compute BTC options max pain strike from Deribit open interest."""
    d = _deribit_get("get_book_summary_by_currency",
                      {"currency": "BTC", "kind": "option"})
    if not d: return {}
    try:
        items = d.get("result", [])
        # Parse strikes and OI from instrument names like BTC-27MAR25-70000-C
        from collections import defaultdict
        strike_oi = defaultdict(float)
        for item in items:
            inst = item.get("instrument_name","")
            parts = inst.split("-")
            if len(parts) != 4: continue
            try:
                strike = float(parts[2])
                oi     = float(item.get("open_interest", 0))
                strike_oi[strike] += oi
            except: continue

        if not strike_oi: return {}

        # Max pain = strike where total option value destroyed is maximised
        # For each possible expiry price, sum (intrinsic value × OI) for all strikes
        strikes = sorted(strike_oi.keys())
        # Simplified: strike with highest total OI = proxy for max pain
        max_pain_strike = max(strike_oi, key=lambda s: strike_oi[s])
        total_oi = sum(strike_oi.values())

        return {
            "maxpain_strike": max_pain_strike,
            "maxpain_total_oi": round(total_oi, 1),
        }
    except: return {}

# ── WAVE 5: INSTITUTIONAL + STRUCTURE + ADVANCED DERIVATIVES ─

def fetch_cme_btc_futures():
    """CME Bitcoin futures (BTC=F) — institutional futures pricing."""
    closes = fetch_yahoo("BTC%3DF", 10)
    if len(closes) < 2: return {}
    btc_spot_closes = fetch_yahoo("BTCUSD%3DX", 10)  # spot proxy
    out = {
        "cme_btc_price": round(closes[-1], 0),
        "cme_5d_chg":    round((closes[-1]-closes[0])/closes[0]*100, 2),
    }
    # CME premium vs Binance spot (if we have spot)
    if btc_spot_closes and len(btc_spot_closes) >= 1:
        spot = btc_spot_closes[-1]
        if spot > 0:
            out["cme_premium_pct"] = round((closes[-1]-spot)/spot*100, 3)
    return out

def fetch_mstr_premium():
    """MicroStrategy NAV premium — institutional BTC sentiment proxy.
    MSTR holds ~439,000 BTC as of Q1 2025 (approximate — company keeps buying).
    Premium = (MSTR market cap - BTC holdings value) / BTC holdings value.
    """
    MSTR_BTC_HOLDINGS = 439_000   # approx — update quarterly
    d = _get("https://query1.finance.yahoo.com/v8/finance/chart/MSTR",
             {"interval": "1d", "range": "2d"})
    btc = _get("https://query1.finance.yahoo.com/v8/finance/chart/BTC-USD",
               {"interval": "1d", "range": "2d"})
    try:
        mstr_price = d["chart"]["result"][0]["indicators"]["quote"][0]["close"][-1]
        btc_price  = btc["chart"]["result"][0]["indicators"]["quote"][0]["close"][-1]
        # MSTR shares outstanding ~246M (approximate)
        MSTR_SHARES = 246_000_000
        mstr_mcap   = mstr_price * MSTR_SHARES
        btc_value   = MSTR_BTC_HOLDINGS * btc_price
        premium_pct = (mstr_mcap - btc_value) / btc_value * 100
        return {
            "mstr_price":   round(mstr_price, 2),
            "mstr_premium": round(premium_pct, 1),
            "mstr_btc_val": round(btc_value/1e9, 1),   # BN USD
        }
    except: return {}

def fetch_funding_cumulative():
    """30-day cumulative Binance funding rate = total cost burden on longs."""
    d = _get("https://fapi.binance.com/fapi/v1/fundingRate",
             {"symbol": "BTCUSDT", "limit": 90})  # 90 periods = ~30 days
    if not d: return {}
    try:
        rates = [float(x["fundingRate"]) for x in d]
        cumulative = sum(rates)   # total cost paid by longs in 30d
        avg_8h     = cumulative / len(rates) if rates else 0
        # Annualised equivalent
        annualised = avg_8h * 3 * 365  # 3 funding periods/day * 365
        regime = ("overheated" if cumulative > 0.02    # >2% total = exhausted longs
                  else "elevated" if cumulative > 0.01
                  else "negative" if cumulative < -0.01
                  else "normal")
        return {
            "funding_cum_30d":  round(cumulative * 100, 3),  # pct
            "funding_avg_8h":   round(avg_8h * 100, 4),
            "funding_ann":      round(annualised * 100, 1),
            "funding_regime":   regime,
        }
    except: return {}

def fetch_stablecoin_depeg():
    """Monitor USDT and USDC for any deviation from $1.000 — stress signal."""
    d = _get("https://api.coingecko.com/api/v3/simple/price",
             {"ids": "tether,usd-coin", "vs_currencies": "usd"})
    if not d: return {}
    try:
        usdt = float(d.get("tether",{}).get("usd", 1.0))
        usdc = float(d.get("usd-coin",{}).get("usd", 1.0))
        usdt_dev = abs(usdt - 1.0)
        usdc_dev = abs(usdc - 1.0)
        max_dev  = max(usdt_dev, usdc_dev)
        status   = ("crisis"   if max_dev > 0.02
                    else "stress"  if max_dev > 0.005
                    else "watch"   if max_dev > 0.002
                    else "normal")
        return {
            "usdt_price":   round(usdt, 4),
            "usdc_price":   round(usdc, 4),
            "stable_depeg": round(max_dev * 100, 3),   # pct deviation
            "stable_status": status,
        }
    except: return {}

def fetch_eth_btc_ratio():
    """ETH/BTC ratio — crypto risk appetite gauge."""
    d = _get("https://api.binance.com/api/v3/ticker/price", {"symbol": "ETHBTC"})
    hist = _get("https://api.binance.com/api/v3/klines",
                {"symbol": "ETHBTC", "interval": "1d", "limit": 14})
    if not d: return {}
    try:
        current = float(d["price"])
        out = {"eth_btc_ratio": round(current, 5)}
        if hist and len(hist) >= 7:
            closes = [float(c[4]) for c in hist]
            avg7   = sum(closes[-7:]) / 7
            avg14  = sum(closes) / len(closes)
            out["eth_btc_7d_avg"]  = round(avg7, 5)
            out["eth_btc_trend"]   = ("rising" if current > avg7 * 1.01
                                      else "falling" if current < avg7 * 0.99
                                      else "flat")
        return out
    except: return {}

def fetch_cross_exchange_spread(binance_price):
    """BTC price spread between Binance and OKX — liquidity/stress signal."""
    if not binance_price: return {}
    d = _get("https://www.okx.com/api/v5/market/ticker", {"instId": "BTC-USDT-SWAP"})
    try:
        okx_price = float(d["data"][0]["last"])
        spread_abs = abs(binance_price - okx_price)
        spread_pct = spread_abs / binance_price * 100
        status = ("fragmented" if spread_pct > 0.15
                  else "stressed"    if spread_pct > 0.05
                  else "normal")
        return {
            "spread_okx_abs": round(spread_abs, 2),
            "spread_okx_pct": round(spread_pct, 4),
            "spread_status":  status,
        }
    except: return {}

def fetch_multi_etf_flows():
    """FBTC (Fidelity) and ARKB (ARK) ETF flow signals."""
    out = {}
    for ticker, name in [("FBTC", "fbtc"), ("ARKB", "arkb")]:
        closes  = fetch_yahoo(ticker, 7)
        if len(closes) < 2: continue
        chg = (closes[-1]-closes[0])/closes[0]*100
        out[f"{name}_5d_chg"] = round(chg, 2)
        out[f"{name}_signal"] = ("inflow" if chg > 1 else "outflow" if chg < -1 else "neutral")
    return out

def fetch_iv_skew_and_term():
    """IV Skew + Term Structure from Binance European Options."""
    import requests as _rq
    from datetime import datetime as _dt2
    try:
        r = _rq.get("https://eapi.binance.com/eapi/v1/mark",
                    params={"underlyingAsset": "BTC"},
                    headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
        if not r.ok: return {}
        resp = r.json()
        if not isinstance(resp, list) or len(resp) == 0: return {}

        r2 = _rq.get("https://api.binance.com/api/v3/ticker/price",
                     params={"symbol": "BTCUSDT"},
                     headers={"User-Agent": "Mozilla/5.0"}, timeout=8)
        btc_idx = float(r2.json().get("price", 66000)) if r2.ok else 66000

        now = _dt2.utcnow()
        puts_short, calls_short, puts_long, calls_long = [], [], [], []

        for item in resp:
            sym = item.get("symbol", "")
            if not sym.startswith("BTC-"): continue
            parts = sym.split("-")
            if len(parts) != 4: continue
            try:
                exp   = _dt2.strptime(parts[1], "%y%m%d")
                days  = (exp - now).days
                if days < 1 or days > 90: continue
                strike = float(parts[2])
                otype  = parts[3].upper()
                iv_d   = float(item.get("markIV", 0) or 0)
                if iv_d <= 0 or iv_d > 5: continue
                iv = iv_d * 100
                if abs(strike - btc_idx) / btc_idx > 0.20: continue
            except: continue

            if days <= 30:
                if otype == "P": puts_short.append(iv)
                else:            calls_short.append(iv)
            else:
                if otype == "P": puts_long.append(iv)
                else:            calls_long.append(iv)

        def avg(lst): return sum(lst)/len(lst) if lst else None
        out = {}
        p_s = avg(puts_short)
        c_s = avg(calls_short)
        if p_s and c_s:
            skew = p_s - c_s
            out["iv_skew"]        = round(skew, 1)
            out["iv_put_avg"]     = round(p_s, 1)
            out["iv_call_avg"]    = round(c_s, 1)
            out["iv_short_iv"]    = round((p_s + c_s) / 2, 1)
            out["iv_skew_regime"] = ("fear" if skew > 10 else "slight_fear" if skew > 3
                                     else "neutral" if skew > -3 else "greed")
        elif p_s or c_s:
            out["iv_short_iv"] = round((p_s or c_s), 1)
        long_iv = avg(puts_long + calls_long)
        if long_iv and out.get("iv_short_iv"):
            ratio = out["iv_short_iv"] / long_iv
            out["iv_term_ratio"]  = round(ratio, 2)
            out["iv_long_iv"]     = round(long_iv, 1)
            out["iv_term_regime"] = ("front_loaded" if ratio > 1.2
                                     else "back_loaded" if ratio < 0.8 else "normal")
        return out
    except: return {}


def compute_derived_signals(md):
    """Compute signals derived purely from already-fetched data — no new API calls."""
    out = {}
    # DVOL/RV ratio (options richness)
    dvol = md.get("dvol")
    rv14 = md.get("rv14")
    if dvol and rv14 and rv14 > 0:
        ratio = dvol / rv14
        out["dvol_rv_ratio"] = round(ratio, 2)
        # >1.3 = options expensive vs realised = mean reversion expected
        # <0.7 = options cheap = vol expansion expected
        out["dvol_rv_regime"] = ("expensive" if ratio > 1.3
                                 else "cheap" if ratio < 0.7
                                 else "fair")

    # Funding rate divergence across exchanges
    fr_bn = md.get("funding_rate", 0) or 0
    fr_bb = md.get("bybit_funding", 0) or 0
    fr_ok = md.get("okx_funding", 0) or 0
    valid = [f for f in [fr_bn, fr_bb, fr_ok] if f is not None]
    if len(valid) >= 2:
        divergence = max(valid) - min(valid)
        out["funding_divergence"] = round(divergence * 100, 4)  # pct
        out["funding_div_regime"] = ("extreme" if divergence > 0.001
                                     else "notable" if divergence > 0.0005
                                     else "normal")

    # Miner hash price (USD per TH/s per day)
    hr_th = md.get("hashrate_th")
    btc_p = md.get("btc_price") or md.get("mark_price", 0)
    if hr_th and btc_p and hr_th > 0:
        # 3.125 BTC block reward (post-halving) * 144 blocks/day * btc price / total TH
        hash_price = (3.125 * 144 * btc_p) / hr_th
        out["hash_price_usd"] = round(hash_price, 4)  # USD per TH/s per day
        # Historical context: ~$0.05-0.15 normal, <$0.03 = capitulation
        out["hash_price_regime"] = ("capitulation" if hash_price < 0.03
                                    else "stress"       if hash_price < 0.06
                                    else "normal"       if hash_price < 0.15
                                    else "high")

    # BTC spot volume vs OI ratio (leverage saturation)
    oi_btc  = md.get("oi_btc", 0)  # BTC
    btc_p_  = md.get("btc_price") or md.get("mark_price", 100000)
    # Fetch spot 24h volume from Binance
    sv = _get("https://api.binance.com/api/v3/ticker/24hr", {"symbol": "BTCUSDT"})
    if sv and oi_btc and btc_p_:
        try:
            spot_vol_usd = float(sv.get("quoteVolume", 0))
            oi_usd       = oi_btc * float(btc_p_)
            vol_oi_ratio = spot_vol_usd / oi_usd if oi_usd > 0 else 0
            out["vol_oi_ratio"] = round(vol_oi_ratio, 2)
            # Low ratio = high leverage relative to volume = crash risk
            out["vol_oi_regime"] = ("saturated" if vol_oi_ratio < 0.5
                                    else "elevated"  if vol_oi_ratio < 1.0
                                    else "healthy")
        except: pass

    # Ratio slope — read last 5 records from history file to compute
    try:
        import os as _os
        hist_file = HISTORY_FILE
        if _os.path.exists(hist_file):
            with open(hist_file, "r") as _hf:
                _recent = []
                for _line in _hf:
                    try:
                        _rec = json.loads(_line.strip())
                        if "hl_ratio" in _rec:
                            _recent.append(_rec["hl_ratio"])
                    except: pass
            if len(_recent) >= 3:
                # Simple slope: (latest - oldest) / n
                _slope = (_recent[-1] - _recent[-3]) / 2
                out["ratio_slope"]     = round(_slope, 5)
                out["ratio_slope_dir"] = ("falling" if _slope < -0.005
                                          else "rising" if _slope > 0.005
                                          else "flat")
                # Consecutive falling scans count
                _fall_count = 0
                for _i in range(len(_recent)-1, 0, -1):
                    if _recent[_i] < _recent[_i-1]:
                        _fall_count += 1
                    else:
                        break
                out["ratio_falling_n"] = _fall_count
    except: pass

    return out

def fetch_btc_realtime_price():
    """Fetch real-time BTC spot price from Binance — not daily close."""
    d = _get("https://api.binance.com/api/v3/ticker/24hr", {"symbol": "BTCUSDT"})
    if not d: return {}
    try:
        return {
            "btc_rt_price":    float(d["lastPrice"]),
            "btc_24h_chg_pct": float(d["priceChangePercent"]),
            "btc_24h_high":    float(d["highPrice"]),
            "btc_24h_low":     float(d["lowPrice"]),
            "btc_24h_vol_usd": float(d["quoteVolume"]),
        }
    except: return {}

def fetch_btc_realtime_price():
    """Fetch real-time BTC spot price from Binance — not daily close."""
    d = _get("https://api.binance.com/api/v3/ticker/24hr", {"symbol": "BTCUSDT"})
    if not d: return {}
    try:
        return {
            "btc_rt_price":    float(d["lastPrice"]),
            "btc_24h_chg_pct": float(d["priceChangePercent"]),
            "btc_24h_high":    float(d["highPrice"]),
            "btc_24h_low":     float(d["lowPrice"]),
            "btc_24h_vol_usd": float(d["quoteVolume"]),
        }
    except: return {}

def fetch_alt_prices():
    """Fetch ETH and SOL spot prices."""
    out = {}
    for sym, key in [("ETHUSDT","eth_price"), ("SOLUSDT","sol_price")]:
        d = _get("https://api.binance.com/api/v3/ticker/price", {"symbol": sym})
        if d:
            try: out[key] = float(d["price"])
            except: pass
    return out

def fetch_all_market_data():
    """Fetch all external sources in sequence. Returns combined dict."""
def fetch_all_market_data():
    """Fetch all external sources in sequence. Returns combined dict."""
# ── WAVE 5: INSTITUTIONAL + STRUCTURE + ADVANCED DERIVATIVES ─

def fetch_cme_btc_futures():
    """CME Bitcoin futures (BTC=F) — institutional futures pricing."""
    closes = fetch_yahoo("BTC%3DF", 10)
    if len(closes) < 2: return {}
    btc_spot_closes = fetch_yahoo("BTCUSD%3DX", 10)  # spot proxy
    out = {
        "cme_btc_price": round(closes[-1], 0),
        "cme_5d_chg":    round((closes[-1]-closes[0])/closes[0]*100, 2),
    }
    # CME premium vs Binance spot (if we have spot)
    if btc_spot_closes and len(btc_spot_closes) >= 1:
        spot = btc_spot_closes[-1]
        if spot > 0:
            out["cme_premium_pct"] = round((closes[-1]-spot)/spot*100, 3)
    return out

def fetch_mstr_premium():
    """MicroStrategy NAV premium — institutional BTC sentiment proxy.
    MSTR holds ~439,000 BTC as of Q1 2025 (approximate — company keeps buying).
    Premium = (MSTR market cap - BTC holdings value) / BTC holdings value.
    """
    MSTR_BTC_HOLDINGS = 439_000   # approx — update quarterly
    d = _get("https://query1.finance.yahoo.com/v8/finance/chart/MSTR",
             {"interval": "1d", "range": "2d"})
    btc = _get("https://query1.finance.yahoo.com/v8/finance/chart/BTC-USD",
               {"interval": "1d", "range": "2d"})
    try:
        mstr_price = d["chart"]["result"][0]["indicators"]["quote"][0]["close"][-1]
        btc_price  = btc["chart"]["result"][0]["indicators"]["quote"][0]["close"][-1]
        # MSTR shares outstanding ~246M (approximate)
        MSTR_SHARES = 246_000_000
        mstr_mcap   = mstr_price * MSTR_SHARES
        btc_value   = MSTR_BTC_HOLDINGS * btc_price
        premium_pct = (mstr_mcap - btc_value) / btc_value * 100
        return {
            "mstr_price":   round(mstr_price, 2),
            "mstr_premium": round(premium_pct, 1),
            "mstr_btc_val": round(btc_value/1e9, 1),   # BN USD
        }
    except: return {}

def fetch_funding_cumulative():
    """30-day cumulative Binance funding rate = total cost burden on longs."""
    d = _get("https://fapi.binance.com/fapi/v1/fundingRate",
             {"symbol": "BTCUSDT", "limit": 90})  # 90 periods = ~30 days
    if not d: return {}
    try:
        rates = [float(x["fundingRate"]) for x in d]
        cumulative = sum(rates)   # total cost paid by longs in 30d
        avg_8h     = cumulative / len(rates) if rates else 0
        # Annualised equivalent
        annualised = avg_8h * 3 * 365  # 3 funding periods/day * 365
        regime = ("overheated" if cumulative > 0.02    # >2% total = exhausted longs
                  else "elevated" if cumulative > 0.01
                  else "negative" if cumulative < -0.01
                  else "normal")
        return {
            "funding_cum_30d":  round(cumulative * 100, 3),  # pct
            "funding_avg_8h":   round(avg_8h * 100, 4),
            "funding_ann":      round(annualised * 100, 1),
            "funding_regime":   regime,
        }
    except: return {}

def fetch_stablecoin_depeg():
    """Monitor USDT and USDC for any deviation from $1.000 — stress signal."""
    d = _get("https://api.coingecko.com/api/v3/simple/price",
             {"ids": "tether,usd-coin", "vs_currencies": "usd"})
    if not d: return {}
    try:
        usdt = float(d.get("tether",{}).get("usd", 1.0))
        usdc = float(d.get("usd-coin",{}).get("usd", 1.0))
        usdt_dev = abs(usdt - 1.0)
        usdc_dev = abs(usdc - 1.0)
        max_dev  = max(usdt_dev, usdc_dev)
        status   = ("crisis"   if max_dev > 0.02
                    else "stress"  if max_dev > 0.005
                    else "watch"   if max_dev > 0.002
                    else "normal")
        return {
            "usdt_price":   round(usdt, 4),
            "usdc_price":   round(usdc, 4),
            "stable_depeg": round(max_dev * 100, 3),   # pct deviation
            "stable_status": status,
        }
    except: return {}

def fetch_eth_btc_ratio():
    """ETH/BTC ratio — crypto risk appetite gauge."""
    d = _get("https://api.binance.com/api/v3/ticker/price", {"symbol": "ETHBTC"})
    hist = _get("https://api.binance.com/api/v3/klines",
                {"symbol": "ETHBTC", "interval": "1d", "limit": 14})
    if not d: return {}
    try:
        current = float(d["price"])
        out = {"eth_btc_ratio": round(current, 5)}
        if hist and len(hist) >= 7:
            closes = [float(c[4]) for c in hist]
            avg7   = sum(closes[-7:]) / 7
            avg14  = sum(closes) / len(closes)
            out["eth_btc_7d_avg"]  = round(avg7, 5)
            out["eth_btc_trend"]   = ("rising" if current > avg7 * 1.01
                                      else "falling" if current < avg7 * 0.99
                                      else "flat")
        return out
    except: return {}

def fetch_cross_exchange_spread(binance_price):
    """BTC price spread between Binance and OKX — liquidity/stress signal."""
    if not binance_price: return {}
    d = _get("https://www.okx.com/api/v5/market/ticker", {"instId": "BTC-USDT-SWAP"})
    try:
        okx_price = float(d["data"][0]["last"])
        spread_abs = abs(binance_price - okx_price)
        spread_pct = spread_abs / binance_price * 100
        status = ("fragmented" if spread_pct > 0.15
                  else "stressed"    if spread_pct > 0.05
                  else "normal")
        return {
            "spread_okx_abs": round(spread_abs, 2),
            "spread_okx_pct": round(spread_pct, 4),
            "spread_status":  status,
        }
    except: return {}

def fetch_multi_etf_flows():
    """FBTC (Fidelity) and ARKB (ARK) ETF flow signals."""
    out = {}
    for ticker, name in [("FBTC", "fbtc"), ("ARKB", "arkb")]:
        closes  = fetch_yahoo(ticker, 7)
        if len(closes) < 2: continue
        chg = (closes[-1]-closes[0])/closes[0]*100
        out[f"{name}_5d_chg"] = round(chg, 2)
        out[f"{name}_signal"] = ("inflow" if chg > 1 else "outflow" if chg < -1 else "neutral")
    return out

def fetch_iv_skew_and_term():
    """IV Skew + Term Structure from Binance European Options."""
    import requests as _rq
    from datetime import datetime as _dt2
    try:
        r = _rq.get("https://eapi.binance.com/eapi/v1/mark",
                    params={"underlyingAsset": "BTC"},
                    headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
        if not r.ok: return {}
        resp = r.json()
        if not isinstance(resp, list) or len(resp) == 0: return {}

        r2 = _rq.get("https://api.binance.com/api/v3/ticker/price",
                     params={"symbol": "BTCUSDT"},
                     headers={"User-Agent": "Mozilla/5.0"}, timeout=8)
        btc_idx = float(r2.json().get("price", 66000)) if r2.ok else 66000

        now = _dt2.utcnow()
        puts_short, calls_short, puts_long, calls_long = [], [], [], []

        for item in resp:
            sym = item.get("symbol", "")
            if not sym.startswith("BTC-"): continue
            parts = sym.split("-")
            if len(parts) != 4: continue
            try:
                exp   = _dt2.strptime(parts[1], "%y%m%d")
                days  = (exp - now).days
                if days < 1 or days > 90: continue
                strike = float(parts[2])
                otype  = parts[3].upper()
                iv_d   = float(item.get("markIV", 0) or 0)
                if iv_d <= 0 or iv_d > 5: continue
                iv = iv_d * 100
                if abs(strike - btc_idx) / btc_idx > 0.20: continue
            except: continue

            if days <= 30:
                if otype == "P": puts_short.append(iv)
                else:            calls_short.append(iv)
            else:
                if otype == "P": puts_long.append(iv)
                else:            calls_long.append(iv)

        def avg(lst): return sum(lst)/len(lst) if lst else None
        out = {}
        p_s = avg(puts_short)
        c_s = avg(calls_short)
        if p_s and c_s:
            skew = p_s - c_s
            out["iv_skew"]        = round(skew, 1)
            out["iv_put_avg"]     = round(p_s, 1)
            out["iv_call_avg"]    = round(c_s, 1)
            out["iv_short_iv"]    = round((p_s + c_s) / 2, 1)
            out["iv_skew_regime"] = ("fear" if skew > 10 else "slight_fear" if skew > 3
                                     else "neutral" if skew > -3 else "greed")
        elif p_s or c_s:
            out["iv_short_iv"] = round((p_s or c_s), 1)
        long_iv = avg(puts_long + calls_long)
        if long_iv and out.get("iv_short_iv"):
            ratio = out["iv_short_iv"] / long_iv
            out["iv_term_ratio"]  = round(ratio, 2)
            out["iv_long_iv"]     = round(long_iv, 1)
            out["iv_term_regime"] = ("front_loaded" if ratio > 1.2
                                     else "back_loaded" if ratio < 0.8 else "normal")
        return out
    except: return {}


def compute_derived_signals(md):
    """Compute signals derived purely from already-fetched data — no new API calls."""
    out = {}
    # DVOL/RV ratio (options richness)
    dvol = md.get("dvol")
    rv14 = md.get("rv14")
    if dvol and rv14 and rv14 > 0:
        ratio = dvol / rv14
        out["dvol_rv_ratio"] = round(ratio, 2)
        # >1.3 = options expensive vs realised = mean reversion expected
        # <0.7 = options cheap = vol expansion expected
        out["dvol_rv_regime"] = ("expensive" if ratio > 1.3
                                 else "cheap" if ratio < 0.7
                                 else "fair")

    # Funding rate divergence across exchanges
    fr_bn = md.get("funding_rate", 0) or 0
    fr_bb = md.get("bybit_funding", 0) or 0
    fr_ok = md.get("okx_funding", 0) or 0
    valid = [f for f in [fr_bn, fr_bb, fr_ok] if f is not None]
    if len(valid) >= 2:
        divergence = max(valid) - min(valid)
        out["funding_divergence"] = round(divergence * 100, 4)  # pct
        out["funding_div_regime"] = ("extreme" if divergence > 0.001
                                     else "notable" if divergence > 0.0005
                                     else "normal")

    # Miner hash price (USD per TH/s per day)
    hr_th = md.get("hashrate_th")
    btc_p = md.get("btc_price") or md.get("mark_price", 0)
    if hr_th and btc_p and hr_th > 0:
        # 3.125 BTC block reward (post-halving) * 144 blocks/day * btc price / total TH
        hash_price = (3.125 * 144 * btc_p) / hr_th
        out["hash_price_usd"] = round(hash_price, 4)  # USD per TH/s per day
        # Historical context: ~$0.05-0.15 normal, <$0.03 = capitulation
        out["hash_price_regime"] = ("capitulation" if hash_price < 0.03
                                    else "stress"       if hash_price < 0.06
                                    else "normal"       if hash_price < 0.15
                                    else "high")

    # BTC spot volume vs OI ratio (leverage saturation)
    oi_btc  = md.get("oi_btc", 0)  # BTC
    btc_p_  = md.get("btc_price") or md.get("mark_price", 100000)
    # Fetch spot 24h volume from Binance
    sv = _get("https://api.binance.com/api/v3/ticker/24hr", {"symbol": "BTCUSDT"})
    if sv and oi_btc and btc_p_:
        try:
            spot_vol_usd = float(sv.get("quoteVolume", 0))
            oi_usd       = oi_btc * float(btc_p_)
            vol_oi_ratio = spot_vol_usd / oi_usd if oi_usd > 0 else 0
            out["vol_oi_ratio"] = round(vol_oi_ratio, 2)
            # Low ratio = high leverage relative to volume = crash risk
            out["vol_oi_regime"] = ("saturated" if vol_oi_ratio < 0.5
                                    else "elevated"  if vol_oi_ratio < 1.0
                                    else "healthy")
        except: pass

    return out

def fetch_all_market_data():
    """Fetch all external sources in sequence. Returns combined dict."""
def fetch_all_market_data():
    """Fetch all external sources in sequence. Returns combined dict."""
    print("  Fetching market context data...", flush=True)
    md = {}
    md.update(fetch_binance_funding())
    md.update(fetch_binance_ls_ratio())
    md.update(fetch_binance_oi())
    md.update(fetch_binance_taker_vol())
    md.update(fetch_fear_greed())
    # Wave 1 sources
    md.update(fetch_bybit_funding())
    md.update(fetch_okx_funding())
    md.update(fetch_bybit_ls_ratio())
    md.update(fetch_deribit_dvol())
    md.update(fetch_coingecko_dominance())
    md.update(fetch_btc_200ma_and_rv())
    btc_price = md.get("btc_price") or md.get("mark_price")
    md.update(fetch_coinbase_premium(btc_price))
    # Wave 2 sources
    md.update(fetch_mempool())
    md.update(fetch_wikipedia_views())
    md.update(fetch_deribit_pc_ratio())
    md.update(fetch_futures_basis())
    md.update(fetch_stablecoin_supply())
    md.update(fetch_polymarket_macro())
    md.update(fetch_etf_flows())
    # Wave 3 sources
    md.update(fetch_macro_traditional())
    md.update(fetch_treasury_rates())
    md.update(fetch_onchain_btc())
    md.update(fetch_deribit_maxpain())
    # Wave 5 sources
    md.update(fetch_cme_btc_futures())
    md.update(fetch_mstr_premium())
    md.update(fetch_funding_cumulative())
    md.update(fetch_stablecoin_depeg())
    md.update(fetch_eth_btc_ratio())
    md.update(fetch_multi_etf_flows())
    md.update(fetch_iv_skew_and_term())
    btc_p = md.get("btc_price") or md.get("mark_price")
    md.update(fetch_cross_exchange_spread(btc_p))
    md.update(fetch_btc_realtime_price())
    md.update(fetch_alt_prices())
    # Computed signals (no new API calls)
    md.update(compute_derived_signals(md))
    n = sum(1 for v in md.values() if v is not None)
    print(f"    Sources loaded: {n}/{len(md)} fields across all waves", flush=True)
    return md

def score_signal(md, hl_bias):
    """
    Multi-source conviction score. Range -10 to +10.
    Positive = BULLISH, Negative = BEARISH.
    """
    signals = []
    score   = 0

    # ── TIER 1: HIGH WEIGHT ───────────────────────────────────
    # 1. HL Whale bias ±2
    if hl_bias == "BEARISH":
        score -= 2; signals.append(("HL Whales", "BEARISH", -2, "Top 60 HL traders 2:1+ short"))
    elif hl_bias == "BULLISH":
        score += 2; signals.append(("HL Whales", "BULLISH", +2, "Top 60 HL traders 1.3:1+ long"))
    else:
        signals.append(("HL Whales", "NEUTRAL", 0, "No clear directional bias"))

    # 2. Multi-exchange funding consensus ±2 (1pt each, up to 2)
    fr_bn = md.get("funding_rate", 0) or 0
    fr_bb = md.get("bybit_funding", 0) or 0
    fr_ok = md.get("okx_funding", 0) or 0
    FUND_HIGH = 0.0005   # >0.05%/8h = longs crowded
    FUND_LOW  = -0.0002  # <-0.02%/8h = shorts crowded
    n_bear_fund = sum(1 for f in [fr_bn,fr_bb,fr_ok] if f > FUND_HIGH)
    n_bull_fund = sum(1 for f in [fr_bn,fr_bb,fr_ok] if f < FUND_LOW)
    fund_score = min(2, n_bear_fund) * -1 + min(2, n_bull_fund)
    if fund_score <= -1:
        score += fund_score
        exch = "/".join([e for e,f in [("BN",fr_bn),("BB",fr_bb),("OKX",fr_ok)] if f > FUND_HIGH])
        signals.append(("Funding (multi)", "BEARISH", fund_score,
            f"{exch} elevated — longs paying, crowd is long"))
    elif fund_score >= 1:
        score += fund_score
        exch = "/".join([e for e,f in [("BN",fr_bn),("BB",fr_bb),("OKX",fr_ok)] if f < FUND_LOW])
        signals.append(("Funding (multi)", "BULLISH", fund_score,
            f"{exch} negative — shorts paying, crowd is short"))
    else:
        avg_fr = (fr_bn + fr_bb + fr_ok) / 3 if all(x is not None for x in [fr_bn,fr_bb,fr_ok]) else fr_bn
        signals.append(("Funding (multi)", "NEUTRAL", 0,
            f"BN {fr_bn*100:.3f}% / BB {fr_bb*100:.3f}% / OKX {fr_ok*100:.3f}% — neutral"))

    # ── TIER 2: REGIME CONTEXT ───────────────────────────────
    # 3. BTC vs 200-day MA ±1
    vs_ma = md.get("btc_vs_200ma")
    ma200 = md.get("btc_200ma")
    if vs_ma is not None:
        if vs_ma < -0.02:   # >2% below 200MA — bear regime confirmed
            score -= 1; signals.append(("BTC vs 200MA", "BEARISH", -1,
                f"{vs_ma*100:.1f}% below 200MA (${ma200:,.0f}) — bear regime"))
        elif vs_ma > 0.02:  # >2% above 200MA — bull regime
            score += 1; signals.append(("BTC vs 200MA", "BULLISH", +1,
                f"{vs_ma*100:+.1f}% above 200MA (${ma200:,.0f}) — bull regime"))
        else:
            signals.append(("BTC vs 200MA", "NEUTRAL", 0,
                f"{vs_ma*100:+.1f}% vs 200MA (${ma200:,.0f}) — transitional"))
    else:
        signals.append(("BTC vs 200MA", "NEUTRAL", 0, "Price data unavailable"))

    # 4. Deribit DVOL — options market fear ±1
    dvol = md.get("dvol")
    if dvol is not None:
        dvol_trend = md.get("dvol_trend", "flat")
        if dvol > 70:
            # High DVOL = extreme fear + uncertainty. Amplifies bearish signals.
            # If we're already bearish (score<0), confirms risk-off. If bullish, reduces conviction.
            if score < 0:
                score -= 1; signals.append(("Deribit DVOL", "BEARISH", -1,
                    f"DVOL {dvol:.0f} (HIGH) + rising — extreme fear, confirms risk-off"))
            else:
                signals.append(("Deribit DVOL", "NEUTRAL", 0,
                    f"DVOL {dvol:.0f} (HIGH) — high uncertainty, mixed signal"))
        elif dvol < 40:
            # Low DVOL = calm market. Bullish environments tend to have lower vol.
            if score > 0:
                score += 1; signals.append(("Deribit DVOL", "BULLISH", +1,
                    f"DVOL {dvol:.0f} (LOW) — calm market, confirms bullish"))
            else:
                signals.append(("Deribit DVOL", "NEUTRAL", 0,
                    f"DVOL {dvol:.0f} (LOW) — calm but bearish positioning, wait"))
        else:
            signals.append(("Deribit DVOL", "NEUTRAL", 0,
                f"DVOL {dvol:.0f} ({dvol_trend}) — normal vol range (40-70)"))
    else:
        signals.append(("Deribit DVOL", "NEUTRAL", 0, "Options data unavailable"))

    # ── TIER 3: INSTITUTIONAL SIGNALS ────────────────────────
    # 5. Binance top traders ±1
    top_ls = md.get("top_ls_ratio", 1.0)
    if top_ls < 0.8:
        score -= 1; signals.append(("Binance Top Traders", "BEARISH", -1,
            f"Top accounts {md.get('top_short_pct',0)*100:.0f}% short"))
    elif top_ls > 1.3:
        score += 1; signals.append(("Binance Top Traders", "BULLISH", +1,
            f"Top accounts {md.get('top_long_pct',0)*100:.0f}% long"))
    else:
        signals.append(("Binance Top Traders", "NEUTRAL", 0,
            f"L/S ratio {top_ls:.2f} — no extreme"))

    # 6. Bybit L/S ratio ±1
    bb_ls = md.get("bybit_ls_ratio")
    if bb_ls is not None:
        if bb_ls < 0.8:
            score -= 1; signals.append(("Bybit L/S Ratio", "BEARISH", -1,
                f"Bybit traders {md.get('bybit_short_pct',0)*100:.0f}% short"))
        elif bb_ls > 1.3:
            score += 1; signals.append(("Bybit L/S Ratio", "BULLISH", +1,
                f"Bybit traders {md.get('bybit_long_pct',0)*100:.0f}% long"))
        else:
            signals.append(("Bybit L/S Ratio", "NEUTRAL", 0,
                f"Bybit L/S {bb_ls:.2f} — neutral"))
    else:
        signals.append(("Bybit L/S Ratio", "NEUTRAL", 0, "Data unavailable"))

    # 7. Coinbase premium ±1
    cb_prem = md.get("cb_premium")
    if cb_prem is not None:
        if cb_prem > 0.001:   # +0.1% premium — US buyers paying up
            score += 1; signals.append(("Coinbase Premium", "BULLISH", +1,
                f"+{cb_prem*100:.3f}% premium — US institutional buying"))
        elif cb_prem < -0.001: # -0.1% discount — US sellers or absent
            score -= 1; signals.append(("Coinbase Premium", "BEARISH", -1,
                f"{cb_prem*100:.3f}% discount — US demand absent"))
        else:
            signals.append(("Coinbase Premium", "NEUTRAL", 0,
                f"{cb_prem*100:+.3f}% — neutral, no US institutional pressure"))
    else:
        signals.append(("Coinbase Premium", "NEUTRAL", 0, "Data unavailable"))

    # ── TIER 4: FLOW SIGNALS ─────────────────────────────────
    # 8. OI trend ±1
    oi_trend = md.get("oi_trend", "flat")
    btc_price = md.get("btc_price") or md.get("mark_price", 0)
    ma200_p   = md.get("btc_200ma", btc_price)
    price_bear = btc_price < ma200_p if btc_price and ma200_p else (hl_bias == "BEARISH")
    if oi_trend == "rising" and price_bear:
        score -= 1; signals.append(("Open Interest", "BEARISH", -1,
            "OI rising in bearish regime — new shorts entering"))
    elif oi_trend == "rising" and not price_bear:
        score += 1; signals.append(("Open Interest", "BULLISH", +1,
            "OI rising in bullish regime — new longs entering"))
    elif oi_trend == "falling":
        signals.append(("Open Interest", "NEUTRAL", 0,
            "OI falling — deleveraging, no strong directional signal"))
    else:
        signals.append(("Open Interest", "NEUTRAL", 0, "OI flat"))

    # 9. Taker volume ±1
    buy_pct = md.get("taker_buy_pct", 0.5)
    if buy_pct < 0.44:
        score -= 1; signals.append(("Taker Volume", "BEARISH", -1,
            f"{(1-buy_pct)*100:.0f}% sell-side — aggressive sellers"))
    elif buy_pct > 0.56:
        score += 1; signals.append(("Taker Volume", "BULLISH", +1,
            f"{buy_pct*100:.0f}% buy-side — aggressive buyers"))
    else:
        signals.append(("Taker Volume", "NEUTRAL", 0,
            f"{buy_pct*100:.0f}% buy / {(1-buy_pct)*100:.0f}% sell"))

    # ── TIER 5: MACRO SENTIMENT ──────────────────────────────
    # 10. BTC dominance ±1
    btc_dom = md.get("btc_dominance")
    if btc_dom is not None:
        if btc_dom > 58:    # high dominance = risk-off, capital fleeing alts into BTC/cash
            if score < 0:
                score -= 1; signals.append(("BTC Dominance", "BEARISH", -1,
                    f"BTC.D {btc_dom:.1f}% (HIGH) — risk-off, capital exiting alts"))
            else:
                signals.append(("BTC Dominance", "NEUTRAL", 0,
                    f"BTC.D {btc_dom:.1f}% (HIGH) — defensive positioning"))
        elif btc_dom < 48:  # low dominance = risk-on, money rotating to alts
            if score > 0:
                score += 1; signals.append(("BTC Dominance", "BULLISH", +1,
                    f"BTC.D {btc_dom:.1f}% (LOW) — risk-on, alt season signal"))
            else:
                signals.append(("BTC Dominance", "NEUTRAL", 0,
                    f"BTC.D {btc_dom:.1f}% (LOW) — risk-on but bearish book"))
        else:
            signals.append(("BTC Dominance", "NEUTRAL", 0,
                f"BTC.D {btc_dom:.1f}% — normal range"))
    else:
        signals.append(("BTC Dominance", "NEUTRAL", 0, "Data unavailable"))

    # 11. Fear & Greed — contrarian ±1
    fg = md.get("score", 50)
    if fg >= 75:
        score -= 1; signals.append(("Fear & Greed", "BEARISH", -1,
            f"{fg}/100 Extreme Greed — contrarian short"))
    elif fg <= 25:
        score += 1; signals.append(("Fear & Greed", "BULLISH", +1,
            f"{fg}/100 Extreme Fear — contrarian long"))
    else:
        signals.append(("Fear & Greed", "NEUTRAL", 0,
            f"{fg}/100 ({md.get('label','Neutral')}) — no extreme"))

    # ── WAVE 2 SIGNALS ───────────────────────────────────────

    # 12. Bitcoin ETF flows ±2 (highest impact — real institutional money)
    etf_sig = md.get("etf_flow_signal")
    etf_chg = md.get("etf_price_5d_chg", 0)
    if etf_sig == "inflow":
        score += 2; signals.append(("ETF Flows (IBIT)", "BULLISH", +2,
            f"IBIT +{etf_chg:.1f}% 5d, vol elevated — institutional buying"))
    elif etf_sig == "outflow":
        score -= 2; signals.append(("ETF Flows (IBIT)", "BEARISH", -2,
            f"IBIT {etf_chg:.1f}% 5d, vol elevated — institutional selling"))
    else:
        signals.append(("ETF Flows (IBIT)", "NEUTRAL", 0,
            f"IBIT {etf_chg:+.1f}% 5d — no strong flow signal"))

    # 13. Deribit put/call ratio ±1
    pc  = md.get("pc_ratio")
    pcs = md.get("pc_sentiment","neutral")
    if pc is not None:
        if pcs == "fear":      # high put buying = bearish fear
            score -= 1; signals.append(("Options P/C Ratio", "BEARISH", -1,
                f"P/C {pc:.2f} — heavy put buying, options traders hedging"))
        elif pcs == "complacency":  # nobody buying puts = contrarian bearish
            score -= 1; signals.append(("Options P/C Ratio", "BEARISH", -1,
                f"P/C {pc:.2f} — extreme complacency, no hedging (contrarian)"))
        else:
            signals.append(("Options P/C Ratio", "NEUTRAL", 0,
                f"P/C {pc:.2f} — normal hedging activity"))
    else:
        signals.append(("Options P/C Ratio", "NEUTRAL", 0, "Data unavailable"))

    # 14. Bitcoin mempool ±1
    mpool_level = md.get("mempool_level", "normal")
    mpool_fee   = md.get("mempool_fastest_fee", 0)
    mpool_count = md.get("mempool_count", 0)
    if mpool_level == "high":
        # High mempool = something happening on-chain (whale moves, exchange deposits)
        # Combined with bearish book = additional selling pressure signal
        if score < 0:
            score -= 1; signals.append(("Mempool", "BEARISH", -1,
                f"Fee {mpool_fee} sat/vB, {mpool_count:,} pending — heavy on-chain activity"))
        else:
            signals.append(("Mempool", "NEUTRAL", 0,
                f"Fee {mpool_fee} sat/vB — high on-chain activity (direction unclear)"))
    elif mpool_level == "normal" and mpool_fee < 5:
        # Very quiet mempool = low urgency, no whale moves
        signals.append(("Mempool", "NEUTRAL", 0,
            f"Fee {mpool_fee} sat/vB — quiet, no urgent on-chain activity"))
    else:
        signals.append(("Mempool", "NEUTRAL", 0,
            f"Fee {mpool_fee} sat/vB ({mpool_level}) — normal on-chain activity"))

    # 15. Futures basis (contango) ±1
    basis_ann  = md.get("basis_ann")
    basis_str  = md.get("basis_struct", "flat")
    if basis_ann is not None:
        if basis_ann > 15:    # deep contango = strong institutional demand
            score += 1; signals.append(("Futures Basis", "BULLISH", +1,
                f"{basis_ann:.1f}%/yr contango — institutions paying premium for futures"))
        elif basis_ann < 0:   # backwardation = stress, people want spot not futures
            score -= 1; signals.append(("Futures Basis", "BEARISH", -1,
                f"{basis_ann:.1f}%/yr backwardation — stress, spot preferred over futures"))
        elif basis_ann < 5:   # flat/low basis = institutional demand weak
            if score < 0:
                score -= 1; signals.append(("Futures Basis", "BEARISH", -1,
                    f"{basis_ann:.1f}%/yr — very low basis, institutional carry demand weak"))
            else:
                signals.append(("Futures Basis", "NEUTRAL", 0,
                    f"{basis_ann:.1f}%/yr — low institutional carry demand"))
        else:
            signals.append(("Futures Basis", "NEUTRAL", 0,
                f"{basis_ann:.1f}%/yr {basis_str} — normal institutional demand"))
    else:
        signals.append(("Futures Basis", "NEUTRAL", 0, "Data unavailable"))

    # 16. Stablecoin supply ±1
    stable_trend = md.get("stable_trend","stable")
    stable_chg   = md.get("stable_7d_chg_b", 0)
    stable_total = md.get("stable_total_b", 0)
    if stable_trend == "expanding":
        score += 1; signals.append(("Stablecoin Supply", "BULLISH", +1,
            f"USDT+USDC +${stable_chg:.1f}B in 7 days — new money entering crypto"))
    elif stable_trend == "contracting":
        score -= 1; signals.append(("Stablecoin Supply", "BEARISH", -1,
            f"USDT+USDC ${stable_chg:.1f}B in 7 days — money leaving crypto"))
    else:
        signals.append(("Stablecoin Supply", "NEUTRAL", 0,
            f"${stable_total:.0f}B total, stable — no major supply change"))

    # 17. Polymarket macro odds ±1
    rc_prob   = md.get("pm_rate_cut_prob")
    rec_prob  = md.get("pm_recession_prob")
    pm_scored = False
    if rc_prob is not None and not pm_scored:
        if rc_prob > 0.65:    # high rate cut probability = bullish for BTC
            score += 1; signals.append(("Polymarket Macro", "BULLISH", +1,
                f"{rc_prob*100:.0f}% rate cut probability — easing = risk-on"))
            pm_scored = True
        elif rc_prob < 0.25:  # low rate cut probability = rates staying high = bearish
            score -= 1; signals.append(("Polymarket Macro", "BEARISH", -1,
                f"{rc_prob*100:.0f}% rate cut probability — tight policy = risk-off"))
            pm_scored = True
    if rec_prob is not None and not pm_scored:
        if rec_prob > 0.55:   # high recession probability = risk-off
            score -= 1; signals.append(("Polymarket Macro", "BEARISH", -1,
                f"{rec_prob*100:.0f}% recession probability — economic fear"))
            pm_scored = True
    if not pm_scored:
        signals.append(("Polymarket Macro", "NEUTRAL", 0,
            "No extreme macro probabilities detected"))

    # 18. Wikipedia views ±1 (contrarian — spikes = retail arriving = potential top)
    wiki_ratio = md.get("wiki_ratio", 1.0)
    wiki_level = md.get("wiki_level", "normal")
    wiki_avg7  = md.get("wiki_avg7", 0)
    if wiki_level == "spike":
        score -= 1; signals.append(("Wikipedia Views", "BEARISH", -1,
            f"{wiki_avg7:,}/day avg ({wiki_ratio:.1f}x normal) — retail FOMO spike"))
    elif wiki_level == "collapse":
        score += 1; signals.append(("Wikipedia Views", "BULLISH", +1,
            f"{wiki_avg7:,}/day avg ({wiki_ratio:.1f}x normal) — retail interest collapsed"))
    else:
        signals.append(("Wikipedia Views", "NEUTRAL", 0,
            f"{wiki_avg7:,}/day — normal retail attention level"))

    # ── WAVE 3: MACRO + ON-CHAIN ─────────────────────────────

    # 19. DXY (US Dollar Index) ±2  [highest macro impact]
    dxy_chg = md.get("dxy_5d_chg")
    dxy_cur = md.get("dxy_current")
    if dxy_chg is not None:
        if dxy_chg > 1.0:     # DXY up >1% in 5 days = dollar surging = risk-off = BTC bearish
            pts = -2 if dxy_chg > 2.0 else -1
            score += pts; signals.append(("DXY Dollar Index", "BEARISH", pts,
                f"DXY {dxy_chg:+.1f}% in 5d ({dxy_cur:.1f}) — dollar strength = BTC headwind"))
        elif dxy_chg < -1.0:  # DXY falling = dollar weakening = BTC tailwind
            pts = 2 if dxy_chg < -2.0 else 1
            score += pts; signals.append(("DXY Dollar Index", "BULLISH", pts,
                f"DXY {dxy_chg:+.1f}% in 5d ({dxy_cur:.1f}) — dollar weakness = BTC tailwind"))
        else:
            signals.append(("DXY Dollar Index", "NEUTRAL", 0,
                f"DXY {dxy_chg:+.1f}% in 5d — neutral dollar movement"))
    else:
        signals.append(("DXY Dollar Index", "NEUTRAL", 0, "Data unavailable"))

    # 20. SPX (S&P 500) ±2  [BTC tracks equities closely]
    spx_chg = md.get("spx_5d_chg")
    spx_cur = md.get("spx_current")
    if spx_chg is not None:
        if spx_chg > 1.5:
            pts = 2 if spx_chg > 3.0 else 1
            score += pts; signals.append(("S&P 500 (SPX)", "BULLISH", pts,
                f"SPX {spx_chg:+.1f}% in 5d — equity strength supports BTC"))
        elif spx_chg < -1.5:
            pts = -2 if spx_chg < -3.0 else -1
            score += pts; signals.append(("S&P 500 (SPX)", "BEARISH", pts,
                f"SPX {spx_chg:+.1f}% in 5d — equity weakness drags BTC"))
        else:
            signals.append(("S&P 500 (SPX)", "NEUTRAL", 0,
                f"SPX {spx_chg:+.1f}% in 5d — equities flat, neutral for BTC"))
    else:
        signals.append(("S&P 500 (SPX)", "NEUTRAL", 0, "Data unavailable"))

    # 21. US 10Y Treasury yield ±1
    y10  = md.get("yield_10y")
    y_reg = md.get("yield_10y_regime","normal")
    y2   = md.get("yield_2y")
    yc   = md.get("yield_curve")
    yc_r = md.get("yield_curve_regime","normal")
    if y10 is not None:
        if y_reg == "tight":   # >5% = tight money = risk assets suffer
            score -= 1; signals.append(("US 10Y Treasury", "BEARISH", -1,
                f"10Y yield {y10:.2f}% — tight financial conditions, risk-off"))
        elif y10 < 3.5:        # low yields = loose money = risk-on
            score += 1; signals.append(("US 10Y Treasury", "BULLISH", +1,
                f"10Y yield {y10:.2f}% — loose conditions, risk assets supported"))
        else:
            signals.append(("US 10Y Treasury", "NEUTRAL", 0,
                f"10Y yield {y10:.2f}% — normal range"))
    else:
        signals.append(("US 10Y Treasury", "NEUTRAL", 0, "Data unavailable"))

    # 22. Yield curve ±1
    if yc is not None:
        if yc_r == "inverted":
            score -= 1; signals.append(("Yield Curve (10-2Y)", "BEARISH", -1,
                f"Spread {yc:+.2f}% (INVERTED) — recession signal, risk-off"))
        elif yc > 0.5:
            score += 1; signals.append(("Yield Curve (10-2Y)", "BULLISH", +1,
                f"Spread {yc:+.2f}% — normal/steep curve, expansion signal"))
        else:
            signals.append(("Yield Curve (10-2Y)", "NEUTRAL", 0,
                f"Spread {yc:+.2f}% — flat curve, neutral"))
    else:
        signals.append(("Yield Curve (10-2Y)", "NEUTRAL", 0, "Data unavailable"))

    # 23. Gold ±1
    gold_chg = md.get("gold_5d_chg")
    gold_cur = md.get("gold_current")
    if gold_chg is not None:
        btc_chg_approx = md.get("btc_vs_200ma", 0)  # use as proxy
        if gold_chg > 1.5 and score < 0:
            # Gold rising + bearish BTC = flight to safety = more bearish
            score -= 1; signals.append(("Gold", "BEARISH", -1,
                f"Gold +{gold_chg:.1f}% 5d (${gold_cur:.0f}) — safe haven demand, BTC excluded"))
        elif gold_chg > 1.5:
            signals.append(("Gold", "NEUTRAL", 0,
                f"Gold +{gold_chg:.1f}% 5d — macro fear, BTC correlation unclear"))
        elif gold_chg < -1.0:
            signals.append(("Gold", "NEUTRAL", 0,
                f"Gold {gold_chg:.1f}% 5d — gold selling, risk appetite unclear"))
        else:
            signals.append(("Gold", "NEUTRAL", 0,
                f"Gold {gold_chg:+.1f}% 5d — stable"))
    else:
        signals.append(("Gold", "NEUTRAL", 0, "Data unavailable"))

    # 24. Bitcoin hash rate — regime and capitulation risk indicator
    # NOTE: Miners are 0.036% of daily volume — no day-to-day price impact.
    # Signal value is purely about REGIME (long-term commitment) and
    # CAPITULATION PROXIMITY (break-even prices as risk levels).
    hr = md.get("hashrate_th")
    btc_p_for_hr = md.get("btc_price") or md.get("mark_price", 0)
    if hr is not None and btc_p_for_hr:
        hr_ehs = hr / 1000  # convert TH to EH/s
        # Break-even prices for miner generations (at $0.055/kWh)
        # S19 XP: ~$49,800 | S19 Pro: ~$74,500 | S21: ~$36,900
        s19_pro_breakeven = 74_500    # already loss-making now
        s19_xp_breakeven  = 49_800    # 26% margin buffer
        s21_breakeven     = 36_900    # safe
        pct_to_s19xp  = (btc_p_for_hr - s19_xp_breakeven) / btc_p_for_hr * 100
        pct_to_s19pro = (btc_p_for_hr - s19_pro_breakeven) / btc_p_for_hr * 100

        if pct_to_s19pro < 0:
            # S19 Pro already underwater — forced selling happening NOW
            score -= 1; signals.append(("Miner Capitulation Risk", "BEARISH", -1,
                f"S19 Pro break-even ${s19_pro_breakeven:,} — {abs(pct_to_s19pro):.0f}% above current, forced selling active"))
        elif pct_to_s19xp < 15:
            # Within 15% of S19 XP shutdown — capitulation zone approaching
            signals.append(("Miner Capitulation Risk", "NEUTRAL", 0,
                f"S19 XP break-even ${s19_xp_breakeven:,} — {pct_to_s19xp:.0f}% buffer, watch closely"))
        else:
            # Comfortable distance — hash rate as regime confidence signal
            if hr_ehs > 750:
                score += 1; signals.append(("Miner Regime Signal", "BULLISH", +1,
                    f"{hr_ehs:.0f} EH/s — record hash rate, miners making multi-year capital bets"))
            else:
                signals.append(("Miner Regime Signal", "NEUTRAL", 0,
                    f"{hr_ehs:.0f} EH/s — healthy network, miners profitable at ${btc_p_for_hr:,.0f}"))
    else:
        signals.append(("Miner Regime Signal", "NEUTRAL", 0, "Data unavailable"))

    # 25. US High Yield credit spread ±1
    hy = md.get("hy_spread")
    hy_r = md.get("hy_regime","normal")
    if hy is not None:
        if hy_r == "stress":   # HY blowing out = credit fear = risk-off
            score -= 1; signals.append(("HY Credit Spread", "BEARISH", -1,
                f"{hy:.0f}bps — credit stress, risk-off conditions"))
        elif hy_r == "elevated":
            signals.append(("HY Credit Spread", "NEUTRAL", 0,
                f"{hy:.0f}bps — elevated but not stressed"))
        else:
            signals.append(("HY Credit Spread", "NEUTRAL", 0,
                f"{hy:.0f}bps — normal credit conditions"))
    else:
        signals.append(("HY Credit Spread", "NEUTRAL", 0, "Data unavailable"))

    # 26. Deribit max pain ±1
    maxpain = md.get("maxpain_strike")
    btc_cur = md.get("btc_price") or md.get("mark_price", 0)
    if maxpain and btc_cur:
        diff_pct = (btc_cur - maxpain) / maxpain * 100
        if diff_pct > 8:
            score -= 1; signals.append(("Options Max Pain", "BEARISH", -1,
                f"BTC ${btc_cur:,.0f} vs max pain ${maxpain:,.0f} (+{diff_pct:.1f}%) — gravity pulls down"))
        elif diff_pct < -8:
            score += 1; signals.append(("Options Max Pain", "BULLISH", +1,
                f"BTC ${btc_cur:,.0f} vs max pain ${maxpain:,.0f} ({diff_pct:.1f}%) — gravity pulls up"))
        else:
            signals.append(("Options Max Pain", "NEUTRAL", 0,
                f"BTC ${btc_cur:,.0f} near max pain ${maxpain:,.0f} ({diff_pct:+.1f}%)"))
    else:
        signals.append(("Options Max Pain", "NEUTRAL", 0, "Data unavailable"))

    # ── WAVE 5: INSTITUTIONAL + STRUCTURE + ADVANCED OPTIONS ─────

    # 27. IV Skew (25-delta put/call asymmetry) ±2
    iv_skew = md.get("iv_skew")
    iv_regime = md.get("iv_skew_regime","neutral")
    if iv_skew is not None:
        if iv_regime == "fear":        # puts >> calls = bearish positioning
            score -= 2; signals.append(("IV Skew (Deribit)", "BEARISH", -2,
                f"Put IV - Call IV = +{iv_skew:.1f}pp — heavy put buying, options traders hedging hard"))
        elif iv_regime == "slight_fear":
            score -= 1; signals.append(("IV Skew (Deribit)", "BEARISH", -1,
                f"Put IV - Call IV = +{iv_skew:.1f}pp — mild put premium, slight bearish lean"))
        elif iv_regime == "greed":     # calls >> puts = bullish speculation
            score += 1; signals.append(("IV Skew (Deribit)", "BULLISH", +1,
                f"Call IV > Put IV by {abs(iv_skew):.1f}pp — calls in demand, speculative buying"))
        else:
            signals.append(("IV Skew (Deribit)", "NEUTRAL", 0,
                f"Skew {iv_skew:+.1f}pp — balanced put/call demand"))
    else:
        signals.append(("IV Skew (Deribit)", "NEUTRAL", 0, "Data unavailable"))

    # 28. CME BTC futures ±2 (institutional positioning signal)
    cme_chg = md.get("cme_5d_chg")
    cme_prem = md.get("cme_premium_pct")
    if cme_chg is not None:
        if cme_chg > 2:
            pts = 2 if cme_chg > 4 else 1
            score += pts; signals.append(("CME BTC Futures", "BULLISH", pts,
                f"CME BTC +{cme_chg:.1f}% 5d — institutional money entering"))
        elif cme_chg < -2:
            pts = -2 if cme_chg < -4 else -1
            score += pts; signals.append(("CME BTC Futures", "BEARISH", pts,
                f"CME BTC {cme_chg:.1f}% 5d — institutional money exiting"))
        else:
            note = f", CME premium {cme_prem:+.2f}%" if cme_prem else ""
            signals.append(("CME BTC Futures", "NEUTRAL", 0,
                f"CME BTC {cme_chg:+.1f}% 5d{note} — neutral"))
    else:
        signals.append(("CME BTC Futures", "NEUTRAL", 0, "Data unavailable"))

    # 29. MicroStrategy NAV premium ±1
    mstr_prem = md.get("mstr_premium")
    if mstr_prem is not None:
        if mstr_prem > 80:     # extreme euphoria — contrarian bearish
            score -= 1; signals.append(("MSTR NAV Premium", "BEARISH", -1,
                f"MSTR +{mstr_prem:.0f}% premium to BTC NAV — institutional euphoria (contrarian)"))
        elif mstr_prem > 30:   # elevated but not extreme — bullish
            score += 1; signals.append(("MSTR NAV Premium", "BULLISH", +1,
                f"MSTR +{mstr_prem:.0f}% premium — institutions paying up for BTC exposure"))
        elif mstr_prem < 0:    # discount — institutional fear
            score -= 1; signals.append(("MSTR NAV Premium", "BEARISH", -1,
                f"MSTR {mstr_prem:.0f}% discount to BTC NAV — institutional fear"))
        else:
            signals.append(("MSTR NAV Premium", "NEUTRAL", 0,
                f"MSTR +{mstr_prem:.0f}% premium — normal range"))
    else:
        signals.append(("MSTR NAV Premium", "NEUTRAL", 0, "Data unavailable"))

    # 30. Cumulative 30-day funding rate ±2
    cum_fund = md.get("funding_cum_30d")
    fund_reg = md.get("funding_regime","normal")
    if cum_fund is not None:
        if fund_reg == "overheated":   # longs have been bleeding — capitulation likely
            score -= 2; signals.append(("Funding Cumulative", "BEARISH", -2,
                f"{cum_fund:.2f}% cumulative 30d — longs exhausted after sustained bleed"))
        elif fund_reg == "elevated":
            score -= 1; signals.append(("Funding Cumulative", "BEARISH", -1,
                f"{cum_fund:.2f}% cumulative 30d — longs crowded and paying steadily"))
        elif fund_reg == "negative":   # shorts paying for 30 days = shorts exhausted
            score += 1; signals.append(("Funding Cumulative", "BULLISH", +1,
                f"{cum_fund:.2f}% cumulative 30d — shorts exhausted, potential squeeze"))
        else:
            signals.append(("Funding Cumulative", "NEUTRAL", 0,
                f"{cum_fund:.2f}% cumulative 30d — no sustained imbalance"))
    else:
        signals.append(("Funding Cumulative", "NEUTRAL", 0, "Data unavailable"))

    # 31. Stablecoin depeg ±2 (emergency signal)
    stable_status = md.get("stable_status","normal")
    stable_dev    = md.get("stable_depeg", 0)
    if stable_status == "crisis":
        score -= 2; signals.append(("Stablecoin Depeg", "BEARISH", -2,
            f"USDT/USDC {stable_dev:.2f}% off $1 — EMERGENCY: stablecoin crisis"))
    elif stable_status == "stress":
        score -= 1; signals.append(("Stablecoin Depeg", "BEARISH", -1,
            f"USDT/USDC {stable_dev:.2f}% deviation — stablecoin stress, watch closely"))
    elif stable_status == "watch":
        signals.append(("Stablecoin Depeg", "NEUTRAL", 0,
            f"USDT/USDC {stable_dev:.2f}% — minor deviation, monitoring"))
    else:
        signals.append(("Stablecoin Depeg", "NEUTRAL", 0,
            f"USDT ${md.get('usdt_price',1):.4f} / USDC ${md.get('usdc_price',1):.4f} — pegged"))

    # 32. ETH/BTC ratio ±1
    eth_btc    = md.get("eth_btc_ratio")
    eth_trend  = md.get("eth_btc_trend","flat")
    if eth_btc is not None:
        if eth_trend == "rising":
            score += 1; signals.append(("ETH/BTC Ratio", "BULLISH", +1,
                f"ETH/BTC {eth_btc:.5f} rising — risk-on within crypto, alt season signal"))
        elif eth_trend == "falling":
            score -= 1; signals.append(("ETH/BTC Ratio", "BEARISH", -1,
                f"ETH/BTC {eth_btc:.5f} falling — risk-off, capital contracting to BTC"))
        else:
            signals.append(("ETH/BTC Ratio", "NEUTRAL", 0,
                f"ETH/BTC {eth_btc:.5f} flat — no clear risk appetite shift"))
    else:
        signals.append(("ETH/BTC Ratio", "NEUTRAL", 0, "Data unavailable"))

    # 33. Cross-exchange spread ±1
    spread_pct = md.get("spread_okx_pct")
    spread_st  = md.get("spread_status","normal")
    if spread_pct is not None:
        if spread_st == "fragmented":
            score -= 1; signals.append(("Exchange Spread", "BEARISH", -1,
                f"Binance/OKX spread {spread_pct:.3f}% — liquidity fragmented, volatility risk"))
        elif spread_st == "stressed":
            signals.append(("Exchange Spread", "NEUTRAL", 0,
                f"Binance/OKX spread {spread_pct:.3f}% — mildly elevated, watch"))
        else:
            signals.append(("Exchange Spread", "NEUTRAL", 0,
                f"Binance/OKX spread ${md.get('spread_okx_abs',0):.1f} — healthy"))
    else:
        signals.append(("Exchange Spread", "NEUTRAL", 0, "Data unavailable"))

    # 34. IV term structure ±1
    term_reg = md.get("iv_term_regime","normal")
    term_rat  = md.get("iv_term_ratio")
    if term_rat is not None:
        if term_reg == "front_loaded":
            score -= 1; signals.append(("IV Term Structure", "BEARISH", -1,
                f"Short/Long IV ratio {term_rat:.2f} — fear is front-loaded, imminent move expected"))
        elif term_reg == "back_loaded":
            signals.append(("IV Term Structure", "NEUTRAL", 0,
                f"Short/Long IV ratio {term_rat:.2f} — calm near-term, longer uncertainty"))
        else:
            signals.append(("IV Term Structure", "NEUTRAL", 0,
                f"Short/Long IV ratio {term_rat:.2f} — normal term structure"))
    else:
        signals.append(("IV Term Structure", "NEUTRAL", 0, "Data unavailable"))

    # 35. DVOL/RV ratio ±1
    dvol_rv   = md.get("dvol_rv_ratio")
    dvol_rv_r = md.get("dvol_rv_regime","fair")
    if dvol_rv is not None:
        if dvol_rv_r == "expensive":  # options pricey vs realized = sell vol
            signals.append(("DVOL/RV Ratio", "NEUTRAL", 0,
                f"DVOL/RV {dvol_rv:.2f} — options rich vs realised, vol likely to compress"))
        elif dvol_rv_r == "cheap":    # options cheap vs realized = expect big move
            if score < 0:  # if bearish, cheap options = bigger bear move expected
                score -= 1; signals.append(("DVOL/RV Ratio", "BEARISH", -1,
                    f"DVOL/RV {dvol_rv:.2f} — options cheap, market underpricing bearish risk"))
            else:
                score += 1; signals.append(("DVOL/RV Ratio", "BULLISH", +1,
                    f"DVOL/RV {dvol_rv:.2f} — options cheap, upside breakout underpriced"))
        else:
            signals.append(("DVOL/RV Ratio", "NEUTRAL", 0,
                f"DVOL/RV {dvol_rv:.2f} — options fairly priced"))
    else:
        signals.append(("DVOL/RV Ratio", "NEUTRAL", 0, "Insufficient data"))

    # 36. Volume/OI ratio ±1
    vol_oi   = md.get("vol_oi_ratio")
    vol_oi_r = md.get("vol_oi_regime","healthy")
    if vol_oi is not None:
        if vol_oi_r == "saturated":  # high leverage vs low volume = crash risk
            score -= 1; signals.append(("Vol/OI Ratio", "BEARISH", -1,
                f"Vol/OI {vol_oi:.2f} — leverage saturated vs spot volume, crash risk"))
        elif vol_oi_r == "elevated":
            signals.append(("Vol/OI Ratio", "NEUTRAL", 0,
                f"Vol/OI {vol_oi:.2f} — elevated leverage, monitor"))
        else:
            signals.append(("Vol/OI Ratio", "NEUTRAL", 0,
                f"Vol/OI {vol_oi:.2f} — healthy leverage ratio"))
    else:
        signals.append(("Vol/OI Ratio", "NEUTRAL", 0, "Data unavailable"))

    # 37. Multi-ETF flows ±1
    fbtc_sig = md.get("fbtc_signal","neutral")
    arkb_sig = md.get("arkb_signal","neutral")
    etf_bull = sum(1 for s in [fbtc_sig, arkb_sig] if s == "inflow")
    etf_bear = sum(1 for s in [fbtc_sig, arkb_sig] if s == "outflow")
    fbtc_chg = md.get("fbtc_5d_chg",0)
    arkb_chg = md.get("arkb_5d_chg",0)
    if etf_bull == 2:
        score += 1; signals.append(("Multi-ETF Flows", "BULLISH", +1,
            f"FBTC {fbtc_chg:+.1f}% + ARKB {arkb_chg:+.1f}% — Fidelity+ARK both in inflow"))
    elif etf_bear == 2:
        score -= 1; signals.append(("Multi-ETF Flows", "BEARISH", -1,
            f"FBTC {fbtc_chg:+.1f}% + ARKB {arkb_chg:+.1f}% — Fidelity+ARK both in outflow"))
    else:
        signals.append(("Multi-ETF Flows", "NEUTRAL", 0,
            f"FBTC {fbtc_chg:+.1f}% / ARKB {arkb_chg:+.1f}% — mixed ETF signals"))

    # 38. Miner hash price — profitability gauge, not a flow signal
    # Hash price = USD earned per TH/s per day. Miners are ~0.036% of volume
    # so daily selling is irrelevant to price. What matters:
    # LOW hash price = capitulation zone = CONTRARIAN BUY (forced sellers exhausted)
    # HIGH hash price = miners accumulating, reducing sell pressure
    hash_px  = md.get("hash_price_usd")
    hash_reg = md.get("hash_price_regime","normal")
    if hash_px is not None:
        usd_day = hash_px * 1000   # scale to $/TH (not per-hash)
        if hash_reg == "capitulation":
            # Only meaningful buy signal when price is also falling fast
            score += 1; signals.append(("Miner Hash Price", "BULLISH", +1,
                f"${hash_px:.5f}/TH/day — capitulation: forced sellers exhausted, historic buy zone"))
        elif hash_reg == "stress":
            score -= 1; signals.append(("Miner Hash Price", "BEARISH", -1,
                f"${hash_px:.5f}/TH/day — miners stressed, weaker operations selling to survive"))
        elif hash_reg == "high":
            score += 1; signals.append(("Miner Hash Price", "BULLISH", +1,
                f"${hash_px:.5f}/TH/day — very profitable, miners accumulating not selling"))
        else:
            signals.append(("Miner Hash Price", "NEUTRAL", 0,
                f"${hash_px:.5f}/TH/day — normal profitability, no forced selling pressure"))
    else:
        signals.append(("Miner Hash Price", "NEUTRAL", 0, "Data unavailable"))

    return score, signals

def hl_get(url, retries=2):
    for attempt in range(retries + 1):
        try:
            r = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
            if r.ok: return r.json()
        except Exception as e:
            if attempt == retries: return None
            time.sleep(1)
    return None

# ── LEADERBOARD ────────────────────────────────────────────
def fetch_leaderboard():
    """Fetch Hyperliquid leaderboard — top traders by PnL"""
    print(f"  Fetching leaderboard...", flush=True)
    data = hl_get(HL_LB)
    if not data:
        print("  Leaderboard unavailable, trying info endpoint...", flush=True)
        data = hl_post({"type": "leaderboard"})
    if not data:
        return []

    # The leaderboard returns a list of entries
    entries = data if isinstance(data, list) else data.get("leaderboardRows", data.get("data", []))

    wallets = []
    for entry in entries:
        try:
            # Field names vary — try all known formats
            addr = (entry.get("ethAddress") or entry.get("address") or
                    entry.get("user") or entry.get("account") or "")
            if not addr or not addr.startswith("0x"): continue

            # PnL fields
            pnl_all  = float(entry.get("allTimePnl") or entry.get("pnl") or
                              entry.get("totalPnl") or entry.get("accountValue", 0))
            pnl_30d  = float(entry.get("pnl30d") or entry.get("windowPnl") or
                              entry.get("pnlMonth") or 0)

            # Win rate
            wr = float(entry.get("winRate") or entry.get("winRatio") or 0)

            # Volume
            vol = float(entry.get("vlm") or entry.get("volume") or 0)

            if pnl_all >= MIN_ALL_TIME_PNL:
                wallets.append({
                    "address": addr.lower(),
                    "pnl_all": pnl_all,
                    "pnl_30d": pnl_30d,
                    "win_rate": wr,
                    "volume": vol,
                    "rank": len(wallets) + 1
                })
        except Exception: continue

    # If leaderboard didn't have PnL data, just return all addresses
    if not wallets and entries:
        print(f"  [debug] leaderboard keys: {list(entries[0].keys())[:10]}", flush=True)
        for i, entry in enumerate(entries[:TOP_N_WALLETS]):
            addr = (entry.get("ethAddress") or entry.get("address") or
                    entry.get("user") or "")
            if addr and addr.startswith("0x"):
                wallets.append({"address": addr.lower(), "pnl_all": 0,
                                 "pnl_30d": 0, "win_rate": 0, "volume": 0, "rank": i+1})

    print(f"  {len(wallets)} qualifying wallets from leaderboard", flush=True)
    return wallets[:TOP_N_WALLETS]

# ── POSITIONS ──────────────────────────────────────────────
def fetch_positions(wallet_addr):
    """Fetch current open perpetual positions for a wallet"""
    data = hl_post({"type": "clearinghouseState", "user": wallet_addr})
    if not data: return [], {}

    margin = data.get("marginSummary", {})
    account_value = float(margin.get("accountValue") or 0)
    total_ntl = float(margin.get("totalNtlPos") or 0)

    positions = []
    for ap in (data.get("assetPositions") or []):
        try:
            pos = ap.get("position") or ap
            coin = pos.get("coin", "")
            szi  = float(pos.get("szi") or 0)     # positive = long, negative = short
            if szi == 0: continue

            entry_px     = float(pos.get("entryPx") or 0)
            pos_value    = float(pos.get("positionValue") or abs(szi * entry_px))
            unrealized   = float(pos.get("unrealizedPnl") or 0)
            liq_px       = float(pos.get("liquidationPx") or 0) if pos.get("liquidationPx") else None
            leverage_info= pos.get("leverage") or {}
            leverage     = float(leverage_info.get("value") or leverage_info.get("rawUsd") or 1)
            lev_type     = leverage_info.get("type") or "cross"

            positions.append({
                "coin": coin,
                "side": "LONG" if szi > 0 else "SHORT",
                "size": abs(szi),
                "size_usd": pos_value,
                "entry_px": entry_px,
                "liq_px": liq_px,
                "unrealized_pnl": unrealized,
                "leverage": leverage,
                "lev_type": lev_type,
            })
        except Exception: continue

    summary = {
        "account_value": account_value,
        "total_ntl": total_ntl,
    }
    return positions, summary

def fetch_recent_pnl(wallet_addr):
    """Get 30-day PnL from fills as a fallback"""
    since = (now_ts() - 30*24*3600) * 1000  # 30 days in ms
    data = hl_post({"type": "userFillsByTime", "user": wallet_addr,
                    "startTime": int(since)})
    if not isinstance(data, list): return 0
    return sum(float(f.get("closedPnl") or 0) for f in data)

# ── STATE ──────────────────────────────────────────────────
def load_state():
    if os.path.exists(STATE_FILE):
        try:
            with open(STATE_FILE) as f: return json.load(f)
        except: pass
    return {}

def save_state(state):
    with open(STATE_FILE, "w") as f: json.dump(state, f, indent=2)

def save_history(ts_str, btc_long_usd, btc_short_usd, btc_bias,
                 market_data=None, conv_score=None, conv_signals=None, big_positions=None):
    """Append one full scan record to history — saves everything for backtesting."""
    md = market_data or {}
    ratio = btc_short_usd / btc_long_usd if btc_long_usd > 0 else 0
    # Per-coin exposure snapshot
    coin_exp = {}
    if big_positions:
        for p in big_positions:
            coin = p.get("coin","").upper()
            side = p.get("side","")
            size = p.get("size_usd", 0) or 0
            if coin not in coin_exp: coin_exp[coin] = {"l": 0, "s": 0}
            if side == "LONG": coin_exp[coin]["l"] += size
            else:              coin_exp[coin]["s"] += size
    record = {
        "ts": ts_str,
        # Whale
        "hl_long":  round(btc_long_usd, 0),
        "hl_short": round(btc_short_usd, 0),
        "hl_ratio": round(ratio, 4),
        "hl_bias":  btc_bias,
        "hl_coins": {k: {"l": round(v["l"]), "s": round(v["s"])}
                     for k, v in coin_exp.items() if v["l"]+v["s"] > 500000},
        # Score
        "score": conv_score,
        # Derivatives
        "funding_bn":  md.get("funding_rate"),
        "funding_bb":  md.get("bybit_funding"),
        "funding_okx": md.get("okx_funding"),
        "funding_cum": md.get("funding_cum_30d"),
        "funding_reg": md.get("funding_regime"),
        "dvol":        md.get("dvol"),
        "dvol_trend":  md.get("dvol_trend"),
        "dvol_rv":     md.get("dvol_rv_ratio"),
        "pc_ratio":    md.get("pc_ratio"),
        "pc_sent":     md.get("pc_sentiment"),
        "maxpain":     md.get("maxpain_strike"),
        "iv_skew":     md.get("iv_skew"),
        "iv_skew_reg": md.get("iv_skew_regime"),
        "iv_term":     md.get("iv_term_ratio"),
        "iv_term_reg": md.get("iv_term_regime"),
        "basis_ann":   md.get("basis_ann"),
        "basis_str":   md.get("basis_struct"),
        # Institutional
        "ibit_chg":  md.get("etf_price_5d_chg"),
        "ibit_sig":  md.get("etf_flow_signal"),
        "fbtc_chg":  md.get("fbtc_5d_chg"),
        "arkb_chg":  md.get("arkb_5d_chg"),
        "cme_chg":   md.get("cme_5d_chg"),
        "cme_prem":  md.get("cme_premium_pct"),
        "mstr_prem": md.get("mstr_premium"),
        "top_ls":    md.get("top_ls_ratio"),
        "bb_ls":     md.get("bybit_ls_ratio"),
        "cb_prem":   md.get("cb_premium"),
        # Market structure
        "oi_btc":   md.get("oi_btc"),
        "oi_trend": md.get("oi_trend"),
        "vol_oi":   md.get("vol_oi_ratio"),
        "taker_buy":md.get("taker_buy_pct"),
        "spread":   md.get("spread_okx_pct"),
        # On-chain
        "mempool_fee": md.get("mempool_fastest_fee"),
        "mempool_lvl": md.get("mempool_level"),
        "hashrate":    md.get("hashrate_th"),
        "hash_px":     md.get("hash_price_usd"),
        # Macro
        "btc_price":   md.get("btc_price") or md.get("mark_price"),
        "btc_vs_ma":   md.get("btc_vs_200ma"),
        "dxy_chg":     md.get("dxy_5d_chg"),
        "dxy":         md.get("dxy_current"),
        "spx_chg":     md.get("spx_5d_chg"),
        "gold_chg":    md.get("gold_5d_chg"),
        "yield_10y":   md.get("yield_10y"),
        "yield_curve": md.get("yield_curve"),
        "hy_spread":   md.get("hy_spread"),
        # Crypto macro
        "btc_dom":      md.get("btc_dominance"),
        "eth_btc":      md.get("eth_btc_ratio"),
        "stable_b":     md.get("stable_total_b"),
        "stable_chg":   md.get("stable_7d_pct"),
        "usdt_px":      md.get("usdt_price"),
        "usdc_px":      md.get("usdc_price"),
        # Sentiment
        "fg":        md.get("score"),
        "fg_label":  md.get("label"),
        "wiki_7d":   md.get("wiki_avg7"),
        "wiki_ratio":md.get("wiki_ratio"),
        "pm_cut":    md.get("pm_rate_cut_prob"),
        "pm_rec":    md.get("pm_recession_prob"),
        "rv14":      md.get("rv14"),

        # ── Real-time price data ───────────────────────────────
        "btc_rt":      md.get("btc_rt_price"),
        "btc_24h_pct": md.get("btc_24h_chg_pct"),
        "btc_24h_hi":  md.get("btc_24h_high"),
        "btc_24h_lo":  md.get("btc_24h_low"),
        "btc_vol_usd": md.get("btc_24h_vol_usd"),
        "eth_price":   md.get("eth_price"),
        "sol_price":   md.get("sol_price"),

        # ── Absolute macro levels (not just 5d change) ─────────
        "spx":         md.get("spx_current"),
        "gold":        md.get("gold_current"),

        # ── Whale metrics ─────────────────────────────────────
        "n_wallets_long":  len([p for p in (big_positions or [])
                                if p.get("coin","").upper() in ("BTC","UBTC")
                                and p.get("side") == "LONG"]),
        "n_wallets_short": len([p for p in (big_positions or [])
                                if p.get("coin","").upper() in ("BTC","UBTC")
                                and p.get("side") == "SHORT"]),

        # Avg entry price of longs and shorts (weighted by size)
        "whale_long_entry":  (
            sum(p.get("entry_px",0)*(p.get("size_usd",0) or 0)
                for p in (big_positions or [])
                if p.get("coin","").upper() in ("BTC","UBTC")
                and p.get("side")=="LONG" and p.get("entry_px"))
            / max(1, sum(p.get("size_usd",0) or 0
                         for p in (big_positions or [])
                         if p.get("coin","").upper() in ("BTC","UBTC")
                         and p.get("side")=="LONG" and p.get("entry_px")))
        ) if any(p.get("coin","").upper() in ("BTC","UBTC") and p.get("side")=="LONG"
                 for p in (big_positions or [])) else None,

        "whale_short_entry": (
            sum(p.get("entry_px",0)*(p.get("size_usd",0) or 0)
                for p in (big_positions or [])
                if p.get("coin","").upper() in ("BTC","UBTC")
                and p.get("side")=="SHORT" and p.get("entry_px"))
            / max(1, sum(p.get("size_usd",0) or 0
                         for p in (big_positions or [])
                         if p.get("coin","").upper() in ("BTC","UBTC")
                         and p.get("side")=="SHORT" and p.get("entry_px")))
        ) if any(p.get("coin","").upper() in ("BTC","UBTC") and p.get("side")=="SHORT"
                 for p in (big_positions or [])) else None,

        # Short concentration (top 3 as % of total short)
        "top3_conc": (
            sum(sorted([p.get("size_usd",0) or 0
                        for p in (big_positions or [])
                        if p.get("coin","").upper() in ("BTC","UBTC")
                        and p.get("side")=="SHORT"],
                       reverse=True)[:3])
            / max(1, btc_short_usd) * 100
        ) if btc_short_usd > 0 else None,

        # Nearest liq levels (up = short liq, down = long liq)
        "liq_up_pct":  min(
            [(p.get("liq_px",0) - (md.get("btc_rt_price") or 67000))
             / (md.get("btc_rt_price") or 67000) * 100
             for p in (big_positions or [])
             if p.get("coin","").upper() in ("BTC","UBTC")
             and p.get("side") == "SHORT"
             and p.get("liq_px") and p.get("liq_px") > (md.get("btc_rt_price") or 67000)
             and p.get("size_usd",0) > 1_000_000],
            default=None
        ),
        "liq_dn_pct":  min(
            [((md.get("btc_rt_price") or 67000) - p.get("liq_px",0))
             / (md.get("btc_rt_price") or 67000) * 100
             for p in (big_positions or [])
             if p.get("coin","").upper() in ("BTC","UBTC")
             and p.get("side") == "LONG"
             and p.get("liq_px") and p.get("liq_px") < (md.get("btc_rt_price") or 67000)
             and p.get("size_usd",0) > 1_000_000],
            default=None
        ),

        # ── Time context ──────────────────────────────────────
        "hour_utc":    int(ts_str[11:13]) if len(ts_str) > 12 else None,
        "dow":         None,  # filled below

        # ── Data quality ──────────────────────────────────────
        "deribit_ok":  md.get("dvol") is not None,
        "fred_ok":     md.get("yield_10y") is not None,
        "macro_ok":    md.get("spx_current") is not None,

        # ── Per-signal score breakdown ─────────────────────────
        "signals": {s[0]: s[2] for s in (conv_signals or [])},
    }

    # Fill day of week from timestamp
    try:
        from datetime import datetime as _dtt
        _dt_parsed = _dtt.strptime(ts_str[:16], "%Y-%m-%d %H:%M")
        record["dow"] = _dt_parsed.weekday()  # 0=Mon, 6=Sun
    except: pass
    # Strip None values to keep file compact
    record = {k: v for k, v in record.items() if v is not None}
    with open(HISTORY_FILE, "a", encoding="utf-8") as f:
        f.write(json.dumps(record) + "\n")

def load_history(max_records=50000):
    """Load last N scan records from history file."""
    if not os.path.exists(HISTORY_FILE):
        return []
    with open(HISTORY_FILE, "r", encoding="utf-8") as f:
        lines = [l.strip() for l in f if l.strip()]
    records = []
    for l in lines:
        try: records.append(json.loads(l))
        except: continue
    return records[-max_records:]

# ── ALERT CLASS ────────────────────────────────────────────
class Alert:
    def __init__(self, atype, wallet, wallet_info, coin, side,
                 size_usd, entry_px, liq_px, leverage, lev_type,
                 unrealized_pnl, prev_size_usd=None, account_value=None):
        self.atype = atype           # NEW_POSITION | SIZE_INCREASE | BIG_POSITION
        self.wallet = wallet
        self.wallet_info = wallet_info
        self.coin = coin
        self.side = side             # LONG | SHORT
        self.size_usd = size_usd
        self.entry_px = entry_px
        self.liq_px = liq_px
        self.leverage = leverage
        self.lev_type = lev_type
        self.unrealized_pnl = unrealized_pnl
        self.prev_size_usd = prev_size_usd
        self.account_value = account_value or 0
        self.timestamp = now_ts()
        self.date = now_str()
        self.is_btc = coin.upper() in ("BTC","UBTC","WBTC")
        self.is_eth = coin.upper() in ("ETH","WETH")
        self.is_major = self.is_btc or self.is_eth or coin.upper() in ("SOL","BNB","ARB")

        # Score: higher = more interesting signal
        score = 0
        if atype == "NEW_POSITION": score += 5
        if atype == "SIZE_INCREASE": score += 3
        if size_usd >= 1_000_000: score += 4
        elif size_usd >= 500_000: score += 3
        elif size_usd >= 200_000: score += 2
        elif size_usd >= 100_000: score += 1
        if self.is_btc: score += 2
        if self.is_eth: score += 1
        pnl = wallet_info.get("pnl_all", 0)
        if pnl >= 1_000_000: score += 3
        elif pnl >= 500_000: score += 2
        elif pnl >= 100_000: score += 1
        self.score = min(10, score)

# ── MAIN SCAN ──────────────────────────────────────────────
def run_scan(prev_state, wallets, verbose=True):
    alerts = []
    new_state = {}

    if verbose:
        print(f"\n[SCAN] {now_str()} — {len(wallets)} wallets", flush=True)

    for i, w in enumerate(wallets):
        addr = w["address"]
        if verbose and i % 10 == 0:
            pct = int(i / len(wallets) * 80) + 10
            print(f"  [{pct:2d}%] wallet {i+1}/{len(wallets)}", flush=True)

        positions, summary = fetch_positions(addr)
        time.sleep(SLEEP)

        # Store snapshot keyed by coin+side
        snap = {}
        for p in positions:
            key = f"{p['coin']}_{p['side']}"
            snap[key] = p

        new_state[addr] = {"positions": snap, "summary": summary, "ts": now_ts()}

        prev = prev_state.get(addr, {}).get("positions", {})
        # Only alert new positions if we have a prior state for this wallet
        # (avoids 500 "new" alerts on first run)
        has_prior_state = addr in prev_state

        for key, pos in snap.items():
            if pos["size_usd"] < MIN_POSITION_USD: continue

            if key not in prev:
                # New position
                if pos["size_usd"] >= NEW_POSITION_MIN and has_prior_state:
                    a = Alert("NEW_POSITION", addr, w,
                              pos["coin"], pos["side"],
                              pos["size_usd"], pos["entry_px"],
                              pos["liq_px"], pos["leverage"], pos["lev_type"],
                              pos["unrealized_pnl"],
                              account_value=summary.get("account_value"))
                    alerts.append(a)
                    log_alert(f"[NEW {pos['side']} {pos['coin']}] "
                              f"Whale {addr[:10]}... | "
                              f"{fmt_usd(pos['size_usd'])} @ ${pos['entry_px']:,.0f}")
            else:
                # Existing position — check for size increase
                prev_pos = prev[key]
                prev_usd = prev_pos.get("size_usd", 0)
                if prev_usd > 0:
                    increase = (pos["size_usd"] - prev_usd) / prev_usd
                    if increase >= MIN_SIZE_INCREASE and pos["size_usd"] >= MIN_POSITION_USD:
                        a = Alert("SIZE_INCREASE", addr, w,
                                  pos["coin"], pos["side"],
                                  pos["size_usd"], pos["entry_px"],
                                  pos["liq_px"], pos["leverage"], pos["lev_type"],
                                  pos["unrealized_pnl"],
                                  prev_size_usd=prev_usd,
                                  account_value=summary.get("account_value"))
                        alerts.append(a)
                        log_alert(f"[SIZE +{increase*100:.0f}% {pos['side']} {pos['coin']}] "
                                  f"Whale {addr[:10]}... | "
                                  f"{fmt_usd(prev_usd)} → {fmt_usd(pos['size_usd'])}")
                        # Individual position alerts removed — aggregate alerts only

    # Also report currently open big positions (even if unchanged) for the report
    big_positions = []
    for addr, snap_data in new_state.items():
        winfo = next((w for w in wallets if w["address"] == addr), {})
        for key, pos in snap_data.get("positions", {}).items():
            if pos["size_usd"] >= MIN_POSITION_USD:
                big_positions.append({
                    "wallet": addr,
                    "wallet_info": winfo,
                    **pos
                })

    big_positions.sort(key=lambda x: -x["size_usd"])

    if verbose:
        print(f"\n  Scan done: {len(alerts)} new alerts | "
              f"{len(big_positions)} large open positions", flush=True)

    return alerts, big_positions, new_state

# ── HTML REPORT ────────────────────────────────────────────
ATYPE_LABELS = {
    "NEW_POSITION":  ("NEW POSITION",  "#39d353"),
    "SIZE_INCREASE": ("SIZE INCREASE", "#f0b429"),
    "BIG_POSITION":  ("LARGE OPEN",   "#4da3ff"),
}

def build_html(alerts, big_positions, wallets, ts_str, scan_num=1, history=None, market_data=None):
    live_prices = fetch_all_mids()
    market_data = market_data or {}   # current mid prices for liq distance calc
    n_alerts = len(alerts)
    new_pos  = [a for a in alerts if a.atype == "NEW_POSITION"]
    size_inc = [a for a in alerts if a.atype == "SIZE_INCREASE"]
    btc_alerts = [a for a in alerts if a.is_btc]

    def pos_row(p, is_alert=False, atype=None):
        wi = p.get("wallet_info") if isinstance(p, dict) else {}
        addr = p.get("wallet","") if isinstance(p, dict) else p.wallet
        coin = p.get("coin","") if isinstance(p, dict) else p.coin
        side = p.get("side","") if isinstance(p, dict) else p.side
        size = p.get("size_usd",0) if isinstance(p, dict) else p.size_usd
        entry= p.get("entry_px",0) if isinstance(p, dict) else p.entry_px
        liq  = p.get("liq_px") if isinstance(p, dict) else p.liq_px
        lev  = p.get("leverage",1) if isinstance(p, dict) else p.leverage
        lt   = p.get("lev_type","cross") if isinstance(p, dict) else p.lev_type
        upnl = p.get("unrealized_pnl",0) if isinstance(p, dict) else p.unrealized_pnl
        w_pnl= (wi or {}).get("pnl_all", 0) if isinstance(p, dict) else p.wallet_info.get("pnl_all",0)
        w_wr = (wi or {}).get("win_rate", 0) if isinstance(p, dict) else p.wallet_info.get("win_rate",0)
        score= p.score if not isinstance(p, dict) else 0
        prev = p.prev_size_usd if not isinstance(p, dict) else None

        side_c  = "#39d353" if side == "LONG" else "#f05454"
        pnl_c   = "#39d353" if upnl >= 0 else "#f05454"
        coin_c  = "#f0b429" if coin.upper() in ("BTC","UBTC") else "#4da3ff" if coin.upper() in ("ETH","WETH") else "#d4d0c8"
        score_c = "#39d353" if score>=7 else "#f0b429" if score>=5 else "#7a7870"
        liq_str = f"${liq:,.0f}" if liq else "—"
        prev_str= f"<span style='color:var(--t3)'>was {fmt_usd(prev)} →</span> " if prev else ""
        wr_cell = f'<td class="nr" style="color:var(--t2)">{w_wr*100:.0f}%</td>' if w_wr else '<td class="nr" style="color:var(--t3)">—</td>'

        tag = ""
        if atype:
            lbl, col = ATYPE_LABELS.get(atype, ("","#888"))[:2]
            tag = f'<span style="background:{col}25;color:{col};border:1px solid {col}50;padding:0 5px;border-radius:2px;font-size:9px">{lbl}</span>'

        cells = [
            f'<td>{tag}</td>',
            f'<td style="color:{coin_c};font-weight:500">{esc(coin)}</td>',
            f'<td style="color:{side_c};font-weight:500">{side}</td>',
            f'<td class="nr">{prev_str}{fmt_usd(size)}</td>',
            f'<td class="nr">${entry:,.2f}</td>',
            f'<td class="nr">{liq_str}</td>',
            f'<td class="nr">{lev:.0f}x <span style="color:var(--t3)">{lt}</span></td>',
            f'<td class="nr" style="color:{pnl_c}">{fmt_usd(upnl)}</td>',
            f'<td class="nr" style="color:var(--t2);font-size:10px">{addr[:10]}...</td>',
            f'<td class="nr" style="color:var(--t2)">{fmt_usd(w_pnl)}</td>',
            wr_cell,
            f'<td class="nr" style="color:{score_c}">{score:.0f}</td>',
        ]
        return "<tr>" + "".join(cells) + "</tr>"

    thead = '''<thead><tr>
      <th>Signal</th><th>Coin</th><th>Side</th><th class="nr">Size</th>
      <th class="nr">Entry</th><th class="nr">Liq price</th><th class="nr">Leverage</th>
      <th class="nr">Unreal PnL</th><th class="nr">Wallet</th>
      <th class="nr">All-time PnL</th><th class="nr">WR</th><th class="nr">Score</th>
    </tr></thead>'''

    alert_rows = "".join(pos_row(a, True, a.atype) for a in
                         sorted(alerts, key=lambda x: -x.score)) or \
        '<tr><td colspan="12" style="text-align:center;color:var(--t3);padding:20px">No new signals this scan</td></tr>'

    big_rows = "".join(pos_row(p) for p in big_positions[:30]) or \
        '<tr><td colspan="12" style="text-align:center;color:var(--t3);padding:20px">No large positions detected</td></tr>'

    btc_side_counts = defaultdict(int)
    btc_size_totals = defaultdict(float)
    for p in big_positions:
        if p.get("coin","").upper() in ("BTC","UBTC"):
            btc_side_counts[p["side"]] += 1
            btc_size_totals[p["side"]] += p["size_usd"]
    btc_long_usd  = btc_size_totals.get("LONG", 0)
    btc_short_usd = btc_size_totals.get("SHORT", 0)
    btc_total = btc_long_usd + btc_short_usd
    btc_bias = "BULLISH" if btc_long_usd > btc_short_usd * 1.3 else \
               "BEARISH" if btc_short_usd > btc_long_usd * 1.3 else "NEUTRAL"
    bias_c = "#39d353" if btc_bias == "BULLISH" else "#f05454" if btc_bias == "BEARISH" else "#f0b429"

    # ── CONVICTION SCORE ────────────────────────────────────────
    conv_score, conv_signals = score_signal(market_data, btc_bias)
    # Score range -5 to +5, normalise to 0-100 for display
    conv_bar_pct = max(0, min(100, (conv_score + 44) / 88 * 100))  # ±44 realistic max
    # Thresholds calibrated to realistic ±44 maximum:
    # Extreme = ±26 (59% of max) — near-total signal agreement
    # Strong  = ±15 (34%)        — majority of signals agree
    # Moderate= ±8  (18%)        — more agree than disagree
    # Neutral = ±7 or less       — mixed / no edge
    if conv_score <= -26:
        conv_label = "EXTREME BEARISH"
        conv_col   = "#d41515"
    elif conv_score <= -15:
        conv_label = "STRONG BEARISH"
        conv_col   = "#f05454"
    elif conv_score <= -8:
        conv_label = "BEARISH"
        conv_col   = "#f0836a"
    elif conv_score <= -3:
        conv_label = "MILD BEARISH"
        conv_col   = "#f0b090"
    elif conv_score >= 26:
        conv_label = "EXTREME BULLISH"
        conv_col   = "#00c44f"
    elif conv_score >= 15:
        conv_label = "STRONG BULLISH"
        conv_col   = "#39d353"
    elif conv_score >= 8:
        conv_label = "BULLISH"
        conv_col   = "#67d96a"
    elif conv_score >= 3:
        conv_label = "MILD BULLISH"
        conv_col   = "#a0d9a0"
    else:
        conv_label = "NEUTRAL"
        conv_col   = "#f0b429"

    # Build signal rows for the table
    # Impact ratings per signal name (1-5 stars)
    IMPACT = {
        # ★★★★★ Tier 1 — Highest causal impact
        "IV Skew (Deribit)":      5,
        "ETF Flows (IBIT)":       5,
        "HL Whales":              5,
        "CME BTC Futures":        5,
        "Stablecoin Depeg":       5,
        # ★★★★ Tier 2 — Strong institutional/structural
        "DXY Dollar Index":       4,
        "S&P 500 (SPX)":          4,
        "Funding Cumulative":     4,
        "Options P/C Ratio":      4,
        "Options Max Pain":       4,
        "IV Term Structure":      4,
        "MSTR NAV Premium":       4,
        "Polymarket Macro":       4,
        "Miner Capitulation Risk": 4,
        "Miner Regime Signal":    3,
        "Miner Hash Price":        3,
        "Mempool":                4,
        # ★★★ Tier 3 — Solid supporting signals
        "Funding (multi)":        3,
        "US 10Y Treasury":        3,
        "Yield Curve (10-2Y)":    3,
        "Futures Basis":          3,
        "Deribit DVOL":           3,
        "DVOL/RV Ratio":          3,
        "Binance Top Traders":    3,
        "BTC vs 200MA":           3,
        "Multi-ETF Flows":        3,
        # ★★ Tier 4 — Useful context
        "ETH/BTC Ratio":          2,
        "Bybit L/S Ratio":        2,
        "Open Interest":          2,
        "Vol/OI Ratio":           2,
        "Coinbase Premium":       2,
        "Stablecoin Supply":      2,
        "Gold":                   2,
        "Taker Volume":           2,
        "BTC Dominance":          2,
        "Exchange Spread":        2,
        "Wikipedia Views":        2,
        "HY Credit Spread":       2,
        # ★ Tier 5 — Sentiment baseline
        "Fear & Greed":           1,
        "BTC Dominance":          1,
    }

    def sig_row(sig):
        name, bias, pts, desc = sig
        if bias == "BULLISH":
            bc = "#39d353"; arrow = "↑"; pts_str = f"+{pts}"
        elif bias == "BEARISH":
            bc = "#f05454"; arrow = "↓"; pts_str = str(pts)
        else:
            bc = "#7a7870"; arrow = "→"; pts_str = "0"
        impact = IMPACT.get(name, 2)
        stars  = "★" * impact + "☆" * (5 - impact)
        return (f'<tr>'
                f'<td style="color:var(--t3);font-size:9px;letter-spacing:.03em">{stars}</td>'
                f'<td style="font-weight:500">{name}</td>'
                f'<td style="color:{bc};font-weight:500">{arrow} {bias}</td>'
                f'<td class="nr" style="color:{bc}">{pts_str}</td>'
                f'<td style="color:var(--t2);font-size:10px">{desc}</td></tr>')

    conv_rows = "".join(sig_row(s) for s in conv_signals)

    # ── CROSS-ASSET HEATMAP ──────────────────────────────────────
    # Aggregate all coins from big_positions
    from collections import defaultdict as _dd
    _coin_long  = _dd(float)
    _coin_short = _dd(float)
    _coin_n     = _dd(int)
    for p in big_positions:
        coin = p.get("coin","").upper().replace("UBTC","BTC").replace("WETH","ETH")
        side = p.get("side","")
        sz   = p.get("size_usd", 0)
        if side == "LONG":  _coin_long[coin]  += sz
        elif side == "SHORT": _coin_short[coin] += sz
        _coin_n[coin] += 1

    # Build sorted coin list by total exposure, top 16
    all_coins = set(_coin_long.keys()) | set(_coin_short.keys())
    coin_data = []
    for coin in all_coins:
        l = _coin_long[coin]
        s = _coin_short[coin]
        total = l + s
        if total < 100_000: continue   # skip tiny exposures
        ratio = (l - s) / total        # -1=all short, +1=all long
        bias  = "BULLISH" if ratio > 0.2 else "BEARISH" if ratio < -0.2 else "NEUTRAL"
        coin_data.append({
            "coin": coin, "long": l, "short": s,
            "total": total, "ratio": ratio, "bias": bias,
            "n": _coin_n[coin]
        })
    coin_data.sort(key=lambda x: -x["total"])
    top_coins = coin_data[:16]

    # ── NEAREST LIQUIDATION per coin ─────────────────────────────
    _coin_liq = {}
    for _p in big_positions:
        _coin = _p.get("coin","").upper().replace("UBTC","BTC").replace("WETH","ETH")
        _liq  = _p.get("liq_px")
        if not _liq or float(_liq) <= 0: continue
        _liq  = float(_liq)
        _cur  = live_prices.get(_coin, 0)
        if _cur <= 0: continue
        _dist = abs(_liq - _cur) / _cur
        if _dist > 0.80: continue   # ignore unrealistic liq prices
        if _coin not in _coin_liq or _dist < _coin_liq[_coin]["dist"]:
            _coin_liq[_coin] = {
                "liq_px": _liq, "cur_px": _cur, "dist": _dist,
                "side": _p.get("side",""), "size": _p.get("size_usd",0),
                "wallet": _p.get("wallet","")[:10],
            }
    # Attach liq info to coin_data entries
    for _cd in coin_data:
        _cd["liq"] = _coin_liq.get(_cd["coin"])

    def heat_cell(cd):
        ratio = cd["ratio"]          # -1 to +1
        bias  = cd["bias"]
        # Color: green for bullish, red for bearish, intensity = conviction
        intensity = min(abs(ratio), 1.0)
        if bias == "BULLISH":
            r,g,b = int(57 * intensity), int(211 * intensity), int(83 * intensity)
            bg = f"rgba({r},{g},{b},0.18)"
            border = f"rgba({r},{g},{b},0.5)"
            txt_c = "#39d353"
        elif bias == "BEARISH":
            r,g,b = int(240 * intensity), int(84 * intensity), int(84 * intensity)
            bg = f"rgba({r},{g},{b},0.18)"
            border = f"rgba({r},{g},{b},0.5)"
            txt_c = "#f05454"
        else:
            bg = "rgba(122,120,112,0.08)"
            border = "rgba(122,120,112,0.25)"
            txt_c = "#7a7870"

        l_str = fmt_usd(cd["long"])
        s_str = fmt_usd(cd["short"])
        pct_l = cd["long"]  / cd["total"] * 100
        pct_s = cd["short"] / cd["total"] * 100

        arrow = "↑" if bias=="BULLISH" else "↓" if bias=="BEARISH" else "→"
        return (
            f'<div style="background:{bg};border:1px solid {border};border-radius:3px;'
            f'padding:8px 10px;display:flex;flex-direction:column;gap:3px">'
            f'<div style="display:flex;justify-content:space-between;align-items:baseline">'
            f'<span style="font-weight:500;font-size:12px">{esc(cd["coin"])}</span>'
            f'<span style="color:{txt_c};font-size:13px">{arrow}</span></div>'
            f'<div style="font-size:9px;color:var(--t2)">{fmt_usd(cd["total"])} total</div>'
            f'<div style="height:3px;background:var(--b2);border-radius:2px;margin:2px 0">'
            f'<div style="width:{pct_l:.0f}%;height:100%;background:#39d353;border-radius:2px;float:left"></div>'
            f'<div style="width:{pct_s:.0f}%;height:100%;background:#f05454;border-radius:2px;float:right"></div>'
            f'</div>'
            f'<div style="display:flex;justify-content:space-between;font-size:9px">'
            f'<span style="color:#39d353">L {l_str}</span>'
            f'<span style="color:#f05454">S {s_str}</span>'
            f'</div></div>'
        )

    heatmap_cells = "".join(heat_cell(cd) for cd in top_coins)
    if not heatmap_cells:
        heatmap_cells = '<div style="color:var(--t3);font-size:10px;padding:10px">No position data yet</div>'

    n_bull = sum(1 for c in top_coins if c["bias"]=="BULLISH")
    n_bear = sum(1 for c in top_coins if c["bias"]=="BEARISH")
    n_neut = sum(1 for c in top_coins if c["bias"]=="NEUTRAL")

    # ── CASCADE BANNER ───────────────────────────────────────────
    cascade_risks = sorted(
        [{"coin": c, **l} for c, l in _coin_liq.items() if l["dist"] < 0.05],
        key=lambda x: x["dist"]
    )
    if cascade_risks:
        risk_items = "".join(
            f'<div style="display:flex;gap:12px;align-items:center;padding:5px 0;'
            f'border-bottom:1px solid #f0545420">'
            f'<span style="font-weight:500;color:#f05454;min-width:50px">{r["coin"]}</span>'
            f'<span style="color:var(--t2);font-size:10px">'
            f'${r["size"]/1e6:.2f}M {r["side"]} · liq ${r["liq_px"]:,.0f} · '
            f'<strong style="color:#f05454">{r["dist"]*100:.1f}% away</strong> · {r["wallet"]}...</span></div>'
            for r in cascade_risks
        )
        cascade_banner = (
            f'<div style="background:#f0545410;border:1px solid #f0545440;border-radius:3px;'
            f'padding:12px 16px;margin-bottom:10px">'
            f'<div style="color:#f05454;font-size:11px;font-weight:500;margin-bottom:6px">'
            f'⚡ CASCADE RISK — {len(cascade_risks)} position{"s" if len(cascade_risks)>1 else ""} within 5% of liquidation</div>'
            f'{risk_items}'
            f'<div style="color:var(--t3);font-size:9px;margin-top:6px">'
            f'If price reaches these levels, forced buying/selling could amplify the move.</div></div>'
        )
    else:
        cascade_banner = ""

    # ── CONVICTION PANEL HTML ────────────────────────────────────
    # Market context from Binance + Fear/Greed
    fr    = market_data.get("funding_rate", None)
    fg    = market_data.get("score", None)
    fg_lb = market_data.get("label", "N/A")
    top_l = market_data.get("top_long_pct", None)
    top_s = market_data.get("top_short_pct", None)
    bpct  = market_data.get("taker_buy_pct", None)
    oi_t  = market_data.get("oi_trend", "unknown")
    oi_v  = market_data.get("oi_btc", None)

    def _na(v, fmt=".4f", suffix=""): 
        return f"{v:{fmt}}{suffix}" if v is not None else "—"

    # Conviction bar — left=bearish, center=neutral, right=bullish
    # conv_bar_pct: 0%=full bear(-5), 50%=neutral(0), 100%=full bull(+5)
    bar_w    = conv_bar_pct
    bar_left = max(0, 50 - bar_w) if conv_score < 0 else 50
    bar_col  = conv_col

    # Score label line
    bears_out_of = sum(1 for s in conv_signals if s[2] < 0)
    bulls_out_of = sum(1 for s in conv_signals if s[2] > 0)
    n_signals    = len(conv_signals)

    # Stat items
    def mstat(label, val, note="", col="var(--t)"):
        return (f'<div style="text-align:center;padding:8px 4px">'
                f'<div style="font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:3px">{label}</div>'
                f'<div style="font-size:15px;font-weight:500;color:{col}">{val}</div>'
                f'<div style="font-size:9px;color:var(--t3);margin-top:2px">{note}</div></div>')

    fr_col = "#f05454" if fr and fr > 0.0005 else "#39d353" if fr and fr < -0.0002 else "var(--t2)"
    FUND_HIGH = 0.0005
    FUND_LOW  = -0.0002
    fg_col = "#f05454" if fg and fg >= 75 else "#39d353" if fg and fg <= 25 else "#f0b429"



    # Add new stat items for expanded sources
    dvol_val  = market_data.get("dvol")
    rv14_val  = market_data.get("rv14")
    btc_dom   = market_data.get("btc_dominance")
    cb_prem   = market_data.get("cb_premium")
    bb_ls_r   = market_data.get("bybit_ls_ratio")
    fr_bb_v   = market_data.get("bybit_funding")
    fr_ok_v   = market_data.get("okx_funding")
    vs_ma_v   = market_data.get("btc_vs_200ma")

    mstats = (
        mstat("Conviction", f"{conv_score:+d}/±13", conv_label, conv_col) +
        mstat("BN Funding", _na(fr*100 if fr else None,".3f","%"),     "Binance /8h", fr_col) +
        mstat("BB Funding", _na(fr_bb_v*100 if fr_bb_v else None,".3f","%"), "Bybit /8h",
              "#f05454" if fr_bb_v and fr_bb_v>FUND_HIGH else "#39d353" if fr_bb_v and fr_bb_v<FUND_LOW else "var(--t2)") +
        mstat("OKX Funding", _na(fr_ok_v*100 if fr_ok_v else None,".3f","%"), "OKX /8h",
              "#f05454" if fr_ok_v and fr_ok_v>FUND_HIGH else "#39d353" if fr_ok_v and fr_ok_v<FUND_LOW else "var(--t2)") +
        mstat("DVOL", f"{dvol_val:.0f}" if dvol_val else "—", "BTC Impl Vol",
              "#f05454" if dvol_val and dvol_val>70 else "#39d353" if dvol_val and dvol_val<40 else "var(--t2)") +
        mstat("RV 14d", f"{rv14_val:.0f}%" if rv14_val else "—", "Realised vol", "var(--t2)") +
        mstat("vs 200MA", f"{vs_ma_v*100:+.1f}%" if vs_ma_v is not None else "—", "Bull>0 Bear<0",
              "#39d353" if vs_ma_v and vs_ma_v>0.02 else "#f05454" if vs_ma_v and vs_ma_v<-0.02 else "#f0b429") +
        mstat("BTC Dom", f"{btc_dom:.1f}%" if btc_dom else "—", "Dominance", "var(--t2)") +
        mstat("CB Premium", f"{cb_prem*100:+.3f}%" if cb_prem is not None else "—", "US demand",
              "#39d353" if cb_prem and cb_prem>0.001 else "#f05454" if cb_prem and cb_prem<-0.001 else "var(--t2)") +
        mstat("Top Traders", f"{top_l*100:.0f}% L" if top_l else "—",
              f"{top_s*100:.0f}% S" if top_s else "", "var(--t2)") +
        mstat("Bybit L/S", f"{bb_ls_r:.2f}" if bb_ls_r else "—", "ratio", "var(--t2)") +
        mstat("Taker Buy", f"{bpct*100:.0f}%" if bpct else "—", "vs sell", "var(--t2)") +
        mstat("OI Trend", oi_t.upper(), f"{oi_v/1000:.0f}K BTC" if oi_v else "", "var(--t2)") +
        mstat("Fear/Greed", str(fg) if fg else "—", fg_lb, fg_col) +
        mstat("P/C Ratio",
              f"{market_data.get('pc_ratio',0):.2f}" if market_data.get('pc_ratio') else "—",
              market_data.get('pc_sentiment','—'), "var(--t2)") +
        mstat("Mempool",
              f"{market_data.get('mempool_fastest_fee',0)} sat",
              market_data.get('mempool_level','—'),
              "#f05454" if market_data.get('mempool_level')=="high" else "var(--t2)") +
        mstat("Basis/yr",
              f"{market_data.get('basis_ann',0):+.1f}%" if market_data.get('basis_ann') is not None else "—",
              market_data.get('basis_struct','—'), "var(--t2)") +
        mstat("USDT+USDC",
              f"${market_data.get('stable_total_b',0):.0f}B",
              f"{market_data.get('stable_7d_chg_b',0):+.1f}B 7d",
              "#39d353" if market_data.get('stable_trend')=="expanding" else "#f05454" if market_data.get('stable_trend')=="contracting" else "var(--t2)") +
        mstat("Wiki Views",
              f"{market_data.get('wiki_avg7',0):,}" if market_data.get('wiki_avg7') else "—",
              market_data.get('wiki_level','—'),
              "#f05454" if market_data.get('wiki_level')=="spike" else "#39d353" if market_data.get('wiki_level')=="collapse" else "var(--t2)") +
        mstat("ETF (IBIT)",
              f"{market_data.get('etf_price_5d_chg',0):+.1f}%" if market_data.get('etf_price_5d_chg') is not None else "—",
              market_data.get('etf_flow_signal','—'),
              "#39d353" if market_data.get('etf_flow_signal')=="inflow" else "#f05454" if market_data.get('etf_flow_signal')=="outflow" else "var(--t2)") +
        mstat("Poly Fed",
              f"{market_data.get('pm_rate_cut_prob',0)*100:.0f}%" if market_data.get('pm_rate_cut_prob') else "—",
              "cut prob", "var(--t2)")
    )

    conviction_panel = f'''<div class="sec">Market conviction score — 38-source signal machine</div>
<div class="conv-wrap">
  <div style="display:grid;grid-template-columns:auto 1fr;gap:20px;align-items:start">
    <div style="min-width:130px">
      <div style="font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px">Overall signal</div>
      <div style="font-size:28px;font-weight:500;color:{conv_col};line-height:1.1">{conv_score:+d}<span style="font-size:12px;color:var(--t3)">/±44</span></div>
      <div style="font-size:11px;color:{conv_col};margin-top:2px">{conv_label}</div>
      <div class="conv-bar-bg">
        <div style="display:flex;height:100%">
          <div style="width:50%;border-right:1px solid var(--b2);position:relative">
            {"<div class=\'conv-bar\' style=\'width:" + str(abs(conv_score)*20) + "%;background:" + conv_col + ";float:right\'></div>" if conv_score < 0 else ""}
          </div>
          <div style="width:50%">
            {"<div class=\'conv-bar\' style=\'width:" + str(abs(conv_score)*20) + "%;background:" + conv_col + ";\'></div>" if conv_score > 0 else ""}
          </div>
        </div>
      </div>
      <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--t3)">
        <span>BEAR</span><span>NEUTRAL</span><span>BULL</span>
      </div>
    </div>
    <div>
      <div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-bottom:10px">
        {mstats}
      </div>
      <table class="sig-table">
        <thead><tr>
          <td style="color:var(--t3);font-size:9px;text-transform:uppercase;padding-bottom:4px">Impact</td>
          <td style="color:var(--t3);font-size:9px;text-transform:uppercase">Source</td>
          <td style="color:var(--t3);font-size:9px;text-transform:uppercase">Signal</td>
          <td class="nr" style="color:var(--t3);font-size:9px;text-transform:uppercase">Pts</td>
          <td style="color:var(--t3);font-size:9px;text-transform:uppercase">Reading</td>
        </tr></thead>
        <tbody>{conv_rows}</tbody>
      </table>
    </div>
  </div>
</div>'''

    # ── WHALE SENTIMENT INDEX CHART ─────────────────────────────
    history = history or []
    if len(history) >= 2:
        # Build index: starts at 100, goes up when BULLISH, down when BEARISH
        # Magnitude = ratio of dominant side / total exposure
        idx_vals = [100.0]
        for rec in history:
            l = rec.get("hl_long") or rec.get("long", 0)
            s = rec.get("hl_short") or rec.get("short", 0)
            total = l + s
            if total == 0:
                idx_vals.append(idx_vals[-1])
                continue
            # Net ratio: +1 = all long, -1 = all short
            net = (l - s) / total          # range -1 to +1
            # Step size proportional to imbalance (max ±3 points per scan)
            step = net * 3.0
            idx_vals.append(round(idx_vals[-1] + step, 2))

        idx_labels  = [r["ts"][:16] for r in history]
        idx_data    = idx_vals[1:]   # drop seed 100 to align with labels
        idx_colors  = ["#39d353" if v > 100 else "#f05454" if v < 100 else "#f0b429"
                        for v in idx_data]
        last_idx    = idx_data[-1] if idx_data else 100.0
        last_bias   = (next((r.get("hl_bias") or r.get("bias") for r in reversed(history) if r.get("hl_bias") or r.get("bias")), "NEUTRAL")) if history else "NEUTRAL"
        trend_col   = "#39d353" if last_idx >= 100 else "#f05454"
        trend_arrow = "↑" if last_idx > idx_data[-2] else "↓" if len(idx_data)>1 and last_idx < idx_data[-2] else "→"

        # Long/short exposure over time (secondary lines)
        long_data  = [round((r.get("hl_long") or r.get("long", 0))/1e6, 2) for r in history]
        short_data = [round((r.get("hl_short") or r.get("short", 0))/1e6, 2) for r in history]

        import json as _json
        index_chart_html = f"""
<div class="sec">Whale sentiment index — BTC long/short balance over time ({len(history)} scans)</div>
<div class="card" style="margin-bottom:10px">
  <div style="display:grid;grid-template-columns:1fr 3fr;gap:14px;align-items:start">
    <div>
      <div class="sl">Current index</div>
      <div style="font-size:32px;font-weight:500;color:{trend_col};line-height:1.1">{last_idx:.1f} {trend_arrow}</div>
      <div style="color:var(--t3);font-size:9px;margin-top:4px">Started at 100.0</div>
      <div style="margin-top:12px">
        <div class="sl">Reading</div>
        <div style="font-size:11px;color:var(--t2);line-height:1.7">
          Above 100 = whales net long<br>
          Below 100 = whales net short<br>
          Slope = conviction strength
        </div>
      </div>
      <div style="margin-top:12px">
        <div class="sl">Latest snapshot</div>
        <div style="font-size:11px;color:var(--t2);line-height:1.7">
          Long: ${(next((r for r in reversed(history) if r.get("hl_long")), history[-1])).get("hl_long", 0)/1e6:.1f}M<br>
          Short: ${(next((r for r in reversed(history) if r.get("hl_short")), history[-1])).get("hl_short", 0)/1e6:.1f}M<br>
          Ratio: {(next((r for r in reversed(history) if r.get("hl_ratio")), {"hl_ratio":"—"})).get("hl_ratio","—")}x<br>
          Bias: {last_bias}
        </div>
      </div>
    </div>
    <div>
      <div class="sl" style="margin-bottom:6px">Sentiment index (top) · Long/Short $M (bottom)</div>
      <div style="position:relative;height:140px"><canvas id="cIndex"></canvas></div>
      <div style="position:relative;height:100px;margin-top:8px"><canvas id="cExposure"></canvas></div>
    </div>
  </div>
</div>
<script>
(function(){{
const CO={{responsive:true,maintainAspectRatio:false,animation:false,
  plugins:{{legend:{{display:false}},tooltip:{{backgroundColor:'#1e1e1e',borderColor:'#2a2a2a',borderWidth:1,titleColor:'#d4d0c8',bodyColor:'#7a7870',padding:6}}}},
  scales:{{x:{{display:false}},y:{{grid:{{color:'#141414'}},ticks:{{color:'#3d3c38',font:{{family:'IBM Plex Mono',size:9}}}}}}}}}};

const labels={_json.dumps(idx_labels)};
const idxData={_json.dumps(idx_data)};
const baseline=new Array(labels.length).fill(100);

new Chart(document.getElementById('cIndex').getContext('2d'),{{
  type:'line',
  data:{{labels,datasets:[
    {{data:baseline,borderColor:'#2a2a2a',borderWidth:1,borderDash:[4,4],pointRadius:0,fill:false,tension:0}},
    {{data:idxData,borderColor:'{trend_col}',borderWidth:2,pointRadius:0,fill:{{target:'origin',above:'{trend_col}18',below:'{trend_col}18'}},tension:0.3}}
  ]}},
  options:{{...CO,plugins:{{...CO.plugins,tooltip:{{...CO.plugins.tooltip,callbacks:{{label:c=>`Index: ${{c.raw.toFixed(1)}}`}}}}}}}}
}});

new Chart(document.getElementById('cExposure').getContext('2d'),{{
  type:'line',
  data:{{labels,datasets:[
    {{data:{_json.dumps(long_data)},borderColor:'#39d353',borderWidth:1.5,pointRadius:0,fill:false,tension:0.3,label:'Long $M'}},
    {{data:{_json.dumps(short_data)},borderColor:'#f05454',borderWidth:1.5,pointRadius:0,fill:false,tension:0.3,label:'Short $M'}}
  ]}},
  options:{{...CO,plugins:{{...CO.plugins,legend:{{display:true,labels:{{color:'#7a7870',font:{{family:'IBM Plex Mono',size:9}}}}}}}},scales:{{x:{{display:false}},y:{{grid:{{color:'#141414'}},ticks:{{color:'#3d3c38',font:{{family:'IBM Plex Mono',size:9}},callback:v=>v+'M'}},min:0}}}}}}
}});
}})();
</script>"""
    else:
        # Not enough history yet
        scans_needed = 2 - len(history)
        index_chart_html = f"""
<div class="sec">Whale sentiment index — building history</div>
<div class="card" style="margin-bottom:10px">
  <div style="color:var(--t3);font-size:10px;text-align:center;padding:20px">
    Collecting scan history... {scans_needed} more scan{"s" if scans_needed!=1 else ""} needed to display the index chart.<br>
    Data is saved to <strong>hl_whale_history.jsonl</strong> between runs.
  </div>
</div>"""

    return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta http-equiv="refresh" content="300">
<title>Hyperliquid Whale Tracker — {ts_str}</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&display=swap');
*{{box-sizing:border-box;margin:0;padding:0}}
:root{{--bg:#060606;--bg2:#0e0e0e;--bg3:#161616;--bg4:#1e1e1e;--b:#1f1f1f;--b2:#2a2a2a;--t:#d4d0c8;--t2:#7a7870;--t3:#3d3c38;--G:#39d353;--A:#f0b429;--R:#f05454;--B:#4da3ff;--T:#2fd0b0}}
html{{background:var(--bg)}}
body{{background:var(--bg);color:var(--t);font-family:'IBM Plex Mono',monospace;font-size:12px;line-height:1.6;padding:20px 24px;max-width:1400px;margin:0 auto}}
.hdr{{display:flex;align-items:baseline;justify-content:space-between;border-bottom:1px solid var(--b2);padding-bottom:12px;margin-bottom:14px}}
.hdr h1{{font-size:13px;font-weight:500;color:var(--G);letter-spacing:.05em}}
.hr{{color:var(--t2);font-size:10px;text-align:right}}
.stats{{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:14px}}
.stat{{background:var(--bg2);border:1px solid var(--b);border-radius:3px;padding:10px 12px}}
.sl{{color:var(--t3);font-size:9px;text-transform:uppercase;letter-spacing:.09em;margin-bottom:3px}}
.sv{{font-size:18px;font-weight:500;line-height:1.1}}
.ss{{color:var(--t3);font-size:9px;margin-top:2px}}
.bias{{background:var(--bg2);border:1px solid {bias_c}40;border-radius:3px;padding:12px 16px;margin-bottom:14px;display:flex;gap:24px;align-items:center}}
.bias-main{{font-size:18px;font-weight:500;color:{bias_c}}}
.bias-detail{{color:var(--t2);font-size:10px}}
.sec{{font-size:9px;text-transform:uppercase;letter-spacing:.13em;color:var(--t3);padding:5px 0 9px;display:flex;align-items:center;gap:8px}}
.sec::after{{content:'';flex:1;height:1px;background:var(--b)}}
.card{{background:var(--bg2);border:1px solid var(--b);border-radius:3px;padding:14px;margin-bottom:10px;overflow-x:auto}}
table{{width:100%;border-collapse:collapse;font-size:11px}}
th{{text-align:left;padding:4px 6px;color:var(--t2);font-weight:400;font-size:9px;text-transform:uppercase;letter-spacing:.07em;border-bottom:1px solid var(--b2)}}
td{{padding:4px 6px;border-bottom:1px solid var(--b);white-space:nowrap}}
tr:hover td{{background:var(--bg4)}}.nr{{text-align:right}}
.ins{{border-left:2px solid var(--T);padding:8px 12px;background:#0a2e2840;border-radius:0 3px 3px 0;font-size:10px;line-height:1.6;margin-top:8px}}
.ins.w{{border-color:var(--A);background:#3d2e0a40}}
::-webkit-scrollbar{{width:3px;height:3px}}::-webkit-scrollbar-thumb{{background:var(--b2)}}
.conv-wrap{{background:var(--bg2);border:1px solid var(--b);border-radius:3px;padding:14px;margin-bottom:10px}}
.conv-bar-bg{{height:8px;background:var(--b2);border-radius:4px;overflow:hidden;margin:8px 0 4px}}
.conv-bar{{height:100%;border-radius:4px}}
.sig-table{{width:100%;border-collapse:collapse;font-size:11px}}
.sig-table td{{padding:3px 8px;border-bottom:1px solid var(--b)}}
.sig-table tr:last-child td{{border-bottom:none}}
</style></head><body>

<div class="hdr">
  <h1>◈ HYPERLIQUID WHALE TRACKER</h1>
  <div class="hr">Generated {ts_str}<br>Scan #{scan_num} · {len(wallets)} wallets tracked · auto-refresh 5min</div>
</div>

<div class="stats">
  <div class="stat"><div class="sl">New alerts</div><div class="sv" style="color:{'var(--G)' if n_alerts else 'var(--t3)'}">{n_alerts}</div><div class="ss">This scan</div></div>
  <div class="stat"><div class="sl">New positions</div><div class="sv" style="color:var(--G)">{len(new_pos)}</div><div class="ss">Opened since last scan</div></div>
  <div class="stat"><div class="sl">Size increases</div><div class="sv" style="color:var(--A)">{len(size_inc)}</div><div class="ss">Added to position</div></div>
  <div class="stat"><div class="sl">BTC alerts</div><div class="sv" style="color:var(--A)">{len(btc_alerts)}</div><div class="ss">Directly tradeable</div></div>
  <div class="stat"><div class="sl">Large positions</div><div class="sv" style="color:var(--B)">{len(big_positions)}</div><div class="ss">≥${MIN_POSITION_USD//1000}K open</div></div>
  <div class="stat"><div class="sl">Wallets tracked</div><div class="sv">{len(wallets)}</div><div class="ss">Top HL traders</div></div>
</div>

<div class="bias">
  <div>
    <div class="sl">BTC Whale Bias</div>
    <div class="bias-main">{btc_bias}</div>
  </div>
  <div class="bias-detail">
    Long exposure: {fmt_usd(btc_long_usd)} ({btc_side_counts.get('LONG',0)} positions)<br>
    Short exposure: {fmt_usd(btc_short_usd)} ({btc_side_counts.get('SHORT',0)} positions)<br>
    Total BTC exposure: {fmt_usd(btc_total)}
  </div>
  <div class="bias-detail" style="margin-left:auto;color:var(--t3)">
    Bias = BULLISH when longs &gt;1.3× shorts<br>
    Mirror on Binance BTCUSDT.P in the same direction
  </div>
</div>

{conviction_panel}
{index_chart_html}
{cascade_banner}
<div class="sec">Cross-asset whale heat map — top {len(top_coins)} coins by exposure</div>
<div class="card" style="margin-bottom:10px">
  <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:7px;margin-bottom:10px">
    {heatmap_cells}
  </div>
  <div style="display:flex;gap:16px;font-size:9px;color:var(--t3);border-top:1px solid var(--b);padding-top:8px;margin-top:4px">
    <span>↑ BULLISH: {n_bull} coins</span>
    <span>↓ BEARISH: {n_bear} coins</span>
    <span>→ NEUTRAL: {n_neut} coins</span>
    <span style="margin-left:auto">Bar = long (green) / short (red) split · Sorted by total exposure</span>
  </div>
</div>
<div class="sec">New alerts this scan — act on these</div>
<div class="card">
  <table>{thead}<tbody>{alert_rows}</tbody></table>
  <div class="ins w" style="margin-top:8px">
    <strong>How to use:</strong> NEW POSITION = whale just opened. SIZE INCREASE = adding conviction.
    Score ≥7 = high quality signal. BTC/ETH alerts are directly executable on Binance BTCUSDT.P — mirror the direction.
    Always verify the whale's historical PnL before following. Don't follow wallets with &lt;30-day PnL history.
  </div>
</div>

<div class="sec">All large open positions (≥{fmt_usd(MIN_POSITION_USD)}) — whale book</div>
<div class="card" style="margin-bottom:20px">
  <table>{thead}<tbody>{big_rows}</tbody></table>
  <div class="ins" style="margin-top:8px">
    <strong>Whale book:</strong> All current large positions held by top traders. Sorted by size.
    The BTC whale bias above is computed from this table. Positions near their liquidation price are fragile —
    a cascade could move the market if liquidated. Monitor liq prices during volatile periods.
  </div>
</div>

<div style="text-align:center;color:var(--t3);font-size:9px;padding-top:10px;border-top:1px solid var(--b)">
  Hyperliquid Whale Tracker · {ts_str} · Data: api.hyperliquid.xyz (public, no auth required)
  · Positions fully on-chain · Min wallet PnL: {fmt_usd(MIN_ALL_TIME_PNL)} · Min position: {fmt_usd(MIN_POSITION_USD)}
</div>
</body></html>"""

# ── WATCH MODE ─────────────────────────────────────────────
def watch_mode(interval, wallets):
    state = load_state()
    scan_num = 0
    print(f"  Watch mode: rescanning every {interval//60}min. Ctrl+C to stop.\n", flush=True)

    while True:
        try:
            scan_num += 1
            alerts, big_positions, state = run_scan(state, wallets, verbose=True)
            save_state(state)
            ts_str = now_str()
            # Compute BTC bias for history
            _btc = [p for p in big_positions if p.get("coin","").upper() in ("BTC","UBTC")]
            _bl = sum(p["size_usd"] for p in _btc if p["side"]=="LONG")
            _bs = sum(p["size_usd"] for p in _btc if p["side"]=="SHORT")
            _bb = "BULLISH" if _bl > _bs*1.3 else "BEARISH" if _bs > _bl*1.3 else "NEUTRAL"
            history = load_history()
            market_data = fetch_all_market_data()
            _conv_score, _conv_signals = score_signal(market_data, _bb)
            save_history(ts_str, _bl, _bs, _bb,
                         market_data=market_data,
                         conv_score=_conv_score,
                         conv_signals=_conv_signals,
                         big_positions=big_positions)
            html = build_html(alerts, big_positions, wallets, ts_str, scan_num, history, market_data)
            with open("hl_whale_report.html","w",encoding="utf-8") as f: f.write(html)
            print(f"  Report updated | {len(alerts)} alerts | {len(big_positions)} large positions\n", flush=True)

            # ── TELEGRAM ALERTS ─────────────────────────────────
            global _tg_last_bias, _tg_last_score, _tg_bias_ts, _tg_scan_n, _tg_alerted_liq, _tg_prev_bl, _tg_prev_bs, _tg_prev_ratio, _tg_agg_alert_ts
            _tg_scan_n += 1

            # Recompute values needed for alerts
            _btc_price = market_data.get("btc_rt_price") or market_data.get("mark_price") or 0
            _conv_score2, _conv_sigs2 = score_signal(market_data, _bb)

            # A. BTC whale bias flip
            if _bb != _tg_last_bias and _tg_last_bias is not None:
                if time.time() - _tg_bias_ts > TG_BIAS_COOLDOWN:
                    tg_bias_flip(_tg_last_bias, _bb, _bl, _bs, _conv_score2, _btc_price)
                    _tg_bias_ts = time.time()
            _tg_last_bias = _bb

            # B. Conviction score crossing threshold
            def _score_bucket(s):
                if s >= 8: return "extreme_bull"
                if s >= 5: return "bull"
                if s <= -8: return "extreme_bear"
                if s <= -5: return "bear"
                return "neutral"

            _new_bucket = _score_bucket(_conv_score2)
            _old_bucket = _score_bucket(_tg_last_score) if _tg_last_score is not None else "neutral"
            if _new_bucket != _old_bucket and _new_bucket != "neutral":
                tg_conviction_alert(_conv_score2, _tg_last_score or 0,
                                    _conv_sigs2, _bb, _btc_price)
            _tg_last_score = _conv_score2

            # C. Emergency signals — stablecoin depeg
            _stable_status = market_data.get("stable_status", "normal")
            if _stable_status in ("stress", "crisis"):
                _stable_dev = market_data.get("stable_depeg", 0)
                _impact = -2 if _stable_status == "crisis" else -1
                tg_emergency("Stablecoin Depeg",
                             f"USDT/USDC {_stable_dev:.3f}% off $1.00 — status: {_stable_status.upper()}",
                             _impact)

            # D. Cascade liquidation proximity (<3% from current price)
            _liq_dn = None
            _liq_up = None
            _btc_pos = [p for p in big_positions if p.get("coin","").upper() in ("BTC","UBTC")]
            if _btc_price:
                _long_liqs = [p.get("liq_px",0) for p in _btc_pos
                              if p.get("side")=="LONG" and p.get("liq_px")
                              and p.get("liq_px") < _btc_price and p.get("size_usd",0) > 1_000_000]
                _short_liqs = [p.get("liq_px",0) for p in _btc_pos
                               if p.get("side")=="SHORT" and p.get("liq_px")
                               and p.get("liq_px") > _btc_price and p.get("size_usd",0) > 1_000_000]
                # Helper: only alert a liq level if it's new OR got 1%+ closer
                def _should_alert_liq(liq_px, dist_pct):
                    # Round liq to nearest $500 to treat nearby levels as same position
                    key = round(liq_px / 500) * 500
                    prev_dist = _tg_alerted_liq.get(key)
                    if prev_dist is None:
                        # New level — alert
                        _tg_alerted_liq[key] = dist_pct
                        return True
                    if abs(dist_pct) < abs(prev_dist) - 1.0:
                        # Got materially closer (1%+ tighter) — alert again
                        _tg_alerted_liq[key] = dist_pct
                        return True
                    return False  # same position, not materially closer — suppress

                # Prune stale levels (liq > 5% away no longer relevant)
                _tg_alerted_liq = {k: v for k, v in _tg_alerted_liq.items()
                                   if abs(v) <= 5.0}

                if _long_liqs:
                    _nearest_long_liq = max(_long_liqs)
                    _liq_dn = (_btc_price - _nearest_long_liq) / _btc_price * 100
                    if _liq_dn < 3.0 and _should_alert_liq(_nearest_long_liq, _liq_dn):
                        tg_emergency("Cascade Liquidation Risk — LONGS",
                                     f"Large BTC long liq at ${_nearest_long_liq:,.0f} "
                                     f"— only {_liq_dn:.1f}% below current ${_btc_price:,.0f}",
                                     -1)
                if _short_liqs:
                    _nearest_short_liq = min(_short_liqs)
                    _liq_up = (_nearest_short_liq - _btc_price) / _btc_price * 100
                    if _liq_up < 3.0 and _should_alert_liq(_nearest_short_liq, _liq_up):
                        tg_emergency("Cascade Liquidation Risk — SHORTS",
                                     f"Large BTC short liq at ${_nearest_short_liq:,.0f} "
                                     f"— only {_liq_up:.1f}% above current ${_btc_price:,.0f}",
                                     -1)

            # D2. Aggregate BTC exposure change
            now_t_agg = time.time()
            if now_t_agg - _tg_agg_alert_ts > TG_RATIO_COOLDOWN:
                if _tg_prev_bl is not None and _tg_prev_bs is not None:
                    ratio_now  = _bs / _bl if _bl > 0 else 0
                    # Check for significant moves
                    long_change  = abs(_bl - _tg_prev_bl)
                    short_change = abs(_bs - _tg_prev_bs)
                    ratio_change = abs(ratio_now - (_tg_prev_ratio or 0))

                    if long_change >= TG_LONG_CHANGE_USD:
                        tg_aggregate_change("LONGS", _bl, _bs, _tg_prev_bl, _tg_prev_bs,
                                            _btc_price, "up" if _bl > _tg_prev_bl else "down")
                        _tg_agg_alert_ts = now_t_agg
                    elif short_change >= TG_SHORT_CHANGE_USD:
                        tg_aggregate_change("SHORTS", _bl, _bs, _tg_prev_bl, _tg_prev_bs,
                                            _btc_price, "up" if _bs > _tg_prev_bs else "down")
                        _tg_agg_alert_ts = now_t_agg
                    elif ratio_change >= TG_RATIO_CHANGE:
                        tg_aggregate_change("RATIO", _bl, _bs, _tg_prev_bl, _tg_prev_bs,
                                            _btc_price, "")
                        _tg_agg_alert_ts = now_t_agg

            # Update previous values for next scan comparison
            _tg_prev_bl    = _bl
            _tg_prev_bs    = _bs
            _tg_prev_ratio = _bs / _bl if _bl > 0 else 0

            # E. Regime reversal long signal
            _hl_ratio = _bs / _bl if _bl > 0 else 0
            _fall_n   = market_data.get("ratio_falling_n", 0)
            if _hl_ratio < 1.35 and _fall_n >= 3 and _conv_score2 > -8:
                tg_regime_reversal(_hl_ratio, _conv_score2, _btc_price, _fall_n)

            # F. Periodic scan summary
            if _tg_scan_n % TG_SUMMARY_EVERY_N == 0:
                tg_scan_summary(_tg_scan_n, len(alerts), _bb,
                                _conv_score2, _btc_price, len(big_positions))

            time.sleep(interval)
        except KeyboardInterrupt:
            print("\n  Stopped."); break
        except Exception as e:
            print(f"  Error: {e}"); time.sleep(30)

# ── MAIN ────────────────────────────────────────────────────
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Hyperliquid Whale Tracker")
    parser.add_argument("--watch", action="store_true", help="Continuous scan mode")
    parser.add_argument("--interval", type=int, default=300, help="Scan interval seconds")
    parser.add_argument("--min-pnl", type=int, default=MIN_ALL_TIME_PNL,
                        help=f"Min all-time PnL to track (default {MIN_ALL_TIME_PNL})")
    parser.add_argument("--min-pos", type=int, default=MIN_POSITION_USD,
                        help=f"Min position size USD (default {MIN_POSITION_USD})")
    args = parser.parse_args()

    MIN_ALL_TIME_PNL = args.min_pnl
    MIN_POSITION_USD = args.min_pos

    print("="*60)
    print("  HYPERLIQUID WHALE TRACKER")
    print(f"  Min wallet PnL: {fmt_usd(MIN_ALL_TIME_PNL)}")
    print(f"  Min position:   {fmt_usd(MIN_POSITION_USD)}")
    print(f"  Top wallets:    {TOP_N_WALLETS}")
    print(f"  Mode: {'WATCH' if args.watch else 'ONE-TIME'}")
    print("="*60+"\n")

    try: import requests
    except ImportError: print("pip install requests"); sys.exit(1)

    # Step 1: Build wallet list
    print("[10%] Fetching leaderboard...")
    wallets = fetch_leaderboard()
    if not wallets:
        print("ERROR: Could not fetch leaderboard. Check network.")
        sys.exit(1)

    print(f"\n      Top wallets by all-time PnL:")
    for w in wallets[:5]:
        print(f"      {w['address'][:14]}... | PnL: {fmt_usd(w['pnl_all'])} | "
              f"WR: {w['win_rate']*100:.0f}%" if w.get('win_rate') else
              f"      {w['address'][:14]}... | rank #{w['rank']}")

    if args.watch:
        watch_mode(args.interval, wallets)
    else:
        # One-time scan
        state = load_state()
        print(f"\n[20%] Scanning {len(wallets)} wallets for positions...")
        alerts, big_positions, state = run_scan(state, wallets, verbose=True)
        save_state(state)

        ts_str = now_str()
        _btc = [p for p in big_positions if p.get("coin","").upper() in ("BTC","UBTC")]
        _bl = sum(p["size_usd"] for p in _btc if p["side"]=="LONG")
        _bs = sum(p["size_usd"] for p in _btc if p["side"]=="SHORT")
        _bb = "BULLISH" if _bl > _bs*1.3 else "BEARISH" if _bs > _bl*1.3 else "NEUTRAL"
        history = load_history()
        market_data = fetch_all_market_data()
        _conv_score, _conv_signals = score_signal(market_data, _bb)
        save_history(ts_str, _bl, _bs, _bb,
                     market_data=market_data,
                     conv_score=_conv_score,
                     conv_signals=_conv_signals,
                     big_positions=big_positions)
        html = build_html(alerts, big_positions, wallets, ts_str, history=history, market_data=market_data)
        with open("hl_whale_report.html","w",encoding="utf-8") as f: f.write(html)

        print(f"\n[100%] Done!\n{'='*60}")
        print(f"  ALERTS:          {len(alerts)}")
        print(f"  NEW POSITIONS:   {len([a for a in alerts if a.atype=='NEW_POSITION'])}")
        print(f"  SIZE INCREASES:  {len([a for a in alerts if a.atype=='SIZE_INCREASE'])}")
        print(f"  LARGE POSITIONS: {len(big_positions)}")
        btc = [p for p in big_positions if p.get("coin","").upper() in ("BTC","UBTC")]
        if btc:
            longs  = sum(p["size_usd"] for p in btc if p["side"]=="LONG")
            shorts = sum(p["size_usd"] for p in btc if p["side"]=="SHORT")
            print(f"  BTC LONGS:       {fmt_usd(longs)}")
            print(f"  BTC SHORTS:      {fmt_usd(shorts)}")
            bias = "BULLISH" if longs > shorts*1.3 else "BEARISH" if shorts > longs*1.3 else "NEUTRAL"
            print(f"  BTC WHALE BIAS:  {bias}")
        print("="*60)
        print(f"\n  Open hl_whale_report.html in browser.")
        print(f"  Run with --watch for continuous monitoring.\n")
        print(f"  Tip: Second run will show NEW positions since this snapshot.")
        print(f"       python hyperliquid_whale.py --watch --interval 120\n")
