"""
PAPER TRADING BOT - BTC 15-Min Kalshi Markets
$100 starting balance. Smart tiered bet sizing. 2:1 reward/risk.
Trailing high-water mark stop-loss — protects profits, not just starting balance.
Live dashboard: open dashboard.html via OPEN DASHBOARD shortcut
"""
import os, base64, datetime, time, json, threading, requests, re
from dotenv import load_dotenv
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend

try:
    import websocket
    WS_AVAILABLE = True
except ImportError:
    WS_AVAILABLE = False

load_dotenv()
KEY_ID   = os.getenv("KALSHI_API_KEY_ID")
KEY_PATH = os.getenv("KALSHI_PRIVATE_KEY_PATH")
BASE_URL = "https://api.elections.kalshi.com"

# ── Data Paths ──────────────────────────────────────────────────────────────
BOT_DIR          = "C:\\kalshibot"
DATA_DIR         = "C:\\kalshibot\\data"
ARCHIVE_DIR      = "C:\\kalshibot\\data\\archive"
ANALYTICS_PATH   = "C:\\kalshibot\\trade_analytics.json"
SNAPSHOTS_PATH   = "C:\\kalshibot\\market_snapshots.json"
SESSION_LOG_PATH = "C:\\kalshibot\\data\\session_history.json"
PAPER_LOG_PATH   = "C:\\kalshibot\\paper_log.json"
AUTO_PARAMS_PATH = "C:\\kalshibot\\auto_params.json"
ARCHIVE_ANALYTICS_AT  = 5000   # archive trade_analytics.json at this many records
ARCHIVE_SNAPSHOTS_AT  = 10000  # archive market_snapshots.json at this many records

STARTING_BALANCE      = 250.00
PROFIT_TARGET         = 0.40
STOP_LOSS             = 0.25
MAX_ENTRY_PRICE       = 0.82
MIN_ENTRY_PRICE       = 0.60  # raised 0.35→0.60: data shows entry<0.60 = 35-46% WR; entry≥0.60 = 65-70% WR
MOMENTUM_SECONDS      = 120
MOMENTUM_THRESHOLD    = 0.0010
MAX_POSITIONS         = 5  # allow 5 simultaneous positions across markets
SCAN_INTERVAL         = 5
MIN_SECS_LEFT         = 30     # enter with 30s+ left
WARMUP_TICKS          = 4
TRAILING_STOP_PCT     = 0.20
ENTRY_COOLDOWN        = 30    # 30s between entries

# ── Confidence Score Thresholds ───────────────────────────────────────────────
SCORE_HIGH             = 28    # score >= 28 → HIGH confidence
SCORE_MEDIUM           = 15    # score >= 15 → MEDIUM (raised from 7 — weak signals were losing)
MIN_MOM_SCORE          = 8     # minimum momentum score to trade: MOM<8=37-46% WR; MOM≥8=57-70% WR

# ── Bet Sizing by Confidence Tier ─────────────────────────────────────────────
BET_HIGH_MIN           = 1.50
BET_HIGH_MAX           = 3.00
BET_MED_MIN            = 0.50  # lowered floor so bot can always place
BET_MED_MAX            = 1.25

# ── Expiration Value Signal Parameters ────────────────────────────────────────
BTC_VOL_PER_MIN        = 0.00040
MIN_MISPRICING         = 0.015  # 1.5% mispricing needed to score any points

# ── Lag Detection Parameters ──────────────────────────────────────────────────
LAG_WINDOW_SECS        = 45
LAG_MIN_BTC_MOVE       = 0.0006  # lowered from 0.0008 — easier to detect lag

prices          = {"binance": None, "coinbase": None, "kraken": None, "okx": None, "bybit": None}
price_times     = {"binance": None, "coinbase": None, "kraken": None, "okx": None, "bybit": None}
price_history   = []
price_lock      = threading.Lock()
paper_positions  = []
paper_log        = []
session_start    = time.time()
balance          = STARTING_BALANCE
high_water_mark  = STARTING_BALANCE
session_halted   = False
traded_tickers   = set()
position_lock    = threading.Lock()
FAST_MONITOR_INTERVAL = 1     # check open positions every 1 second
KALSHI_POLL_INTERVAL  = 3     # dedicated Kalshi market polling thread
HOT_PATH_BTC_MOVE     = 0.0012  # trigger instant score check if BTC moves 0.12%+
kalshi_price_history = []
kalshi_price_lock    = threading.Lock()
market_cache         = None   # single market (legacy)
market_cache_lock    = threading.Lock()
all_markets_cache    = []     # ALL open KXBTC15M markets, refreshed every 3s
hot_path_event       = threading.Event()  # set by Binance WS on big BTC move
_last_hot_btc        = None   # BTC price at last hot path check
good_hours           = []   # populated by auto_analyze and auto_params loader
avoid_hours          = []   # populated by auto_analyze and auto_params loader

_private_key_cache = None  # loaded once, reused forever

def load_private_key(path):
    with open(path, "rb") as f:
        return serialization.load_pem_private_key(f.read(), password=None, backend=default_backend())

def get_private_key():
    global _private_key_cache
    if _private_key_cache is None:
        _private_key_cache = load_private_key(KEY_PATH)
    return _private_key_cache

def sign_request(pk, ts, method, path):
    msg = (ts + method + path).encode("utf-8")
    sig = pk.sign(msg, padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
        salt_length=padding.PSS.DIGEST_LENGTH), hashes.SHA256())
    return base64.b64encode(sig).decode("utf-8")

def get_headers(method, path):
    ts = str(int(datetime.datetime.now().timestamp() * 1000))
    pk = get_private_key()  # cached — no disk read after first call
    return {"KALSHI-ACCESS-KEY": KEY_ID, "KALSHI-ACCESS-TIMESTAMP": ts,
            "KALSHI-ACCESS-SIGNATURE": sign_request(pk, ts, method, path),
            "Content-Type": "application/json"}

def update_high_water_mark():
    global high_water_mark
    if balance > high_water_mark:
        print(f"  ** NEW HIGH: ${balance:.2f}  Stop now at ${balance*(1-TRAILING_STOP_PCT):.2f} **")
        high_water_mark = balance

def check_session_stop_loss():
    return False  # disabled for paper trading — re-enable for live bot

def binance_ws():
    if not WS_AVAILABLE: return
    # Try Binance.US first (works for US users), fall back to Binance.com
    WS_URLS = [
        "wss://stream.binance.us:9443/ws/btcusdt@trade",
    ]
    def on_msg(ws, msg):
        global _last_hot_btc
        try:
            data  = json.loads(msg)
            price = float(data.get("p") or data.get("c") or 0)
            if price > 0:
                now = time.time()
                with price_lock:
                    prices["binance"]      = price
                    price_times["binance"] = now
                    price_history.append((now, price))
                if _last_hot_btc and abs((price - _last_hot_btc) / _last_hot_btc) >= HOT_PATH_BTC_MOVE:
                    hot_path_event.set()
                    _last_hot_btc = price
                elif not _last_hot_btc:
                    _last_hot_btc = price
        except: pass
    def on_error(ws, err): pass
    def on_close(ws, *args):
        with price_lock:
            prices["binance"]      = None
            price_times["binance"] = None
        # Outer while loop handles reconnect — no recursive call needed
    def on_open(ws):
        print("  ✅ Binance WebSocket connected")
    while True:
        for ws_url in WS_URLS:
            try:
                ws = websocket.WebSocketApp(
                    ws_url,
                    on_message=on_msg, on_error=on_error,
                    on_close=on_close, on_open=on_open)
                ws.run_forever(ping_interval=20, ping_timeout=8)
            except Exception as e:
                print(f"  ⚠  Binance WS {ws_url.split('/')[2]} error: {e}")
                time.sleep(3)
                continue
        time.sleep(5)  # both failed, wait before retry cycle

def poll_binance_rest():
    # Try Binance.US first, fall back to Binance.com
    REST_URLS = [
        "https://api.binance.us/api/v3/ticker/price?symbol=BTCUSDT",
    ]
    while True:
        for url in REST_URLS:
            try:
                r = requests.get(url, timeout=5)
                if r.status_code == 200:
                    price = float(r.json()["price"])
                    now   = time.time()
                    with price_lock:
                        prices["binance"] = price; price_times["binance"] = now
                        price_history.append((now, price))
                    break  # success — don't try the other URL
            except: continue
        time.sleep(8)

def poll_coinbase():
    while True:
        try:
            r = requests.get("https://api.coinbase.com/v2/prices/BTC-USD/spot", timeout=5)
            if r.status_code == 200:
                price = float(r.json()["data"]["amount"])
                now   = time.time()
                with price_lock:
                    prices["coinbase"] = price; price_times["coinbase"] = now
                    price_history.append((now, price))
        except: pass
        time.sleep(5)

def poll_kraken():
    while True:
        try:
            r = requests.get("https://api.kraken.com/0/public/Ticker?pair=XBTUSD", timeout=5)
            if r.status_code == 200:
                result = r.json().get("result", {})
                price  = float(result.get("XXBTZUSD", {}).get("c", [0])[0])
                if price > 0:
                    now = time.time()
                    with price_lock:
                        prices["kraken"] = price; price_times["kraken"] = now
                        price_history.append((now, price))
        except: pass
        time.sleep(8)

def get_best_price():
    with price_lock:
        snap_p = dict(prices); snap_t = dict(price_times)
    now   = time.time()
    valid = [(src, snap_p[src]) for src in snap_p if snap_p[src] and snap_t[src] and (now - snap_t[src]) < 30]
    if not valid: return None, None, None
    # Use only fresh prices — prefer Binance, then others in order
    price = next((snap_p[s] for s in ("binance","coinbase","kraken","okx","bybit")
                  if snap_p.get(s) and snap_t.get(s) and (now - snap_t[s]) < 30), None)
    if not price: return None, None, None
    srcs  = "+".join(s for s, _ in valid)
    edge  = None
    b, c, k = snap_p["binance"], snap_p["coinbase"], snap_p["kraken"]
    if b and c and k:
        avg = (c + k) / 2; div = (b - avg) / avg
        if abs(div) > 0.0005: edge = f"EDGE {div*100:+.3f}% Binance vs others"
    return price, srcs, edge

def get_momentum():
    now = time.time(); cutoff = now - MOMENTUM_SECONDS
    with price_lock:
        recent = [(t, p) for t, p in price_history if t >= cutoff]
        price_history[:] = [(t, p) for t, p in price_history if t >= now - 600]
    if len(recent) < WARMUP_TICKS: return None, None, None
    oldest = recent[0][1]; newest = recent[-1][1]
    mom = (newest - oldest) / oldest
    pct = f"{mom*100:+.3f}%"
    if   mom >=  MOMENTUM_THRESHOLD: return mom, "UP",   pct
    elif mom <= -MOMENTUM_THRESHOLD: return mom, "DOWN", pct
    return mom, "FLAT", pct

def poll_okx():
    """OKX spot BTC price — free, no auth, reliable alternative to Binance."""
    while True:
        try:
            r = requests.get("https://www.okx.com/api/v5/market/ticker?instId=BTC-USDT", timeout=5)
            if r.status_code == 200:
                data = r.json().get("data", [])
                if data:
                    price = float(data[0].get("last", 0))
                    if price > 0:
                        now = time.time()
                        with price_lock:
                            prices["okx"] = price; price_times["okx"] = now
                            price_history.append((now, price))
                            if _last_hot_btc and abs((price - _last_hot_btc) / _last_hot_btc) >= HOT_PATH_BTC_MOVE:
                                hot_path_event.set()
                                _last_hot_btc = price  # update so next poll doesn't re-fire
        except: pass
        time.sleep(4)

def poll_bybit():
    """Bybit spot BTC price — free, no auth, good redundancy."""
    while True:
        try:
            r = requests.get("https://api.bybit.com/v5/market/tickers?category=spot&symbol=BTCUSDT", timeout=5)
            if r.status_code == 200:
                lst = r.json().get("result", {}).get("list", [])
                if lst:
                    price = float(lst[0].get("lastPrice", 0))
                    if price > 0:
                        now = time.time()
                        with price_lock:
                            prices["bybit"] = price; price_times["bybit"] = now
                            price_history.append((now, price))
        except: pass
        time.sleep(4)

def poll_kalshi_market():
    """
    Background thread — refreshes ALL open KXBTC15M markets every 3 seconds.
    Main loop scores each market independently for more trading opportunities.
    """
    global market_cache, all_markets_cache
    while True:
        try:
            markets = get_all_15m_markets()
            with market_cache_lock:
                all_markets_cache = markets
                market_cache = markets[0] if markets else None
            for m in markets:
                record_kalshi_price(m)
        except: pass
        time.sleep(KALSHI_POLL_INTERVAL)

def record_kalshi_price(market):
    """Store Kalshi contract prices for lag detection."""
    if not market: return
    with kalshi_price_lock:
        kalshi_price_history.append((time.time(), market["yes_ask"], market["no_ask"]))
        # Keep 10 minutes of history
        cutoff = time.time() - 600
        kalshi_price_history[:] = [(t,y,n) for t,y,n in kalshi_price_history if t >= cutoff]

def score_expiration_value(market, btc_price):
    """
    SIGNAL 1 — EXPIRATION VALUE (0-50 pts)
    Is BTC clearly above/below floor with time running out?
    Is Kalshi underpricing the actual probability?
    """
    if not market or not btc_price: return 0, None
    floor      = market["floor"]
    secs_left  = market["secs_left"]
    yes_ask    = market["yes_ask"]
    no_ask     = market["no_ask"]
    if secs_left <= 0 or floor <= 0: return 0, None
    mins_left  = secs_left / 60.0
    if mins_left > 5.0: return 0, None  # EXP unreliable at long horizons — only use in final 5 min
    # Use real-time measured BTC volatility, fall back to baseline if not enough data
    live_vol = get_btc_volatility()  # returns % per current window
    vol_per_min = (live_vol / 100.0) if live_vol > 0 else BTC_VOL_PER_MIN
    expected_range_pct = vol_per_min * (mins_left ** 0.5)
    distance_pct = (btc_price - floor) / btc_price
    # Confidence: how many standard deviations is BTC from floor?
    if expected_range_pct > 0:
        z_score = abs(distance_pct) / expected_range_pct
    else:
        z_score = 0
    # Convert z-score to rough probability (simplified normal CDF)
    # z=1 → 84%, z=2 → 97.5%, z=3 → 99.9%
    if z_score >= 3.0:   expected_prob = 0.97
    elif z_score >= 2.0: expected_prob = 0.94
    elif z_score >= 1.5: expected_prob = 0.88
    elif z_score >= 1.0: expected_prob = 0.80
    elif z_score >= 0.5: expected_prob = 0.65
    else:                expected_prob = 0.52
    direction = None
    mispricing = 0.0
    if distance_pct > 0:  # BTC above floor → YES should win
        mispricing = expected_prob - yes_ask
        direction  = "UP"
    elif distance_pct < 0:  # BTC below floor → NO should win
        mispricing = expected_prob - no_ask
        direction  = "DOWN"
    if mispricing < MIN_MISPRICING: return 0, None
    score = int(min(50, mispricing * 500))  # 500 multiplier: 2%→10pts, 6%→30pts(HIGH)
    # Bonus: add points when < 5 min left — contract is near resolution, higher certainty
    if mins_left < 5 and z_score >= 1.5:
        score = min(50, score + 5)
    return score, direction

def score_lag_detection(market, btc_price):
    """
    SIGNAL 2 — LAG DETECTION (0-30 pts)
    Did BTC move recently but Kalshi hasn't repriced yet?
    Detect the window where we can enter before market makers catch up.
    """
    if not market or not btc_price: return 0, None
    now    = time.time()
    cutoff = now - LAG_WINDOW_SECS
    # Get BTC price LAG_WINDOW_SECS ago
    with price_lock:
        old_btc = next((p for t, p in price_history if t >= cutoff), None)
    if not old_btc: return 0, None
    btc_move = (btc_price - old_btc) / old_btc  # positive = BTC went up
    if abs(btc_move) < LAG_MIN_BTC_MOVE: return 0, None  # not enough BTC movement
    # Get Kalshi price LAG_WINDOW_SECS ago
    with kalshi_price_lock:
        old_kalshi = next(((y, n) for t, y, n in kalshi_price_history if t >= cutoff), None)
    if not old_kalshi: return 0, None
    old_yes, old_no = old_kalshi
    cur_yes  = market["yes_ask"]
    cur_no   = market["no_ask"]
    # Expected Kalshi move: if BTC went up, YES price should have risen
    if btc_move > 0:
        # BTC went up — check if YES price lagged
        expected_yes_move = btc_move * 1.5   # rough sensitivity
        actual_yes_move   = cur_yes - old_yes
        lag = expected_yes_move - actual_yes_move
        if lag > 0.03:  # Kalshi lagged by 3%+ on a BTC up move
            score = int(min(30, lag * 250))
            return score, "UP"
    elif btc_move < 0:
        # BTC went down — check if NO price lagged
        expected_no_move = abs(btc_move) * 1.5
        actual_no_move   = cur_no - old_no
        lag = expected_no_move - actual_no_move
        if lag > 0.03:
            score = int(min(30, lag * 250))
            return score, "DOWN"
    return 0, None

def score_momentum(direction, momentum):
    """
    SIGNAL 3 — MOMENTUM CONFIRMATION (0-20 pts)
    Used only as a tiebreaker or confirming signal.
    Never triggers a trade on its own.
    """
    if not direction or momentum is None: return 0
    strength = abs(momentum) / MOMENTUM_THRESHOLD
    return int(min(20, strength * 6))

def get_confidence_score(market, btc_price, momentum, direction):
    """
    Master scoring function. Combines all 3 signals.
    Returns: (score, trade_direction, tier, breakdown_str)
    """
    if not market or not btc_price: return 0, None, None, ""
    exp_score, exp_dir   = score_expiration_value(market, btc_price)
    lag_score, lag_dir   = score_lag_detection(market, btc_price)
    # Resolve direction: expiration value takes priority, then lag, then momentum
    trade_dir = exp_dir or lag_dir or (direction if direction in ("UP","DOWN") else None)
    if not trade_dir: return 0, None, None, ""
    # Hard block — if momentum actively opposes trade direction, skip entirely
    opposing = (direction == "UP" and trade_dir == "DOWN") or \
               (direction == "DOWN" and trade_dir == "UP")
    if opposing:
        return 0, None, None, ""
    # Only add momentum points if it confirms the same direction
    mom_score = score_momentum(trade_dir, momentum) if direction == trade_dir else 0
    # Block EXP+LAG with no momentum — data shows 0W/4L, -$8.12. Never trade this combo alone.
    if exp_score > 0 and lag_score > 0 and mom_score == 0:
        return 0, None, None, ""
    # Require MOM on ALL trades — data shows nothing wins without momentum confirming
    # EXP+MOM=83% WR, EXP+LAG+MOM=100% WR. Everything without MOM loses.
    if mom_score == 0:
        return 0, None, None, ""
    # Require meaningful momentum — weak MOM (score 6-10) = 37-46% WR on both bots.
    # MOM>=8 filters borderline signals. MOM 10+ = 57-70% WR.
    if mom_score < MIN_MOM_SCORE:
        return 0, None, None, ""
    # Block EXP+MOM when no LAG — 115-trade sample: EXP+MOM(no LAG)=20-46% WR, consistently loses.
    # With LAG anchor: EXP+LAG+MOM=66-100% WR, LAG+MOM=57-75% WR. MOM-only=66% WR.
    # EXP without LAG confirmation = noise, not signal.
    if exp_score > 0 and lag_score == 0:
        return 0, None, None, ""
    total = exp_score + lag_score + mom_score
    breakdown = f"EXP:{exp_score} LAG:{lag_score} MOM:{mom_score}"
    if   total >= SCORE_HIGH:   tier = "HIGH"
    elif total >= SCORE_MEDIUM: tier = "MEDIUM"
    else:                       tier = None
    return total, trade_dir, tier, breakdown

def get_dynamic_bet(score, current_balance):
    """
    Kelly-inspired dynamic bet sizing.
    Bet size scales with BOTH confidence score AND current balance.
    Higher score = bigger % of balance. Balance grows = bets grow.
    Hard floor: $0.50. Soft cap: 15% of balance per trade.
    """
    if score < SCORE_MEDIUM: return 0.0
    if   score >= 90: pct = 0.15
    elif score >= 85: pct = 0.12
    elif score >= 75: pct = 0.08
    elif score >= 65: pct = 0.05
    elif score >= 55: pct = 0.03
    elif score >= 45: pct = 0.02
    else:             pct = 0.01   # score 38-44
    bet = round(current_balance * pct, 2)
    bet = max(0.50, min(bet, current_balance * 0.15))  # floor $0.50, cap 15%
    return bet

def get_bet_for_tier(tier):
    """Legacy wrapper — used when no score available."""
    if tier == "HIGH":
        return round(min(BET_HIGH_MAX, max(BET_HIGH_MIN, balance * 0.025)), 2)
    return round(min(BET_MED_MAX, max(BET_MED_MIN, balance * 0.010)), 2)


def get_all_15m_markets():
    """Return ALL currently open KXBTC15M markets (Kalshi often has 2-3 overlapping).
    Sorted by secs_left ascending so we score shortest-window first."""
    path    = "/trade-api/v2/markets"
    params  = "?status=open&limit=20&series_ticker=KXBTC15M"
    headers = get_headers("GET", path)
    try:
        r = requests.get(BASE_URL + path + params, headers=headers, timeout=5)
        if r.status_code != 200: return []
    except: return []
    now = datetime.datetime.now(datetime.timezone.utc)
    markets = []
    for m in r.json().get("markets", []):
        try:
            open_t  = datetime.datetime.fromisoformat(m["open_time"].replace("Z", "+00:00"))
            close_t = datetime.datetime.fromisoformat(m["close_time"].replace("Z", "+00:00"))
            if open_t <= now <= close_t:
                secs = (close_t - now).total_seconds()
                if secs < 30: continue  # skip markets about to expire
                markets.append({
                    "ticker"  : m["ticker"],
                    "yes_ask" : float(m.get("yes_ask_dollars") or 0),
                    "yes_bid" : float(m.get("yes_bid_dollars") or 0),
                    "no_ask"  : float(m.get("no_ask_dollars") or 0),
                    "no_bid"  : float(m.get("no_bid_dollars") or 0),
                    "floor"   : float(m.get("floor_strike") or 0),
                    "secs_left": secs,
                    "volume"  : float(m.get("volume_24h_fp") or 0)})
        except: continue
    return sorted(markets, key=lambda x: x["secs_left"])

def get_15m_market():
    """Legacy: returns first active market or None."""
    markets = get_all_15m_markets()
    return markets[0] if markets else None

def get_live_price(ticker):
    path    = f"/trade-api/v2/markets/{ticker}"
    headers = get_headers("GET", path)
    r       = requests.get(BASE_URL + path, headers=headers)
    if r.status_code == 200:
        m = r.json().get("market", {})
        return (float(m.get("yes_bid_dollars") or 0),
                float(m.get("no_bid_dollars") or 0))
    return None, None

# ── Auto-analysis thresholds (only tune on VERY strong patterns) ──────────────
AUTO_MIN_BUCKET_TRADES = 20    # need 20+ trades in a bucket before acting
AUTO_MIN_WIN_RATE      = 0.60  # 60%+ win rate required to mark as "good"
AUTO_MAX_WIN_RATE      = 0.40  # 40% or below to mark as "avoid"
AUTO_RETUNE_EVERY      = 30    # re-analyze every 30 completed trades
_last_autotune_count   = 0     # track when we last auto-tuned

def get_btc_volatility():
    """Std deviation of last 60 BTC ticks — measures choppiness."""
    with price_lock:
        recent = [p for _, p in price_history[-60:]]
    if len(recent) < 10: return 0.0
    mean = sum(recent) / len(recent)
    variance = sum((p - mean) ** 2 for p in recent) / len(recent)
    return round((variance ** 0.5) / mean * 100, 4)  # as % of price

def log_analytics(pos, event, exit_price=None, pnl=None, exit_reason=None):
    """Write rich per-trade data to trade_analytics.json for the learning engine."""
    now = datetime.datetime.now()
    record = {
        "event"            : event,
        "ticker"           : pos["ticker"],
        "timestamp"        : time.time(),
        "time_str"         : now.strftime("%H:%M:%S"),
        "hour"             : now.hour,
        "day_of_week"      : now.weekday(),   # 0=Mon 6=Sun
        "direction"        : pos["direction"],
        "signal_type"      : pos.get("signal_type", "?"),
        "tier"             : pos.get("tier", "?"),
        "entry_price"      : pos["entry_price"],
        "momentum_pct"     : round(abs(pos.get("momentum_raw", 0)) * 100, 4),
        "momentum_strength": round(abs(pos.get("momentum_raw", 0)) / MOMENTUM_THRESHOLD, 2),
        "btc_volatility"   : get_btc_volatility(),
        "market_yes_ask"   : pos.get("market_yes_ask", 0),
        "market_spread"    : round(pos.get("market_yes_ask", 0) - pos.get("market_yes_bid", 0), 3),
        "secs_left"        : pos.get("secs_left", 0),
        "btc_price"        : pos.get("entry_btc", 0),
        "confidence_score" : pos.get("confidence_score", 0),
        "exp_score"        : pos.get("exp_score", 0),
        "lag_score"        : pos.get("lag_score", 0),
        "mom_score"        : pos.get("mom_score", 0),
        "confidence_tier"  : pos.get("tier", "?"),
    }
    if event == "exit" and exit_price is not None:
        hold_secs = int(time.time() - pos.get("entry_time", time.time()))
        record.update({
            "exit_price" : exit_price,
            "pnl"        : round(pnl, 4),
            "pnl_pct"    : round((exit_price - pos["entry_price"]) / pos["entry_price"] * 100, 2),
            "outcome"    : "WIN" if (pnl or 0) > 0 else "LOSS",
            "exit_reason": exit_reason,
            "hold_secs"  : hold_secs,
        })
    # Append to analytics file
    try:
        try:
            with open(ANALYTICS_PATH) as f: data = json.load(f)
        except: data = []
        data.append(record)
        with open(ANALYTICS_PATH, "w") as f: json.dump(data, f, indent=2, default=str)
    except Exception as e:
        pass  # never crash the bot over analytics

def log_market_snapshot(btc_price, market, momentum, direction):
    """
    Runs every scan cycle. Tracks BTC price trend + Kalshi odds over time.
    Stored in market_snapshots.json — separate from per-trade analytics.
    """
    if not btc_price: return
    now = datetime.datetime.now()
    # Calculate short-term BTC change vs last snapshot
    snap_path = SNAPSHOTS_PATH
    try:
        try:
            with open(snap_path) as f: snaps = json.load(f)
        except: snaps = []
        # BTC change vs 1 min ago and 5 min ago
        now_ts   = time.time()
        snaps_1m = [s for s in snaps if s.get("timestamp") and (now_ts - s["timestamp"]) <= 65]
        snaps_5m = [s for s in snaps if s.get("timestamp") and (now_ts - s["timestamp"]) <= 305]
        btc_1m_ago = snaps_1m[0]["btc_price"]  if snaps_1m else btc_price
        btc_5m_ago = snaps_5m[0]["btc_price"]  if snaps_5m else btc_price
        chg_1m = round((btc_price - btc_1m_ago) / btc_1m_ago * 100, 4)
        chg_5m = round((btc_price - btc_5m_ago) / btc_5m_ago * 100, 4)
        record = {
            "timestamp"  : now_ts,
            "time_str"   : now.strftime("%H:%M:%S"),
            "hour"       : now.hour,
            "day_of_week": now.weekday(),
            "btc_price"  : btc_price,
            "btc_chg_1m" : chg_1m,
            "btc_chg_5m" : chg_5m,
            "momentum"   : round(momentum * 100, 4) if momentum else 0,
            "direction"  : direction or "FLAT",
            "volatility" : get_btc_volatility(),
            "yes_ask"    : market["yes_ask"]  if market else None,
            "no_ask"     : market["no_ask"]   if market else None,
            "spread"     : round(market["yes_ask"] - market["yes_bid"], 3) if market else None,
            "secs_left"  : market["secs_left"] if market else None,
        }
        snaps.append(record)
        # Keep last 2000 snapshots (~5.5 hours at 10s intervals)
        if len(snaps) > 2000: snaps = snaps[-2000:]
        with open(snap_path, "w") as f: json.dump(snaps, f, default=str)
    except: pass

def auto_analyze():
    """
    Runs every AUTO_RETUNE_EVERY completed trades.
    Only updates auto_params.json when patterns are very strong:
      - 60%+ win rate AND 20+ trades in bucket = mark as GOOD hour
      - 40% or below AND 20+ trades = mark as AVOID hour
    Does NOT constantly tweak — only acts on overwhelming evidence.
    """
    global _last_autotune_count, avoid_hours, good_hours
    try:
        with open(ANALYTICS_PATH) as f: data = json.load(f)
    except: return
    exits = [r for r in data if r.get("event") == "exit"]
    if len(exits) < AUTO_RETUNE_EVERY: return
    # ── Hour analysis ──
    from collections import defaultdict
    hour_map = defaultdict(lambda: {"wins": 0, "total": 0})
    for t in exits:
        h = t.get("hour", 0)
        hour_map[h]["total"] += 1
        if t.get("outcome") == "WIN": hour_map[h]["wins"] += 1
    new_good   = [h for h, d in hour_map.items()
                  if d["total"] >= AUTO_MIN_BUCKET_TRADES
                  and d["wins"] / d["total"] >= AUTO_MIN_WIN_RATE]
    new_avoid  = [h for h, d in hour_map.items()
                  if d["total"] >= AUTO_MIN_BUCKET_TRADES
                  and d["wins"] / d["total"] <= AUTO_MAX_WIN_RATE]
    # ── Momentum strength analysis ──
    mom_map = defaultdict(lambda: {"wins": 0, "total": 0})
    for t in exits:
        bucket = "strong" if t.get("momentum_strength", 0) >= 2.0 else "weak"
        mom_map[bucket]["total"] += 1
        if t.get("outcome") == "WIN": mom_map[bucket]["wins"] += 1
    # If weak signals are losing badly (40%- with 20+ trades), note it
    weak_note = ""
    if mom_map["weak"]["total"] >= AUTO_MIN_BUCKET_TRADES:
        weak_wr = mom_map["weak"]["wins"] / mom_map["weak"]["total"]
        if weak_wr <= AUTO_MAX_WIN_RATE:
            weak_note = f"Weak signals losing ({weak_wr*100:.0f}% WR) — consider raising threshold"
    # ── Only write if something actually changed ──
    existing_good  = sorted(good_hours)
    existing_avoid = sorted(avoid_hours)
    new_good_s  = sorted(new_good)
    new_avoid_s = sorted(new_avoid)
    changed = (new_good_s != existing_good or new_avoid_s != existing_avoid)
    notes = []
    if new_good_s:  notes.append(f"Best hours (60%+ WR, 20+ trades): {[f'{h:02d}:xx' for h in new_good_s]}")
    if new_avoid_s: notes.append(f"Avoided hours (40%- WR, 20+ trades): {[f'{h:02d}:xx' for h in new_avoid_s]}")
    if weak_note:   notes.append(weak_note)
    params = {
        "generated_at"    : str(datetime.datetime.now()),
        "trades_analyzed" : len(exits),
        "good_hours"      : new_good_s,
        "avoid_hours"     : new_avoid_s,
        "notes"           : notes,
    }
    with open(AUTO_PARAMS_PATH, "w") as f: json.dump(params, f, indent=2)
    # Apply immediately in this session
    good_hours  = new_good_s
    avoid_hours = new_avoid_s
    _last_autotune_count = len(exits)
    if changed and (new_good_s or new_avoid_s):
        print(f"\n  [LEARN] Auto-params updated ({len(exits)} trades analyzed)")
        for note in notes: print(f"    {note}")
    else:
        print(f"  [LEARN] {len(exits)} trades analyzed — no strong patterns yet (need 60%+ or 40%- with 20+ trades)")

def ensure_data_dirs():
    """Create data/ and data/archive/ folders if they don't exist."""
    for d in [DATA_DIR, ARCHIVE_DIR]:
        os.makedirs(d, exist_ok=True)

def archive_file_if_large(path, label, threshold):
    """
    If a JSON file has >= threshold records, move it to archive with a timestamp.
    Archives are NEVER deleted. The working file is reset to empty [].
    """
    if not os.path.exists(path): return 0
    try:
        with open(path) as f: data = json.load(f)
        if not isinstance(data, list): return len(data)
        count = len(data)
        if count >= threshold:
            ts    = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
            fname = os.path.basename(path).replace('.json', f'_{ts}.json')
            dest  = os.path.join(ARCHIVE_DIR, fname)
            # Write archive copy
            with open(dest, 'w') as f: json.dump(data, f, indent=2, default=str)
            # Reset working file to empty
            with open(path, 'w') as f: json.dump([], f)
            print(f"  🗄  {label}: {count} records archived → data/archive/{fname}")
            return 0
        return count
    except Exception as e:
        print(f"  ⚠  archive check failed for {label}: {e}")
        return 0

def save_session_summary():
    """
    Called at startup BEFORE paper_log is wiped.
    Reads the last session's paper_log and appends a summary to session_history.json.
    Even sessions with 0 trades are recorded so we have a complete run history.
    """
    ensure_data_dirs()
    try:
        # Read last session's data
        if not os.path.exists(PAPER_LOG_PATH): return
        with open(PAPER_LOG_PATH) as f: log = json.load(f)
        trades    = log.get('trades', [])
        exits     = [t for t in trades if t.get('event') == 'exit']
        entries   = [t for t in trades if t.get('event') == 'entry']
        wins      = [t for t in exits if t.get('pnl', 0) > 0]
        total_pnl = round(sum(t.get('pnl', 0) for t in exits), 4)
        end_bal   = log.get('balance', STARTING_BALANCE)
        peak_bal  = log.get('high_water_mark', STARTING_BALANCE)
        win_rate  = round(len(wins) / len(exits) * 100, 1) if exits else 0
        summary = {
            "session_end"    : datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            "trades"         : len(exits),
            "entries"        : len(entries),
            "wins"           : len(wins),
            "losses"         : len(exits) - len(wins),
            "win_rate_pct"   : win_rate,
            "total_pnl"      : total_pnl,
            "start_balance"  : STARTING_BALANCE,
            "end_balance"    : end_bal,
            "peak_balance"   : peak_bal,
            "pnl_pct"        : round((end_bal - STARTING_BALANCE) / STARTING_BALANCE * 100, 2),
        }
        # Append to running session history
        history = []
        if os.path.exists(SESSION_LOG_PATH):
            try:
                with open(SESSION_LOG_PATH) as f: history = json.load(f)
            except: history = []
        history.append(summary)
        with open(SESSION_LOG_PATH, 'w') as f:
            json.dump(history, f, indent=2, default=str)
        if exits:
            print(f"  💾 Session saved: {len(exits)} trades | WR:{win_rate:.0f}% | P&L:${total_pnl:+.2f}")
        else:
            print(f"  💾 Session saved: 0 trades this session (recorded for history)")
    except Exception as e:
        print(f"  ⚠  Could not save session summary: {e}")

def preserve_and_archive():
    """
    Master data preservation call. Run at every startup before anything is reset.
    1. Save session summary from last run
    2. Auto-archive large files to data/archive/
    3. Never delete anything
    """
    ensure_data_dirs()
    save_session_summary()
    # Check if files need archiving
    a_count = archive_file_if_large(ANALYTICS_PATH,  "trade_analytics", ARCHIVE_ANALYTICS_AT)
    s_count = archive_file_if_large(SNAPSHOTS_PATH,  "market_snapshots", ARCHIVE_SNAPSHOTS_AT)
    if a_count > 0:
        print(f"  📊 Analytics:  {a_count:,} records  (archives at {ARCHIVE_ANALYTICS_AT:,})")
    if s_count > 0:
        print(f"  📊 Snapshots:  {s_count:,} records  (archives at {ARCHIVE_SNAPSHOTS_AT:,})")

def save_log():
    with open(PAPER_LOG_PATH, "w") as f:
        json.dump({"balance": balance, "high_water_mark": high_water_mark,
                   "trades": paper_log}, f, indent=2, default=str)

def save_state(btc, sources, edge, momentum, direction, market, bet_tier="BASE", score=0, tier=None, breakdown=""):
    exits = [t for t in paper_log if t.get("event") == "exit"]

    # ── KEY CHANGE: include live current_price and computed P&L on each position ──
    pos_data = []
    for p in paper_positions:
        current = p.get("current_price", p["entry_price"])
        pnl_abs = round((current - p["entry_price"]) * p["count"], 4)
        pnl_pct = round((current - p["entry_price"]) / p["entry_price"] * 100, 2)
        held_secs = int(time.time() - p.get("entry_time", time.time()))
        pos_data.append({
            "ticker"       : p["ticker"],
            "side"         : p["side"],
            "direction"    : p["direction"],
            "signal_type"  : p.get("signal_type", "?"),
            "tier"         : p.get("tier", "?"),
            "entry_price"  : p["entry_price"],
            "current_price": current,
            "target"       : p["target"],
            "stop"         : p["stop"],
            "count"        : p["count"],
            "cost"         : p["cost"],
            "pnl_abs"      : pnl_abs,
            "pnl_pct"      : pnl_pct,
            "floor"        : p["floor"],
            "held_secs"    : held_secs,
            "entry_time_str": datetime.datetime.fromtimestamp(
                p.get("entry_time", time.time())).strftime("%H:%M:%S"),
        })

    stop_level = round(high_water_mark * (1 - TRAILING_STOP_PCT), 2)
    # Use pre-computed score passed in from main loop — no redundant recalculation
    _sc, _tr, _bd = score, tier, breakdown
    state = {
        "mode": "paper", "last_update": time.time(),
        "btc_price": btc, "btc_source": sources, "edge": edge or "",
        "balance": round(balance, 2), "starting_balance": STARTING_BALANCE,
        "high_water_mark": round(high_water_mark, 2), "stop_level": stop_level,
        "momentum": momentum or 0, "direction": direction or "FLAT",
        "bet_tier": bet_tier, "session_halted": session_halted,
        "confidence_score": _sc,
        "confidence_tier": _tr or "--",
        "score_breakdown": _bd or "",
        "market": market, "open_positions": pos_data, "completed_trades": exits
    }
    try:
        with open("C:\\kalshibot\\bot_state.json", "w") as f:
            json.dump(state, f, default=str)
    except: pass

def paper_enter(market, trade_direction, signal_type, btc_price, momentum, edge, override_bet=None, override_score=0, score_breakdown=""):
    global balance
    ticker = market["ticker"]
    side        = "yes" if trade_direction == "UP" else "no"
    entry_price = market["yes_ask"] if side == "yes" else market["no_ask"]
    if not (MIN_ENTRY_PRICE <= entry_price <= MAX_ENTRY_PRICE): return False, "SKIP"
    bet   = override_bet if override_bet else get_bet_for_tier(signal_type)
    bet   = min(bet, balance * 0.10)
    count = min(10, max(1, int(bet / entry_price)))  # hard cap at 10 contracts
    cost  = round(entry_price * count, 2)
    if cost > balance: return False, signal_type
    target   = min(round(entry_price * (1 + PROFIT_TARGET), 4), 0.92)
    stop     = round(entry_price * (1 - STOP_LOSS), 4)
    max_gain = round((target - entry_price) * count, 2)
    max_loss = round((entry_price - stop) * count, 2)
    rr       = round(max_gain / max_loss, 1) if max_loss > 0 else 0
    mins     = int(market["secs_left"] // 60)
    secs_r   = int(market["secs_left"] % 60)
    # Parse score breakdown EXP/LAG/MOM
    def _parse(tag):
        m = re.search(rf'{tag}:(-?\d+)', score_breakdown)
        return int(m.group(1)) if m else 0
    exp_s = _parse('EXP'); lag_s = _parse('LAG'); mom_s = _parse('MOM')
    pos = {"ticker": market["ticker"], "side": side, "entry_price": entry_price,
           "current_price": entry_price,
           "count": count, "cost": cost, "target": target, "stop": stop,
           "max_gain": max_gain, "max_loss": max_loss,
           "entry_time": time.time(), "entry_btc": btc_price,
           "direction": trade_direction, "floor": market["floor"],
           "tier": signal_type, "signal_type": signal_type,
           "momentum_raw": momentum or 0,
           "market_yes_ask": market["yes_ask"],
           "market_yes_bid": market["yes_bid"],
           "secs_left": market["secs_left"],
           "confidence_score": override_score,
           "exp_score": exp_s, "lag_score": lag_s, "mom_score": mom_s}
    balance -= cost
    with position_lock:
        paper_positions.append(pos)
    paper_log.append({**pos, "event": "entry",
                      "time_str": datetime.datetime.now().strftime("%H:%M:%S")})
    log_analytics(pos, "entry")
    save_log()
    stop_level = round(high_water_mark * (1 - TRAILING_STOP_PCT), 2)
    print(f"\n  {'='*62}")
    print(f"  PAPER TRADE [{signal_type}]  {'YES BTC UP' if side=='yes' else 'NO  BTC DOWN'}")
    print(f"  BTC: ${btc_price:,.2f}  Floor: ${market['floor']:,.2f}")
    print(f"  Entry: ${entry_price:.3f} x{count} = ${cost:.2f}  |  {mins}m {secs_r}s left")
    print(f"  Target: +${max_gain:.2f} (${target:.3f})  Stop: -${max_loss:.2f} (${stop:.3f})  [{rr}:1]")
    print(f"  Balance: ${balance:.2f}  Peak: ${high_water_mark:.2f}  Trailing stop: ${stop_level:.2f}")
    print(f"  {'='*62}")
    return True, signal_type

def check_exits_fast():
    """
    Called by the 2-second fast monitor thread.
    Checks every open position for target/stop/time exit.
    Prints only when something actually exits — silent otherwise.
    """
    global balance
    with position_lock:
        if not paper_positions: return
        to_close = []
        for pos in paper_positions:
            yb, nb = get_live_price(pos["ticker"])
            if yb is None: continue
            current = yb if pos["side"] == "yes" else nb
            if current <= 0: continue
            pos["current_price"] = current
            entry   = pos["entry_price"]
            pnl_pct = (current - entry) / entry
            pnl_abs = round((current - entry) * pos["count"], 4)
            reason  = None
            if   current >= pos["target"]:              reason = f"TARGET +{pnl_pct*100:.1f}%"
            elif current <= pos["stop"]:                reason = f"STOP {pnl_pct*100:.1f}%"
            elif time.time() - pos["entry_time"] > 780: reason = f"TIME {pnl_pct*100:+.1f}%"
            if reason:
                balance = round(balance + current * pos["count"], 2)
                icon    = "WIN " if pnl_abs >= 0 else "LOSS"
                print(f"\n  ⚡[{icon}] {reason}  ${entry:.3f}→${current:.3f}  "
                      f"P&L:${pnl_abs:+.2f}  Bal:${balance:.2f}")
                paper_log.append({**pos, "event": "exit", "exit_price": current,
                                   "pnl": pnl_abs, "reason": reason,
                                   "balance_after": balance,
                                   "time_str": datetime.datetime.now().strftime("%H:%M:%S")})
                log_analytics(pos, "exit", exit_price=current,
                              pnl=pnl_abs, exit_reason=reason)
                to_close.append(pos)
                save_log()
        for pos in to_close:
            paper_positions.remove(pos)

def start_dashboard_server():
    """Serve the kalshibot folder at localhost:8765 so dashboard.html can fetch bot_state.json."""
    import http.server, socketserver, os
    os.chdir("C:\\kalshibot")
    class QuietHandler(http.server.SimpleHTTPRequestHandler):
        def log_message(self, *args): pass  # silence request logs
    try:
        with socketserver.TCPServer(("", 8765), QuietHandler) as httpd:
            httpd.serve_forever()
    except OSError:
        pass  # port already in use — another instance is serving

def fast_monitor_loop():
    """Daemon thread — checks positions every 2 seconds independently of the main loop."""
    while True:
        try:
            time.sleep(FAST_MONITOR_INTERVAL)
            check_exits_fast()
        except Exception as e:
            pass  # never kill the thread on an error

def print_position_status():
    """Called by main loop every 10 seconds — just prints HOLD status, no exit logic."""
    with position_lock:
        for pos in paper_positions:
            current = pos.get("current_price", pos["entry_price"])
            entry   = pos["entry_price"]
            pnl_pct = (current - entry) / entry
            print(f"  HOLD {'YES' if pos['side']=='yes' else 'NO '}  "
                  f"${entry:.3f}→${current:.3f}  ({pnl_pct*100:+.1f}%)  "
                  f"target:${pos['target']:.3f}  stop:${pos['stop']:.3f}")


def print_report():
    print("\n\n" + "="*65)
    print("  PAPER TRADING SESSION REPORT")
    print("="*65)
    exits = [t for t in paper_log if t.get("event") == "exit"]
    if not exits:
        print(f"  No completed trades.  Balance: ${balance:.2f}  Peak: ${high_water_mark:.2f}")
        return
    wins      = [t for t in exits if t.get("pnl", 0) > 0]
    losses    = [t for t in exits if t.get("pnl", 0) <= 0]
    total_pnl = round(sum(t.get("pnl", 0) for t in exits), 2)
    total_bet = round(sum(t.get("cost", 0) for t in exits), 2)
    hrs  = int((time.time() - session_start) / 3600)
    mins = int(((time.time() - session_start) % 3600) / 60)
    print(f"  Session      : {hrs}h {mins}m")
    print(f"  Start        : ${STARTING_BALANCE:.2f}")
    print(f"  Peak balance : ${high_water_mark:.2f}  (+${high_water_mark-STARTING_BALANCE:.2f})")
    print(f"  Final balance: ${balance:.2f}")
    print(f"  Total P&L    : ${total_pnl:+.2f}  ({(balance-STARTING_BALANCE)/STARTING_BALANCE*100:+.1f}%)")
    print(f"  Total wagered: ${total_bet:.2f}")
    print(f"  {'─'*45}")
    print(f"  Trades       : {len(exits)}  ({len(wins)}W / {len(losses)}L)  Win rate: {len(wins)/len(exits)*100:.0f}%")
    if exits: print(f"  Avg/trade    : ${total_pnl/len(exits):+.2f}")
    types = {}
    for t in exits:
        st = t.get("signal_type", "?")
        if st not in types: types[st] = {"count": 0, "pnl": 0, "wins": 0}
        types[st]["count"] += 1; types[st]["pnl"] += t.get("pnl", 0)
        if t.get("pnl", 0) > 0: types[st]["wins"] += 1
    if types:
        print(f"  By type      :")
        for st, data in sorted(types.items()):
            wr = data["wins"]/data["count"]*100 if data["count"] else 0
            print(f"    {st:<16}  {data['count']} trades  P&L: ${data['pnl']:+.2f}  WR: {wr:.0f}%")
    print(f"\n  {'Time':>8}  {'Type':>9}  {'Side':>4}  {'Entry':>7}  {'Exit':>7}  {'P&L':>7}  Result")
    for t in exits[-30:]:
        print(f"  {t.get('time_str','?'):>8}  {t.get('signal_type','?'):>9}  "
              f"{'YES' if t.get('side')=='yes' else 'NO ':>4}  "
              f"${t.get('entry_price',0):.3f}  ${t.get('exit_price',0):.3f}  "
              f"${t.get('pnl',0):>+6.2f}  {'WIN' if t.get('pnl',0)>0 else 'LOSS'}")
    print(f"\n  Log: C:\\kalshibot\\paper_log.json")
    print("="*65)

def run():
    global balance, high_water_mark, good_hours, avoid_hours, traded_tickers
    # ── Step 1: Preserve all data from last session BEFORE resetting anything ──
    preserve_and_archive()
    balance = STARTING_BALANCE; high_water_mark = STARTING_BALANCE
    # ── Step 2: Reset paper balance to $100 (analytics + snapshots always preserved) ──
    with open(PAPER_LOG_PATH, "w") as f:
        json.dump({"balance": STARTING_BALANCE, "high_water_mark": STARTING_BALANCE, "trades": []}, f)
    print(f"  ✅ Paper balance reset to ${STARTING_BALANCE:.2f}  (all learning data preserved)")
    # Load auto-tuned params if the learning engine has generated them
    if os.path.exists(AUTO_PARAMS_PATH):
        try:
            with open(AUTO_PARAMS_PATH) as f: ap = json.load(f)
            print(f"  ✅ Loaded auto_params.json ({ap.get('trades_analyzed','?')} trades analyzed)")
            for note in ap.get("notes", []): print(f"     {note}")
            good_hours  = ap.get("good_hours", [])
            avoid_hours = ap.get("avoid_hours", [])
        except Exception as e:
            print(f"  ⚠  Could not load auto_params.json: {e}")
            good_hours = []; avoid_hours = []
    else:
        good_hours = []; avoid_hours = []
    print("\n" + "="*65)
    print("  PAPER TRADING BOT v1.5  --  $250 Starting Balance")
    print("  KXBTC15M  |  Binance (WS+REST) + Coinbase + Kraken + OKX + Bybit")
    print("="*65)
    print(f"  Signal engine : LAG+MOM / MOM-only / EXP+LAG+MOM (v1.5 filters)")
    print(f"  Filters       : entry>=0.60 | MOM>=8 | EXP blocked without LAG")
    print(f"  Bet sizing    : DYNAMIC — scales with score + balance (Kelly-inspired)")
    print(f"  Position mon  : every {FAST_MONITOR_INTERVAL}s  |  Entry scan: every {SCAN_INTERVAL}s (instant on big BTC move)")
    print(f"  Kalshi cache  : refreshed every {KALSHI_POLL_INTERVAL}s (background thread, no blocking)")
    print(f"  Hot path      : instant trigger if BTC moves {HOT_PATH_BTC_MOVE*100:.2f}%+ (beats market maker lag)")
    print(f"  R:R           : +{PROFIT_TARGET*100:.0f}% target / -{STOP_LOSS*100:.0f}% stop = {PROFIT_TARGET/STOP_LOSS:.1f}:1")
    print(f"  Trailing stop: halt if balance drops {TRAILING_STOP_PCT*100:.0f}% from PEAK")
    print(f"  Dashboard    : Double-click OPEN DASHBOARD on desktop")
    print("="*65)
    if WS_AVAILABLE:
        threading.Thread(target=binance_ws,        daemon=True).start()
    threading.Thread(target=poll_binance_rest, daemon=True).start()
    threading.Thread(target=poll_coinbase,     daemon=True).start()
    threading.Thread(target=poll_kraken,       daemon=True).start()
    threading.Thread(target=start_dashboard_server, daemon=True).start()
    threading.Thread(target=fast_monitor_loop,         daemon=True).start()
    threading.Thread(target=poll_kalshi_market,        daemon=True).start()
    threading.Thread(target=poll_okx,                  daemon=True).start()
    threading.Thread(target=poll_bybit,                daemon=True).start()
    print("  Starting: Binance WS + REST, Coinbase, Kraken, OKX, Bybit, Kalshi cache...")
    # ── Quick connectivity check on all sources ────────────────────────────────
    test_sources = [
        ("Binance.US","https://api.binance.us/api/v3/ticker/price?symbol=BTCUSDT",
         lambda r: float(r.json()["price"])),
        ("Coinbase", "https://api.coinbase.com/v2/prices/BTC-USD/spot",
         lambda r: float(r.json()["data"]["amount"])),
        ("Kraken",   "https://api.kraken.com/0/public/Ticker?pair=XBTUSD",
         lambda r: float(r.json()["result"]["XXBTZUSD"]["c"][0])),
        ("OKX",      "https://www.okx.com/api/v5/market/ticker?instId=BTC-USDT",
         lambda r: float(r.json()["data"][0]["last"])),
        ("Bybit",    "https://api.bybit.com/v5/market/tickers?category=spot&symbol=BTCUSDT",
         lambda r: float(r.json()["result"]["list"][0]["lastPrice"])),
    ]
    working = []
    for name, url, parser in test_sources:
        try:
            resp = requests.get(url, timeout=4)
            price = parser(resp)
            print(f"  ✅ {name:<10} ${price:>10,.2f}")
            working.append(name)
        except Exception as e:
            print(f"  ❌ {name:<10} unreachable ({type(e).__name__})")
    print(f"  Active feeds: {len(working)}/5  —  {', '.join(working)}")
    if len(working) < 2:
        print("  ⚠  WARNING: fewer than 2 price sources. LAG detection will be limited.")
    print("  " + "─"*60)
    print("  Waiting for prices...")
    for i in range(30):
        time.sleep(1)
        with price_lock:
            has = any(p for p in prices.values() if p); ticks = len(price_history)
        if has and ticks >= WARMUP_TICKS:
            with price_lock:
                p = prices["binance"] or prices["coinbase"] or prices["kraken"]
                srcs = "+".join(k for k in prices if prices[k])
            print(f"  Ready  BTC ${p:,.2f} [{srcs}]"); break
        elif has:
            with price_lock:
                p = prices["binance"] or prices["coinbase"] or prices["kraken"]
            print(f"  BTC ${p:,.2f} ({ticks}/{WARMUP_TICKS} ticks)...")
    else:
        print("  Slow feeds — continuing anyway")
    print(f"  Balance: ${balance:.2f}  Stop at: ${balance*(1-TRAILING_STOP_PCT):.2f}  |  Ctrl+C to stop\n")
    last_entry = 0; current_tier = "BASE"; last_market_ticker = None
    while True:
        try:
            # Wait up to SCAN_INTERVAL seconds, but wake early if BTC makes a big move
            triggered_by_hot_path = hot_path_event.wait(timeout=SCAN_INTERVAL)
            if triggered_by_hot_path:
                hot_path_event.clear()
            now = time.time()
            update_high_water_mark()
            btc, sources, edge = get_best_price()
            if not btc:
                print(f"  [{datetime.datetime.now().strftime('%H:%M:%S')}] No price data"); continue
            momentum, direction, pct = get_momentum()
            # Read from cache instead of blocking API call — refreshed every 3s by background thread
            with market_cache_lock:
                markets = list(all_markets_cache)   # snapshot all open markets
                market  = markets[0] if markets else None
            # Clear per-market lock when the primary market changes
            if market and market["ticker"] != last_market_ticker:
                last_market_ticker = market["ticker"]
                print(f"  [NEW WINDOW] {market['ticker'][-20:]}  ({len(markets)} markets open)")
            with price_lock: ticks = len(price_history)
            warmup = ticks < WARMUP_TICKS
            # Score ALL open markets, pick the highest
            best_score, best_dir, best_tier, best_breakdown, best_market = 0, None, None, "", None
            if not warmup and markets:
                for mkt in markets:
                    sc, td, tr, bd = get_confidence_score(mkt, btc, momentum, direction)
                    if sc > best_score:
                        best_score, best_dir, best_tier, best_breakdown, best_market = sc, td, tr, bd, mkt
            score, trade_dir, tier, breakdown = best_score, best_dir, best_tier, best_breakdown
            if best_market: market = best_market   # use best market for entry
            current_tier = tier or "--"
            if warmup:
                mom_str = f"warming up ({ticks}/{WARMUP_TICKS})"
            else:
                d       = direction or "FLAT"
                tier_str = f" [{tier}]" if tier else ""
                n_mkts  = f" ({len(markets)}mkts)" if len(markets) > 1 else ""
                mom_str = f"{d} {pct}  score:{score}{tier_str}{n_mkts}  {breakdown}"
            stop_level = round(high_water_mark * (1 - TRAILING_STOP_PCT), 2)
            halt_str   = "  [HALTED]" if session_halted else ""
            mkt_str    = "no market" if not market else \
                         f"{int(market['secs_left']//60)}m{int(market['secs_left']%60)}s " \
                         f"Y:{market['yes_ask']:.2f} N:{market['no_ask']:.2f}"
            print(f"  {'⚡' if triggered_by_hot_path else '  '}[{datetime.datetime.now().strftime('%H:%M:%S')}]  "
                  f"BTC ${btc:,.2f} [{sources}]  {mom_str}  |  {mkt_str}  |  "
                  f"${balance:.2f} (peak:${high_water_mark:.2f} stop:${stop_level:.2f}) "
                  f"[{len(paper_positions)}/{MAX_POSITIONS}]{halt_str}")
            if edge: print(f"  {edge}")
            if paper_positions: print_position_status()
            save_state(btc, sources, edge, momentum, direction, market, current_tier,
                       score=score, tier=tier, breakdown=breakdown)
            log_market_snapshot(btc, market, momentum, direction)
            # Auto-analyze every AUTO_RETUNE_EVERY completed trades
            completed = len([t for t in paper_log if t.get("event") == "exit"])
            if completed > 0 and completed != _last_autotune_count and completed % AUTO_RETUNE_EVERY == 0:
                auto_analyze()
            current_hour = datetime.datetime.now().hour
            hour_blocked = False  # disabled — no hour blocking, trade all hours
            can_trade = (not warmup and not check_session_stop_loss()
                and market is not None and tier is not None and trade_dir is not None
                and len(paper_positions) < MAX_POSITIONS
                and now - last_entry > ENTRY_COOLDOWN
                and market["secs_left"] > MIN_SECS_LEFT
                and market.get("yes_ask", 0) > 0 and market.get("no_ask", 0) > 0
                and balance >= BET_MED_MIN
                and not hour_blocked)
            if not can_trade and tier is not None and score >= SCORE_MEDIUM:
                # Score is high enough but something is blocking — print why
                reasons = []
                if warmup:                              reasons.append("warming up")
                if check_session_stop_loss():           reasons.append("session halted")
                if market is None:                      reasons.append("no market")
                if tier is None:                        reasons.append(f"score {score} below threshold")
                if trade_dir is None:                   reasons.append("no direction")
                if len(paper_positions) >= MAX_POSITIONS: reasons.append("max positions")
                if now - last_entry <= ENTRY_COOLDOWN:  reasons.append(f"cooldown {int(ENTRY_COOLDOWN-(now-last_entry))}s left")
                if market and market["secs_left"] <= MIN_SECS_LEFT: reasons.append(f"only {int(market['secs_left'])}s left in window")
                if market and (market.get('yes_ask',0) == 0 or market.get('no_ask',0) == 0): reasons.append("market has no valid prices yet")
                if balance < BET_MED_MIN:               reasons.append(f"balance ${balance:.2f} too low")
                if hour_blocked:                        reasons.append("hour blocked by learning")
                print(f"  ⚠  score:{score} [{tier}] SKIP — {', '.join(reasons)}")
            if can_trade:
                bet      = get_dynamic_bet(score, balance)
                if bet <= 0: continue
                entered, _ = paper_enter(market, trade_dir, tier, btc, momentum, edge,
                                         override_bet=bet, override_score=score,
                                         score_breakdown=breakdown)
                if entered: last_entry = now
        except KeyboardInterrupt:
            print_report(); save_log(); break
        except Exception as e:
            print(f"  Error: {e}"); time.sleep(10)

if __name__ == "__main__":
    run()
