"""
HL-Bot V2 CLI — LLM-Brain Orderflow Trading Bot

Usage:
    python -m hlbot scan [--coins ETH,BTC,SOL]     # Show orderflow data
    python -m hlbot think [--coins ETH,BTC,SOL]     # LLM decides (needs API key)
    python -m hlbot paper [--hours 1]               # Paper trading (simulated LLM)
    python -m hlbot backtest [--hours 48]            # Replay V1 trades through V2 brain
    python -m hlbot report [--hours 24]              # Journal summary
    python -m hlbot config                           # Show config
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from collections import defaultdict
from typing import Optional

from .config import Config
from .api import HLClient
from .models import Candle, OrderflowSnapshot, JournalEntry
from .orderflow import (
    CVDTracker, parse_book, parse_tape, build_orderflow_snapshot,
    classify_regime,
)
from .brain import LLMBrain, SimulatedLLMBrain, LLMTradeDecision
from .risk import RiskManager
from .journal import Journal
from .research_gate import evaluate_research_gate
from .trigger_state import build_trigger_features
from .exit_plan import build_noise_state, evaluate_exit_plan
from .level_repair import repair_trade_levels
from .policy import TradingPolicy, alignment_summary
from .session import filter_session_candles, session_context
from .coinglass import CoinGlassClient, CoinGlassConfig

logging.basicConfig(
    level=logging.INFO,
    format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
    datefmt="%H:%M:%S",
)
log = logging.getLogger("hlbot")


def _policy(cfg: Config) -> TradingPolicy:
    return TradingPolicy.from_config(cfg)


def _cvd_tracker(cfg: Config) -> CVDTracker:
    return CVDTracker(
        state_path=cfg.cvd_state_path,
        reset_hour_utc=cfg.session_reset_hour_utc,
    )


def get_universe(client: HLClient, cfg: Config) -> list[str]:
    """Determine which coins to scan."""
    if cfg.universe not in ("top", "all"):
        coins = [c.strip() for c in cfg.universe.split(",")]
        return [c for c in coins if c not in cfg.blocked_coins]

    data = client.meta_and_contexts()
    if not data or len(data) < 2:
        return []

    universe = data[0]["universe"]
    contexts = data[1]
    pairs = []
    for i, asset in enumerate(universe):
        name = asset["name"]
        if name in cfg.blocked_coins:
            continue
        if i < len(contexts):
            vol = float(contexts[i].get("dayNtlVlm", 0))
            pairs.append((name, vol))

    pairs.sort(key=lambda x: -x[1])
    filtered = [p[0] for p in pairs if p[1] >= cfg.min_day_volume_usd]

    if cfg.universe == "top":
        return filtered[: cfg.max_coins]
    return filtered


def _merge_unique(*groups) -> list[str]:
    """Preserve order while adding coins from multiple iterables once."""
    out: list[str] = []
    seen: set[str] = set()
    for group in groups:
        for coin in group or []:
            normalized = str(coin).strip().upper()
            if not normalized or normalized in seen:
                continue
            seen.add(normalized)
            out.append(normalized)
    return out


def collect_coin_data(
    client: HLClient,
    cvd: CVDTracker,
    coin: str,
    cfg: Config,
    *,
    ws_cache=None,
    market_context: Optional[dict] = None,
    coinglass_client: Optional[CoinGlassClient] = None,
) -> Optional[dict]:
    """Fetch all data for one coin, return structured dict for LLM."""
    policy = _policy(cfg)
    sess = session_context(reset_hour_utc=cfg.session_reset_hour_utc)
    mid_data = client.all_mids()
    mid = float(mid_data.get(coin, 0))
    if mid == 0:
        return None

    # L2 book (2 samples for absorption detection)
    book_snaps = []
    for _ in range(cfg.book_samples):
        raw = client.l2_book(coin)
        if raw:
            book_snaps.append(parse_book(raw, mid))
        time.sleep(cfg.book_sample_interval)

    if not book_snaps:
        return None
    book = book_snaps[-1]

    # Recent trades
    raw_trades = client.trades(coin)
    ticks = parse_tape(raw_trades) if raw_trades else []

    # Candles: 1m/5m for stop-noise validation, 15m primary, 1h profile, 4h regime
    now_ms = int(time.time() * 1000)
    raw_1m = client.candles(coin, "1m", now_ms - min(cfg.candle_hours, 6) * 3600_000)
    candles_1m = [Candle.from_hl(c) for c in raw_1m] if raw_1m else []

    raw_5m = client.candles(coin, "5m", now_ms - min(cfg.candle_hours, 12) * 3600_000)
    candles_5m = [Candle.from_hl(c) for c in raw_5m] if raw_5m else []

    raw_15m = client.candles(coin, "15m", now_ms - cfg.candle_hours * 3600_000)
    candles_15m = [Candle.from_hl(c) for c in raw_15m] if raw_15m else []

    raw_1h = client.candles(coin, "1h", now_ms - cfg.candle_hours * 3600_000 * 4)
    candles_1h = [Candle.from_hl(c) for c in raw_1h] if raw_1h else []
    candles_1h_session = filter_session_candles(candles_1h, sess)

    raw_4h = client.candles(coin, "4h", now_ms - cfg.candle_hours * 3600_000 * 12)
    candles_4h = [Candle.from_hl(c) for c in raw_4h] if raw_4h else []

    # Asset context (funding, OI, volume)
    ctx = client.asset_context(coin)

    # Build orderflow snapshot
    of = build_orderflow_snapshot(
        book=book, tape_ticks=ticks,
        candles=candles_15m, candles_1h=candles_1h_session, candles_4h=candles_4h,
        cvd_tracker=cvd, coin=coin,
        asset_ctx=ctx, book_snapshots=book_snaps,
    )

    # Regime
    regime = classify_regime(candles_15m, candles_1h, candles_4h)

    # Candle summary for LLM (last 10 x 15m)
    candle_summary = _summarize_candles(candles_15m[-10:])

    ws_summary = None
    if ws_cache is not None:
        try:
            ws_summary = ws_cache.summary(coin)
        except Exception as exc:
            ws_summary = {"available": False, "last_error": str(exc)}

    trigger_features = build_trigger_features(
        of=of,
        regime=regime,
        candles_15m=candles_15m,
        candles_1h=candles_1h,
        ws_summary=ws_summary or {},
        policy=policy,
    )
    noise_state = build_noise_state(
        of=of,
        candles_1m=candles_1m,
        candles_5m=candles_5m,
        stop_atr_multiplier=cfg.stop_atr_multiplier,
    )
    coinglass_context = {}
    if coinglass_client is not None:
        try:
            coinglass_context = coinglass_client.context(coin, mid)
        except Exception as exc:
            coinglass_context = {
                "enabled": True,
                "status": "error",
                "error": f"{exc.__class__.__name__}: {str(exc)[:180]}",
            }

    return {
        "coin": coin,
        "mid": mid,
        "of": of,
        "regime": regime,
        "candles_summary": candle_summary,
        "candles_1m": candles_1m,
        "candles_5m": candles_5m,
        "candles_15m": candles_15m,
        "candles_1h": candles_1h,
        "candles_4h": candles_4h,
        "noise_state": noise_state,
        "ws_summary": ws_summary,
        "market_context": market_context or {},
        "coinglass": coinglass_context,
        "session": sess,
        "levels": trigger_features["levels"],
        "trigger_state": trigger_features["trigger_state"],
        "trigger_meta": trigger_features["trigger_meta"],
        "crowding_state": trigger_features["crowding_state"],
        "alignment": {
            "long": alignment_summary(of, "long", policy),
            "short": alignment_summary(of, "short", policy),
        },
    }


def _summarize_candles(candles: list[Candle]) -> str:
    """Compact candle summary for LLM context."""
    if not candles:
        return "no data"
    parts = []
    for c in candles:
        direction = "▲" if c.close > c.open else "▼"
        body_pct = abs(c.close - c.open) / c.open * 100 if c.open else 0
        range_pct = c.range / c.open * 100 if c.open else 0
        parts.append(f"{direction}{body_pct:.1f}%/r{range_pct:.1f}%")
    return " ".join(parts)


def _market_context_map(client: HLClient) -> dict[str, dict]:
    """Build read-only market context for funding dislocations and OI-cap flags."""
    context: dict[str, dict] = {}
    try:
        caps = client.perps_at_open_interest_cap()
    except Exception as exc:
        log.warning("perpsAtOpenInterestCap unavailable: %s", exc)
        caps = set()
    try:
        predicted = client.predicted_fundings()
    except Exception as exc:
        log.warning("predictedFundings unavailable: %s", exc)
        predicted = []

    for coin in caps:
        context.setdefault(coin, {})["at_oi_cap"] = True

    for row in predicted:
        if not (isinstance(row, list) and len(row) >= 2 and isinstance(row[1], list)):
            continue
        coin = str(row[0])
        venues = {str(name): info for name, info in row[1] if info}
        hl = venues.get("HlPerp")
        if not isinstance(hl, dict):
            continue
        hl_rate = float(hl.get("fundingRate") or 0.0)
        cex_rates = []
        for venue in ("BinPerp", "BybitPerp"):
            info = venues.get(venue)
            if isinstance(info, dict) and info.get("fundingRate") is not None:
                cex_rates.append(float(info.get("fundingRate") or 0.0))
        cex_avg = sum(cex_rates) / len(cex_rates) if cex_rates else 0.0
        context.setdefault(coin, {}).update({
            "at_oi_cap": coin in caps,
            "predicted_hl_funding": hl_rate,
            "predicted_cex_funding_avg": cex_avg,
            "funding_dislocation": abs(hl_rate - cex_avg),
        })

    return context


def _coinglass_client(cfg: Config) -> Optional[CoinGlassClient]:
    if not bool(getattr(cfg, "coinglass_enabled", False)):
        return None
    import os
    client = CoinGlassClient(
        CoinGlassConfig(
            enabled=bool(cfg.coinglass_enabled),
            base_url=str(cfg.coinglass_base_url),
            exchange=str(cfg.coinglass_exchange),
            heatmap_range=str(cfg.coinglass_range),
            cache_seconds=int(cfg.coinglass_cache_seconds),
            timeout=float(cfg.coinglass_timeout),
            prefer_pair_heatmap=bool(cfg.coinglass_prefer_pair_heatmap),
            include_coin_liquidations=bool(cfg.coinglass_include_coin_liquidations),
            include_funding_snapshot=bool(cfg.coinglass_include_funding_snapshot),
            include_long_short_ratio=bool(cfg.coinglass_include_long_short_ratio),
        ),
        api_key=os.environ.get("COINGLASS_API_KEY", ""),
    )
    return client


def _fee_adjusted_break_even(entry: float, side: str, cfg: Config) -> float:
    """Move stop to break-even after expected entry+exit fees."""
    fee_cover_rate = (float(cfg.fee_bps) * 2.0) / 10_000.0
    if side == "long":
        return entry * (1.0 + fee_cover_rate)
    return entry * (1.0 - fee_cover_rate)


def _rr_at_entry(side: str, entry: float, stop: float, target: float) -> float:
    risk = abs(entry - stop)
    if risk <= 0:
        return 0.0
    return abs(target - entry) / risk


def _ioc_entry_limit(plan, side: str, spread_bps: float, cfg: Config) -> float:
    """Use a marketable IOC limit without breaking configured min R:R."""
    entry = float(plan.entry)
    stop = float(plan.stop)
    target = float(plan.target)
    side = side.lower()
    buffer_bps = max(float(cfg.ioc_slippage_bps), float(spread_bps) * float(cfg.ioc_slippage_spread_mult))
    buffer_bps = min(buffer_bps, float(cfg.ioc_slippage_max_bps))
    if buffer_bps <= 0:
        return entry

    if side == "long":
        desired = entry * (1.0 + buffer_bps / 10_000.0)
        max_entry = (target + float(cfg.min_rr) * stop) / (1.0 + float(cfg.min_rr))
        if max_entry <= entry:
            return entry
        return min(desired, max_entry)

    desired = entry * (1.0 - buffer_bps / 10_000.0)
    min_entry = (target + float(cfg.min_rr) * stop) / (1.0 + float(cfg.min_rr))
    if min_entry >= entry:
        return entry
    return max(desired, min_entry)


def _execution_entry_guard(
    plan,
    side: str,
    coin_cd: dict,
    coin_of: OrderflowSnapshot,
    cfg: Config,
    *,
    fill_entry: float | None = None,
) -> tuple[bool, str, dict]:
    """Validate levels against the likely live fill zone, not only LLM plan entry.

    A marketable IOC can fill away from the model's plan level. If that fill
    makes the stop sit inside current noise or destroys R:R, reject before
    submitting the order.
    """
    side = side.lower()
    plan_entry = float(plan.entry)
    current_mid = float(fill_entry or getattr(coin_of, "mid", 0.0) or plan_entry)
    stop = float(plan.stop)
    target = float(plan.target)
    noise = coin_cd.get("noise_state") or {}
    floor = float(noise.get("dynamic_stop_floor") or 0.0)
    atr = float(noise.get("atr14") or getattr(coin_of, "atr_14", 0.0) or 0.0)
    required_stop = max(
        floor * float(cfg.min_stop_noise_multiple) if floor > 0 else 0.0,
        atr * float(cfg.stop_atr_multiplier) if atr > 0 else 0.0,
    )

    if fill_entry is not None:
        noise_entry = current_mid
        rr_entry = current_mid
    elif side == "long":
        noise_entry = min(plan_entry, current_mid)
        rr_entry = max(plan_entry, current_mid)
    else:
        noise_entry = max(plan_entry, current_mid)
        rr_entry = min(plan_entry, current_mid)

    if side == "long":
        directional_ok = stop < noise_entry and stop < rr_entry < target
    else:
        directional_ok = target < rr_entry and target < noise_entry < stop

    stop_distance = abs(noise_entry - stop)
    rr_risk = abs(rr_entry - stop)
    rr_reward = abs(target - rr_entry)
    rr = rr_reward / rr_risk if rr_risk > 0 else 0.0
    drift_bps = abs(current_mid - plan_entry) / plan_entry * 10_000.0 if plan_entry > 0 else 0.0
    metrics = {
        "plan_entry": plan_entry,
        "current_mid": current_mid,
        "noise_entry": noise_entry,
        "rr_entry": rr_entry,
        "entry_drift_bps": drift_bps,
        "stop_distance_at_noise_entry": stop_distance,
        "required_stop_distance": required_stop,
        "rr_at_execution_zone": rr,
        "stop": stop,
        "target": target,
    }
    if not directional_ok:
        return False, "execution entry guard: current fill zone breaks directional stop/target order", metrics
    if required_stop > 0 and stop_distance < required_stop:
        return (
            False,
            f"execution entry guard: stop would be inside live noise after fill "
            f"({stop_distance:.8g} < required {required_stop:.8g}, drift={drift_bps:.1f}bps)",
            metrics,
        )
    if rr < float(cfg.min_rr):
        return (
            False,
            f"execution entry guard: R:R at live fill zone {rr:.2f} < {float(cfg.min_rr):.2f} "
            f"(drift={drift_bps:.1f}bps)",
            metrics,
        )
    return True, "", metrics


def _hydrate_live_positions(executor, cfg: Config) -> dict[str, dict]:
    """Rebuild local live manager state from Hyperliquid positions/orders."""
    positions: dict[str, dict] = {}
    try:
        open_orders = executor.frontend_open_orders()
    except Exception as exc:
        log.warning("frontendOpenOrders unavailable, falling back to openOrders: %s", exc)
        open_orders = executor.open_orders()

    for raw_pos in executor.open_positions():
        coin = raw_pos.get("coin")
        if not coin:
            continue
        szi = float(raw_pos.get("szi") or 0.0)
        if szi == 0.0:
            continue

        side = "long" if szi > 0 else "short"
        entry = float(raw_pos.get("entryPx") or 0.0)
        if entry <= 0.0:
            continue

        leverage = int(raw_pos.get("leverage", {}).get("value") or cfg.max_leverage)
        position_value = float(raw_pos.get("positionValue") or 0.0)
        margin_used = float(raw_pos.get("marginUsed") or 0.0)
        margin = margin_used or (position_value / max(float(leverage), 1.0))
        qty = abs(szi)

        side_orders = [
            order for order in open_orders
            if order.get("coin") == coin and order.get("reduceOnly")
        ]
        stop_order = None
        target_order = None
        for order in side_orders:
            try:
                px = float(order.get("triggerPx") or order.get("limitPx") or 0.0)
            except (TypeError, ValueError):
                continue
            order_type = str(order.get("orderType") or "").lower()
            if "stop" in order_type:
                if side == "long":
                    if stop_order is None or px > float(stop_order.get("triggerPx") or stop_order.get("limitPx") or 0.0):
                        stop_order = order
                else:
                    if stop_order is None or px < float(stop_order.get("triggerPx") or stop_order.get("limitPx") or 1e100):
                        stop_order = order
                continue
            if "take profit" in order_type or "take_profit" in order_type:
                if side == "long":
                    if target_order is None or px < float(target_order.get("triggerPx") or target_order.get("limitPx") or 1e100):
                        target_order = order
                else:
                    if target_order is None or px > float(target_order.get("triggerPx") or target_order.get("limitPx") or 0.0):
                        target_order = order
                continue

            # Fallback for plain openOrders, which lacks trigger metadata.
            if side == "long":
                if px < entry and (stop_order is None or px > float(stop_order.get("limitPx") or 0.0)):
                    stop_order = order
                elif px > entry and (target_order is None or px < float(target_order.get("limitPx") or 1e100)):
                    target_order = order
            else:
                if px > entry and (stop_order is None or px < float(stop_order.get("limitPx") or 1e100)):
                    stop_order = order
                elif px < entry and (target_order is None or px > float(target_order.get("limitPx") or 0.0)):
                    target_order = order

        stop = float(stop_order.get("triggerPx") or stop_order.get("limitPx")) if stop_order else entry
        target = float(target_order.get("triggerPx") or target_order.get("limitPx")) if target_order else entry
        fee_be = _fee_adjusted_break_even(entry, side, cfg)
        be_moved = stop >= fee_be if side == "long" else stop <= fee_be

        positions[coin] = {
            "side": side,
            "entry": entry,
            "stop": stop,
            "original_stop": stop,
            "target": target,
            "sz": qty,
            "margin": margin,
            "leverage": leverage,
            "be_moved": be_moved,
            "profit_locked": False,
            "mfe_max": 0,
            "cloid": None,
            "tp_cloid": target_order.get("cloid") if target_order else None,
            "sl_cloid": stop_order.get("cloid") if stop_order else None,
            "opened_at": time.time(),
            "hydrated": True,
        }
        log.info(
            "  HYDRATE %s %s entry=%.6g SL=%.6g TP=%.6g qty=%.8g",
            coin, side, entry, stop, target, qty,
        )

    return positions


def _local_day_start_ms() -> int:
    now = time.localtime()
    return int(time.mktime((now.tm_year, now.tm_mon, now.tm_mday, 0, 0, 0, now.tm_wday, now.tm_yday, now.tm_isdst)) * 1000)


def _hydrate_daily_risk_state(executor, journal: Journal, risk: RiskManager) -> None:
    """Rebuild in-memory daily counters after a service restart."""
    day_start = _local_day_start_ms()
    entries = [e for e in journal.load_all() if int(e.get("ts", 0)) >= day_start]
    submit_count = sum(1 for e in entries if e.get("event") == "submit")
    realized_pnl = 0.0
    loss_count = 0

    try:
        fills = executor.user_fills()
    except Exception as exc:
        log.warning("Could not hydrate daily loss count from userFills: %s", exc)
        fills = []

    for fill in fills:
        if int(fill.get("time") or 0) < day_start:
            continue
        direction = str(fill.get("dir") or "").lower()
        pnl = float(fill.get("closedPnl") or 0.0)
        if "close" not in direction and pnl == 0.0:
            continue
        realized_pnl += pnl
        if pnl < 0:
            loss_count += 1

    risk.daily_trades = max(risk.daily_trades, submit_count)
    risk.daily_losses = max(risk.daily_losses, loss_count)
    risk.daily_pnl = realized_pnl
    log.info(
        "Daily risk state hydrated: trades=%s/%s losses=%s/%s realized_pnl=$%+.2f",
        risk.daily_trades,
        risk.max_daily_trades,
        risk.daily_losses,
        risk.max_daily_losses,
        risk.daily_pnl,
    )


def _journal_exchange_close(journal: Journal, executor, coin: str, pos: dict, risk: RiskManager | None = None) -> dict:
    """Persist the real exchange close using Hyperliquid fills before dropping local state."""
    side = str(pos.get("side") or "").lower()
    opened_at = float(pos.get("opened_at") or time.time())
    since_ms = int(max(0, opened_at - 120) * 1000)
    fills = []
    try:
        raw_fills = executor.user_fills()
    except Exception as exc:
        log.warning("Could not fetch userFills for %s close sync: %s", coin, exc)
        raw_fills = []

    for fill in raw_fills:
        if str(fill.get("coin") or "").upper() != coin.upper():
            continue
        if int(fill.get("time") or 0) < since_ms:
            continue
        direction = str(fill.get("dir") or "").lower()
        pnl = _safe_float(fill.get("closedPnl"))
        if side == "long" and "close long" in direction:
            fills.append(fill)
        elif side == "short" and "close short" in direction:
            fills.append(fill)
        elif pnl != 0.0:
            fills.append(fill)

    if not fills:
        exit_source = "unknown_exchange"
        journal.log(JournalEntry(
            ts=int(time.time() * 1000),
            event="close",
            coin=coin,
            side=side,
            entry=float(pos.get("entry") or 0.0),
            stop=float(pos.get("stop") or 0.0),
            target=float(pos.get("target") or 0.0),
            setup_type=str(pos.get("setup_type") or ""),
            evidence=list(pos.get("evidence") or []),
            pnl_usd=0.0,
            mfe_pct=float(pos.get("mfe_max") or 0.0),
            exit_reason=exit_source,
            extra={"exit_source": exit_source, "sync_warning": "position missing but no matching close fill found"},
        ))
        return {"net_pnl_usd": 0.0, "fills": 0}

    qty_total = sum(abs(_safe_float(fill.get("sz"))) for fill in fills)
    exit_notional = sum(abs(_safe_float(fill.get("sz"))) * _safe_float(fill.get("px")) for fill in fills)
    exit_px = exit_notional / qty_total if qty_total > 0 else 0.0
    closed_pnl = sum(_safe_float(fill.get("closedPnl")) for fill in fills)
    fees = sum(abs(_safe_float(fill.get("fee"))) for fill in fills)
    net_pnl = closed_pnl - fees
    last_ts = max(int(fill.get("time") or 0) for fill in fills)
    exit_source = _classify_exit_source(pos, exit_px)
    exit_reason = exit_source

    journal.log(JournalEntry(
        ts=last_ts or int(time.time() * 1000),
        event="close",
        coin=coin,
        side=side,
        entry=float(pos.get("entry") or 0.0),
        stop=float(pos.get("stop") or 0.0),
        target=float(pos.get("target") or 0.0),
        setup_type=str(pos.get("setup_type") or ""),
        evidence=list(pos.get("evidence") or []),
        pnl_usd=net_pnl,
        mfe_pct=float(pos.get("mfe_max") or 0.0),
        exit_reason=exit_reason,
        extra={
            "exit_source": exit_source,
            "closed_pnl_usd": closed_pnl,
            "fees_usd": fees,
            "net_pnl_usd": net_pnl,
            "bot_pnl_usd": net_pnl if str(exit_source).startswith("bot_") else 0.0,
            "manual_or_unknown_pnl_usd": 0.0 if str(exit_source).startswith("bot_") else net_pnl,
            "exit_price": exit_px,
            "fill_count": len(fills),
            "entry_cloid": pos.get("entry_cloid"),
            "tp_cloid": pos.get("tp_cloid"),
            "sl_cloid": pos.get("sl_cloid"),
        },
    ))
    if risk is not None:
        risk.daily_pnl += net_pnl
        if net_pnl < 0:
            risk.daily_losses += 1
    return {"net_pnl_usd": net_pnl, "fills": len(fills), "exit_price": exit_px}


def _classify_exit_source(pos: dict, exit_px: float) -> str:
    """Best-effort close attribution from final fill price vs managed levels."""
    if exit_px <= 0:
        return "unknown_exchange"
    side = str(pos.get("side") or "").lower()
    stop = _safe_float(pos.get("stop"))
    target = _safe_float(pos.get("target"))
    entry = _safe_float(pos.get("entry"))
    tolerance = max(abs(entry) * 0.0005, abs(entry - stop) * 0.10, 1e-8)
    if target > 0 and abs(exit_px - target) <= tolerance:
        return "bot_tp"
    if stop > 0 and abs(exit_px - stop) <= tolerance:
        if pos.get("be_moved") and entry > 0 and abs(stop - entry) <= tolerance:
            return "bot_be"
        return "bot_sl"
    if side == "long":
        if target > 0 and exit_px >= target - tolerance:
            return "bot_tp"
        if stop > 0 and exit_px <= stop + tolerance:
            return "bot_sl"
    elif side == "short":
        if target > 0 and exit_px <= target + tolerance:
            return "bot_tp"
        if stop > 0 and exit_px >= stop - tolerance:
            return "bot_sl"
    return "manual_or_unknown"


def _loss_cooldown_reason(journal: Journal, decision: LLMTradeDecision, cfg: Config, executor=None) -> str:
    """Reject repeat attempts after a fresh realized loss on the same coin."""
    coin = str(decision.coin or "").upper()
    side = str(decision.side or "").lower()
    now_ms = int(time.time() * 1000)
    loss_cutoff = now_ms - int(max(0, cfg.loss_cooldown_minutes) * 60_000)
    flip_cutoff = now_ms - int(max(0, cfg.flip_cooldown_minutes) * 60_000)

    for row in reversed(journal.load_all()):
        if row.get("event") != "close":
            continue
        if str(row.get("coin") or "").upper() != coin:
            continue
        pnl = _safe_float(row.get("pnl_usd") if row.get("pnl_usd") is not None else row.get("net_pnl_usd"))
        if pnl >= 0:
            continue
        row_ts = int(row.get("ts") or 0)
        row_side = str(row.get("side") or "").lower()
        age_min = max(0.0, (now_ms - row_ts) / 60_000.0)
        if row_side == side and row_ts >= loss_cutoff:
            return (
                f"cooldown: {coin} {side} lost ${pnl:.2f} {age_min:.0f}m ago; "
                f"wait {cfg.loss_cooldown_minutes}m before same coin/side"
            )
        if row_side and row_side != side and row_ts >= flip_cutoff:
            return (
                f"flip cooldown: {coin} {row_side} lost ${pnl:.2f} {age_min:.0f}m ago; "
                f"block immediate {side} flip for {cfg.flip_cooldown_minutes}m"
            )
    if executor is not None:
        try:
            fills = executor.user_fills()
        except Exception as exc:
            log.warning("Could not check exchange fills for cooldown: %s", exc)
            fills = []
        for fill in reversed(fills):
            if str(fill.get("coin") or "").upper() != coin:
                continue
            row_ts = int(fill.get("time") or 0)
            if row_ts < min(loss_cutoff, flip_cutoff):
                continue
            pnl = _safe_float(fill.get("closedPnl"))
            if pnl >= 0:
                continue
            direction = str(fill.get("dir") or "").lower()
            row_side = ""
            if "close long" in direction:
                row_side = "long"
            elif "close short" in direction:
                row_side = "short"
            if not row_side:
                continue
            age_min = max(0.0, (now_ms - row_ts) / 60_000.0)
            if row_side == side and row_ts >= loss_cutoff:
                return (
                    f"cooldown: exchange fill shows {coin} {side} lost ${pnl:.2f} {age_min:.0f}m ago; "
                    f"wait {cfg.loss_cooldown_minutes}m before same coin/side"
                )
            if row_side != side and row_ts >= flip_cutoff:
                return (
                    f"flip cooldown: exchange fill shows {coin} {row_side} lost ${pnl:.2f} {age_min:.0f}m ago; "
                    f"block immediate {side} flip for {cfg.flip_cooldown_minutes}m"
                )
    return ""


def _verify_protective_orders(executor, coin: str, tp_cloid: str | None, sl_cloid: str | None) -> dict:
    try:
        return executor.protective_order_state(coin, tp_cloid=tp_cloid, sl_cloid=sl_cloid)
    except Exception as exc:
        return {"ok": False, "error": str(exc), "has_target": False, "has_stop": False}


def _safe_float(value) -> float:
    try:
        return float(value or 0.0)
    except (TypeError, ValueError):
        return 0.0


def _decision_context_summary(coin_data: list[dict]) -> list[dict]:
    """Compact market context stored with each LLM decision for later tuning."""
    rows = []
    for cd in coin_data:
        of = cd["of"]
        regime = cd["regime"]
        ws = cd.get("ws_summary") or {}
        rows.append({
            "coin": cd.get("coin"),
            "mid": of.mid,
            "spread_bps": of.spread_bps,
            "book_imbalance": of.book_imbalance,
            "tape_delta_pct": of.tape_delta_pct,
            "tape_delta_usd": of.tape_delta_usd,
            "cvd": of.cvd,
            "cvd_divergence": of.cvd_divergence,
            "bid_absorption": of.bid_absorption,
            "ask_absorption": of.ask_absorption,
            "rsi_14": of.rsi_14,
            "macd_histogram": of.macd_histogram,
            "atr_14": of.atr_14,
            "price_vs_vwap": of.price_vs_vwap,
            "vpoc": of.vpoc,
            "vah": of.vah,
            "val": of.val,
            "funding_rate": of.funding_rate,
            "open_interest": of.open_interest,
            "regime": {
                "overall": regime.overall,
                "trend_15m": regime.trend_15m,
                "trend_1h": regime.trend_1h,
                "trend_4h": regime.trend_4h,
                "vol_regime": regime.vol_regime,
            },
            "levels": cd.get("levels", {}),
            "trigger_state": cd.get("trigger_state", {}),
            "trigger_meta": cd.get("trigger_meta", {}),
            "crowding_state": cd.get("crowding_state", {}),
            "noise_state": cd.get("noise_state", {}),
            "alignment": cd.get("alignment", {}),
            "session": cd.get("session", {}),
            "ws": {
                "available": ws.get("available"),
                "stale_s": ws.get("stale_s"),
                "book_stale_s": ws.get("book_stale_s"),
                "tape_stale_s": ws.get("tape_stale_s"),
                "ctx_stale_s": ws.get("ctx_stale_s"),
                "book_available": ws.get("book_available"),
                "tape_available": ws.get("tape_available"),
                "book_velocity": ws.get("book_velocity"),
                "delta_ratio_30s": ws.get("delta_ratio_30s"),
                "delta_usd_30s": ws.get("delta_usd_30s"),
                "total_usd_120s": ws.get("total_usd_120s"),
                "tape_windows": ws.get("tape_windows"),
                "cvd_state": ws.get("cvd_state"),
                "cvd_divergence_2m": ws.get("cvd_divergence_2m"),
                "cvd_divergence_5m": ws.get("cvd_divergence_5m"),
                "cvd_5m": ws.get("cvd_5m"),
                "tape_state": ws.get("tape_state"),
                "large_buy_prints_2m": ws.get("large_buy_prints_2m"),
                "large_sell_prints_2m": ws.get("large_sell_prints_2m"),
                "oi_delta_60s": ws.get("oi_delta_60s"),
                "oi_delta_5m": ws.get("oi_delta_5m"),
                "oi_delta_15m": ws.get("oi_delta_15m"),
                "funding_delta_5m": ws.get("funding_delta_5m"),
                "premium_delta_5m": ws.get("premium_delta_5m"),
            },
            "candles_summary": cd.get("candles_summary", ""),
        })
    return rows


def _coin_context_summary(coin_cd: dict | None) -> dict:
    if not coin_cd:
        return {}
    rows = _decision_context_summary([coin_cd])
    return rows[0] if rows else {}


def _log_llm_decision(
    journal: Journal,
    decision: LLMTradeDecision,
    coin_data: list[dict],
    policy: TradingPolicy | None = None,
) -> None:
    journal.log(JournalEntry(
        ts=int(time.time() * 1000),
        event="llm_decision",
        coin=decision.coin,
        side=decision.side,
        entry=decision.entry_price,
        stop=decision.stop_price,
        target=decision.target_price,
        confidence=decision.confidence,
        setup_type=decision.setup_type,
        evidence=decision.evidence,
        reason=decision.reason,
        extra={
            "action": decision.action,
            "regime_note": decision.regime_note,
            "risk_assessment": decision.risk_assessment,
            "analysis_steps": decision.analysis_steps,
            "playbook_match": decision.playbook_match,
            "trade_plan": decision.trade_plan,
            "level_reasoning": decision.level_reasoning,
            "exit_plan": decision.exit_plan,
            "alignment_magnitude": decision.alignment_magnitude,
            "analyst_report": decision.analyst_report,
            "trader_proposal": decision.trader_proposal,
            "risk_portfolio_decision": decision.risk_portfolio_decision,
            "side_arbitration": decision.side_arbitration,
            "best_candidate": decision.best_candidate,
            "raw_response": decision.raw_response,
            "strategy_policy": policy.as_dict() if policy else {},
            "market_summary": _decision_context_summary(coin_data),
        },
    ))


# === Commands ===

def cmd_scan(args, cfg: Config):
    """One-shot scan — show orderflow data for all coins."""
    client = HLClient()
    cvd = _cvd_tracker(cfg)
    coins = args.coins.split(",") if args.coins else get_universe(client, cfg)
    log.info(f"Scanning {len(coins)} coins: {', '.join(coins)}")

    print(f"\n{'COIN':6} | {'PRICE':>12} | {'STANCE':>12} | {'OMNI':>6} | {'RSI':>5} | {'IMBAL':>6} | {'TAPE':>6} | {'BID_ABS':>7} | {'ASK_ABS':>7} | {'CVD_DIV':>7} | {'REGIME'}")
    print("=" * 120)

    for coin in coins:
        cd = collect_coin_data(client, cvd, coin, cfg)
        if not cd:
            print(f"{coin:6} | NO DATA")
            continue

        of = cd["of"]
        regime = cd["regime"]
        print(
            f"{coin:6} | {cd['mid']:>12.6g} | {of.stance:>12} | {of.omni_score:>+5.2f} | "
            f"{of.rsi_14:>5.1f} | {of.book_imbalance:>+5.2f} | {of.tape_delta_pct:>+5.2f} | "
            f"{of.bid_absorption:>7.2f} | {of.ask_absorption:>7.2f} | "
            f"{'YES' if of.cvd_divergence else 'no':>7} | {regime.overall}"
        )
        time.sleep(1.5)


def cmd_think(args, cfg: Config):
    """LLM decides — calls DeepSeek with full orderflow context."""
    client = HLClient()
    cvd = _cvd_tracker(cfg)
    policy = _policy(cfg)
    coinglass_client = _coinglass_client(cfg)
    if coinglass_client:
        log.info(
            "CoinGlass liquidation feed: %s range=%s exchange=%s",
            "enabled" if coinglass_client.available else "configured but missing COINGLASS_API_KEY",
            cfg.coinglass_range,
            cfg.coinglass_exchange,
        )

    api_key = getattr(args, 'api_key', '') or ''
    if not api_key:
        import os
        api_key = os.environ.get("DEEPSEEK_API_KEY", "")

    if not api_key:
        print("ERROR: No API key. Set DEEPSEEK_API_KEY env var or use --api-key")
        print("Falling back to simulated plumbing brain (not strategy-valid)...")
        brain = SimulatedLLMBrain(policy=policy)
    else:
        brain = LLMBrain(
            api_key=api_key,
            base_url=cfg.llm_base_url,
            model=cfg.llm_model,
            temperature=cfg.llm_temperature,
            timeout=cfg.llm_timeout,
            thinking=cfg.llm_thinking,
            equity=cfg.equity_usd,
            margin=cfg.fixed_margin_usd,
            leverage=cfg.max_leverage,
            policy=policy,
            ohlc_lessons_path=cfg.ohlc_lessons_path,
        )

    coins = args.coins.split(",") if args.coins else get_universe(client, cfg)
    log.info(f"Collecting data for {len(coins)} coins...")

    coin_data = []
    for coin in coins:
        cd = collect_coin_data(client, cvd, coin, cfg, coinglass_client=coinglass_client)
        if cd:
            coin_data.append(cd)
            log.info(f"  {coin}: {cd['of'].stance} (OMNI={cd['of'].omni_score:+.2f})")
        time.sleep(1.5)

    if not coin_data:
        print("No coin data collected.")
        return

    log.info(f"Asking brain to decide across {len(coin_data)} coins...")
    decision = brain.decide(coin_data)

    print(f"\n{'='*80}")
    print(f"BRAIN DECISION: {decision.action.upper()}")
    print(f"{'='*80}")

    if decision.action == "enter":
        print(f"  Coin:       {decision.coin}")
        print(f"  Side:       {decision.side}")
        print(f"  Confidence: {decision.confidence:.0%}")
        print(f"  Entry:      {decision.entry_price}")
        print(f"  Stop:       {decision.stop_price}")
        print(f"  Target:     {decision.target_price}")
        risk = abs(decision.entry_price - decision.stop_price)
        reward = abs(decision.target_price - decision.entry_price)
        rr = reward / risk if risk else 0
        print(f"  R:R:        {rr:.2f}")
        print(f"  Setup:      {decision.setup_type}")
        print(f"  Reason:     {decision.reason}")
        print(f"  Regime:     {decision.regime_note}")
        print(f"  Risk:       {decision.risk_assessment}")
        print(f"  Evidence:")
        for e in decision.evidence:
            print(f"    - {e}")
    else:
        print(f"  Reason:  {decision.reason}")
        if decision.best_candidate:
            bc = decision.best_candidate
            print(f"  Best was: {bc.get('coin','?')} {bc.get('side','?')} ({bc.get('alignment','?')}) — blocker: {bc.get('blocker','?')}")


def cmd_paper(args, cfg: Config):
    """Paper trading loop — uses real LLM if API key available, else simulated.
    Features: per-coin exposure limit, break-even manager, position tracking."""
    import os
    client = HLClient()
    cvd = _cvd_tracker(cfg)
    policy = _policy(cfg)
    coinglass_client = _coinglass_client(cfg)
    journal = Journal(cfg.journal_path)
    risk = RiskManager(
        equity_usd=cfg.equity_usd,
        max_risk_pct=cfg.max_risk_pct,
        min_rr=cfg.min_rr,
        max_daily_losses=cfg.max_daily_losses,
        max_daily_trades=cfg.max_daily_trades,
        min_confidence=cfg.min_confidence,
        spread_max_bps=cfg.spread_max_bps,
        fixed_margin_usd=cfg.fixed_margin_usd,
        stop_atr_multiplier=cfg.stop_atr_multiplier,
    )

    # Use real LLM if API key available
    api_key = os.environ.get("DEEPSEEK_API_KEY", "")
    if api_key:
        brain = LLMBrain(
            api_key=api_key,
            base_url=cfg.llm_base_url,
            model=cfg.llm_model,
            temperature=cfg.llm_temperature,
            timeout=cfg.llm_timeout,
            thinking=cfg.llm_thinking,
            equity=cfg.equity_usd,
            margin=cfg.fixed_margin_usd,
            leverage=cfg.max_leverage,
            policy=policy,
            ohlc_lessons_path=cfg.ohlc_lessons_path,
        )
        brain_type = f"real LLM (DeepSeek {cfg.llm_model})"
    else:
        brain = SimulatedLLMBrain(min_alignment=cfg.min_alignment_score, min_confidence=cfg.min_confidence, policy=policy)
        brain_type = "simulated plumbing only"

    hours = args.hours if hasattr(args, 'hours') else 1
    end_time = time.time() + hours * 3600
    iteration = 0

    # === Position Tracking ===
    # coin -> {side, entry, stop, original_stop, target, qty, margin, leverage, be_moved}
    open_positions: dict[str, dict] = {}
    max_open = cfg.max_open_positions  # from config (default 2)

    log.info(f"Paper trading ({brain_type}) — {hours}h, interval {cfg.interval_seconds}s")
    log.info(f"Max open positions: {max_open}, Break-Even trigger: MFE > 0.5%")

    while time.time() < end_time:
        iteration += 1
        coins = get_universe(client, cfg)

        # Collect data for all coins
        coin_data = []
        mid_prices = {}
        for coin in coins:
            cd = collect_coin_data(client, cvd, coin, cfg)
            if cd:
                coin_data.append(cd)
                mid_prices[coin] = cd["mid"]
            time.sleep(1.5)

        if not coin_data:
            log.warning("No data collected, skipping iteration")
            time.sleep(cfg.interval_seconds)
            continue

        # === Break-Even Manager ===
        for coin, pos in list(open_positions.items()):
            price = mid_prices.get(coin, 0)
            if price == 0:
                continue

            # Calculate current MFE
            if pos["side"] == "long":
                mfe_pct = (price - pos["entry"]) / pos["entry"] * 100
            else:
                mfe_pct = (pos["entry"] - price) / pos["entry"] * 100

            # Close if stop hit
            if pos["side"] == "long" and price <= pos["stop"]:
                pnl = -(pos["entry"] - pos["stop"]) / pos["entry"] * pos["margin"] * pos["leverage"]
                log.info(f"  CLOSE {coin} LONG @ {price:.4g} | PnL: ${pnl:+.2f} | Stop hit")
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="close",
                    coin=coin, side="long", entry=pos["entry"],
                    stop=pos["stop"], target=pos["target"],
                    pnl_usd=pnl, exit_reason="stop",
                    mfe_pct=pos.get("mfe_max", 0), mae_pct=mfe_pct,
                ))
                risk.record_trade(pnl)
                del open_positions[coin]
                continue

            if pos["side"] == "short" and price >= pos["stop"]:
                pnl = -(pos["stop"] - pos["entry"]) / pos["entry"] * pos["margin"] * pos["leverage"]
                log.info(f"  CLOSE {coin} SHORT @ {price:.4g} | PnL: ${pnl:+.2f} | Stop hit")
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="close",
                    coin=coin, side="short", entry=pos["entry"],
                    stop=pos["stop"], target=pos["target"],
                    pnl_usd=pnl, exit_reason="stop",
                    mfe_pct=pos.get("mfe_max", 0), mae_pct=mfe_pct,
                ))
                risk.record_trade(pnl)
                del open_positions[coin]
                continue

            # Close if target hit
            if pos["side"] == "long" and price >= pos["target"]:
                pnl = (pos["target"] - pos["entry"]) / pos["entry"] * pos["margin"] * pos["leverage"]
                log.info(f"  CLOSE {coin} LONG @ {price:.4g} | PnL: ${pnl:+.2f} | Target hit")
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="close",
                    coin=coin, side="long", entry=pos["entry"],
                    stop=pos["stop"], target=pos["target"],
                    pnl_usd=pnl, exit_reason="target",
                    mfe_pct=mfe_pct, mae_pct=0,
                ))
                risk.record_trade(pnl)
                del open_positions[coin]
                continue

            if pos["side"] == "short" and price <= pos["target"]:
                pnl = (pos["entry"] - pos["target"]) / pos["entry"] * pos["margin"] * pos["leverage"]
                log.info(f"  CLOSE {coin} SHORT @ {price:.4g} | PnL: ${pnl:+.2f} | Target hit")
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="close",
                    coin=coin, side="short", entry=pos["entry"],
                    stop=pos["stop"], target=pos["target"],
                    pnl_usd=pnl, exit_reason="target",
                    mfe_pct=mfe_pct, mae_pct=0,
                ))
                risk.record_trade(pnl)
                del open_positions[coin]
                continue

            # Track MFE max
            pos["mfe_max"] = max(pos.get("mfe_max", 0), mfe_pct)

            # Break-Even: if MFE > 0.5%, move stop to entry
            if cfg.enable_break_even and not pos.get("be_moved") and mfe_pct > 0.5:
                pos["original_stop"] = pos["stop"]
                pos["stop"] = pos["entry"]
                pos["be_moved"] = True
                log.info(f"  BE {coin} {pos['side'].upper()} | MFE={mfe_pct:+.2f}% | Stop moved to entry {pos['entry']:.4g}")
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="stop_to_break_even",
                    coin=coin, side=pos["side"],
                    entry=pos["entry"], stop=pos["entry"],
                    extra={"mfe_pct": mfe_pct, "original_stop": pos["original_stop"]},
                ))

        # === New Entry Logic ===
        if len(open_positions) >= max_open:
            if iteration % 10 == 1:
                pos_str = ", ".join(f"{c}({p['side']})" for c, p in open_positions.items())
                log.info(f"  Max exposure: {len(open_positions)}/{max_open} — {pos_str}")
            time.sleep(cfg.interval_seconds)
            continue

        # Brain decides
        decision = brain.decide(coin_data)

        if decision.action == "enter":
            coin = decision.coin

            # Per-coin exposure check: skip if already have position on this coin
            if coin in open_positions:
                log.debug(f"  SKIP {coin}: already in position")
                time.sleep(cfg.interval_seconds)
                continue

            # Find the coin's orderflow snapshot for risk check
            coin_of = None
            for cd in coin_data:
                if cd["coin"] == coin:
                    coin_of = cd["of"]
                    break

            if not coin_of:
                log.warning(f"Brain chose {coin} but no data available")
                time.sleep(cfg.interval_seconds)
                continue

            # Build a minimal Setup for risk check
            from .models import Setup
            setup = Setup(
                setup_type=decision.setup_type,
                side=decision.side,
                coin=coin,
                confidence=decision.confidence,
                edge=2.0,
                evidence=decision.evidence,
                entry_ref=decision.entry_price,
                invalidation=decision.stop_price,
                target=decision.target_price,
                atr=coin_of.atr_14,
            )

            reject = risk.check(setup, coin_of)
            if reject:
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000),
                    event="risk_reject",
                    coin=coin,
                    side=decision.side,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    evidence=decision.evidence,
                    reason=reject,
                ))
                log.info(f"  REJECT {coin} {decision.side}: {reject}")
            else:
                coin_lev = min(client.max_leverage(coin, cfg.max_leverage), cfg.max_leverage)
                plan = risk.build_plan(
                    setup,
                    coin_of,
                    sz_decimals=client.sz_decimals(coin),
                    leverage=coin_lev,
                )

                # Open position tracker
                open_positions[coin] = {
                    "side": decision.side,
                    "entry": plan.entry,
                    "stop": plan.stop,
                    "original_stop": plan.stop,
                    "target": plan.target,
                    "qty": plan.qty,
                    "margin": plan.margin_usd,
                    "leverage": coin_lev,
                    "be_moved": False,
                    "mfe_max": 0,
                    "opened_at": time.time(),
                }

                journal.log(JournalEntry(
                    ts=int(time.time() * 1000),
                    event="submit",
                    coin=coin,
                    side=decision.side,
                    entry=plan.entry,
                    stop=plan.stop,
                    target=plan.target,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    evidence=decision.evidence,
                    extra={
                        "qty": plan.qty,
                        "leverage": coin_lev,
                        "margin_usd": plan.margin_usd,
                        "rr": plan.rr,
                        "mode": "paper",
                        "reason": decision.reason,
                    },
                ))
                risk.record_trade()
                log.info(
                    f"  PAPER {coin} {decision.side} @ {plan.entry:.6g} "
                    f"SL={plan.stop:.6g} TP={plan.target:.6g} "
                    f"R:R={plan.rr:.1f} x{coin_lev} [{decision.setup_type}]"
                )
                log.info(f"    Reason: {decision.reason[:100]}")
        else:
            log.info(f"  WAIT: {decision.reason[:80]}")

        # Sleep until next iteration
        next_time = time.time() + cfg.interval_seconds
        if next_time < end_time:
            remaining = next_time - time.time()
            if remaining > 0:
                time.sleep(remaining)
        else:
            break

    # Close all remaining open positions at market
    log.info(f"Paper session ended. Closing {len(open_positions)} open positions...")
    for coin, pos in list(open_positions.items()):
        price = mid_prices.get(coin, 0)
        if price == 0:
            continue
        if pos["side"] == "long":
            pnl = (price - pos["entry"]) / pos["entry"] * pos["margin"] * pos["leverage"]
        else:
            pnl = (pos["entry"] - price) / pos["entry"] * pos["margin"] * pos["leverage"]
        journal.log(JournalEntry(
            ts=int(time.time() * 1000), event="close",
            coin=coin, side=pos["side"], entry=pos["entry"],
            stop=pos["stop"], target=pos["target"],
            pnl_usd=pnl, exit_reason="session_end",
            mfe_pct=pos.get("mfe_max", 0), mae_pct=0,
        ))
        result_str = "+" if pnl >= 0 else ""
        log.info(f"  CLOSE {coin} {pos['side'].upper()} @ {price:.4g} | PnL: {result_str}${pnl:.2f}")

    summary = journal.summary(hours=hours)
    print(f"\nPaper Session Summary ({hours}h):")
    for k, v in summary.items():
        if isinstance(v, float):
            print(f"  {k:20}: {v:.2f}")
        else:
            print(f"  {k:20}: {v}")


def cmd_backtest(args, cfg: Config):
    """Backtest: replay V1 journal through V2 brain (simulated)."""
    import os
    v1_path = "/root/hyperliquid-bb-bot/data/signal-deck-live.jsonl"
    if not os.path.exists(v1_path):
        print(f"V1 journal not found: {v1_path}")
        return

    # Import here to avoid circular
    from .orderflow import compute_rsi, compute_macd, compute_atr, compute_vwap

    print("Loading V1 trades...")
    v1_trades = []
    with open(v1_path) as f:
        for line in f:
            try:
                entry = json.loads(line.strip())
                if entry.get("event") == "submit":
                    v1_trades.append(entry)
            except:
                continue
    print(f"Loaded {len(v1_trades)} V1 submitted trades")

    client = HLClient()
    brain = SimulatedLLMBrain(min_alignment=cfg.min_alignment_score, policy=_policy(cfg))

    v1_results = []
    v2_decisions = []
    fee_pct = 0.0009  # 9 bps round trip

    for i, trade in enumerate(v1_trades):
        coin = trade.get("coin", "?")
        side = trade.get("side", "?")
        entry = trade.get("entry", 0)
        stop = trade.get("stop", 0)
        target = trade.get("target", 0)
        ts_ms = trade.get("timestamp_ms", 0)
        margin = trade.get("margin_usd", 30)
        leverage = trade.get("leverage", 20)

        if not all([coin, entry, stop, target, ts_ms]):
            continue

        notional = margin * leverage

        # Fetch candles AFTER trade for V1 outcome
        now_ms = int(time.time() * 1000)
        raw_after = client.candles(coin, "5m", ts_ms, min(ts_ms + 200 * 300_000, now_ms))
        time.sleep(0.3)

        candles_after = [Candle.from_hl(c) for c in raw_after] if raw_after else []

        # Simulate V1 outcome
        v1_exit = None
        v1_mfe = 0.0
        v1_mae = 0.0
        for c in candles_after:
            if side == "long":
                v1_mfe = max(v1_mfe, (c.high - entry) / entry * 100)
                v1_mae = min(v1_mae, (c.low - entry) / entry * 100)
                if v1_exit is None and c.low <= stop:
                    v1_exit = "STOP"
                if v1_exit is None and c.high >= target:
                    v1_exit = "TP"
            else:
                v1_mfe = max(v1_mfe, (entry - c.low) / entry * 100)
                v1_mae = min(v1_mae, (entry - c.high) / entry * 100)
                if v1_exit is None and c.high >= stop:
                    v1_exit = "STOP"
                if v1_exit is None and c.low <= target:
                    v1_exit = "TP"
        if v1_exit is None:
            v1_exit = "OPEN"

        # V1 PnL
        if v1_exit == "TP":
            v1_pnl = notional * abs(target - entry) / entry - notional * fee_pct
        elif v1_exit == "STOP":
            v1_pnl = -notional * abs(entry - stop) / entry - notional * fee_pct
        else:
            v1_pnl = 0

        v1_results.append({
            "coin": coin, "side": side, "entry": entry,
            "ts": trade.get("timestamp", "?")[:16],
            "v1_exit": v1_exit, "v1_pnl": v1_pnl,
            "v1_mfe": v1_mfe, "v1_mae": v1_mae,
        })

        # Fetch candles BEFORE trade for V2 analysis
        raw_before = client.candles(coin, "15m", ts_ms - 86400_000, ts_ms)
        time.sleep(0.3)
        candles_before = [Candle.from_hl(c) for c in raw_before] if raw_before else []

        # Build synthetic orderflow snapshot for V2 brain
        if len(candles_before) >= 5:
            closes = [c.close for c in candles_before]
            rsi = compute_rsi(closes)
            macd = compute_macd(closes)
            atr = compute_atr(candles_before)
            vwap = compute_vwap(candles_before)
            mid_price = closes[-1]

            # Synthetic orderflow from candle data
            # (Real V2 would have L2 book + tape, but for backtest we approximate)
            of = OrderflowSnapshot(
                ts=ts_ms, mid=mid_price,
                spread_bps=2.0,  # assume tight spread for majors
                book_imbalance=0.15 if side == "long" else -0.15,  # V1 chose this side
                wall_skew=0.1 if side == "long" else -0.1,
                stacked_bid_count=2 if side == "long" else 0,
                stacked_ask_count=2 if side == "short" else 0,
                bid_depth_usd=500000, ask_depth_usd=500000,
                tape_delta_pct=0.2 if side == "long" else -0.2,
                tape_delta_usd=10000,
                cvd=5000 if side == "long" else -5000,
                cvd_divergence=False,
                large_prints=1, trades_per_sec=5,
                bid_absorption=0.3, ask_absorption=0.3,
                vwap=vwap,
                price_vs_vwap=(mid_price - vwap) / vwap if vwap else 0,
                rsi_14=rsi, macd_histogram=macd, atr_14=atr,
                vpoc=mid_price, vah=mid_price * 1.01, val=mid_price * 0.99,
                funding_rate=0.0, open_interest=0, premium_bps=0,
            )

            from .models import MarketRegime
            regime = MarketRegime(
                trend_15m="up" if rsi > 50 else "down",
                trend_1h="up" if rsi > 55 else "down",
                trend_4h="chop",
                vol_regime="normal",
                overall="trending" if abs(rsi - 50) > 15 else "ranging",
            )

            # Ask V2 brain: would it enter here?
            v2_decision = brain.decide([{
                "coin": coin, "of": of, "regime": regime, "candles_summary": ""
            }])

            v2_action = v2_decision.action
            v2_reason = v2_decision.reason if v2_decision.action == "wait" else ""

            # If V2 would enter on same side, compute V2 PnL using V2's levels
            if v2_action == "enter" and v2_decision.side == side:
                v2_stop = v2_decision.stop_price
                v2_target = v2_decision.target_price

                # Simulate V2 trade outcome
                v2_exit = None
                for c in candles_after:
                    if side == "long":
                        if v2_exit is None and c.low <= v2_stop:
                            v2_exit = "STOP"
                        if v2_exit is None and c.high >= v2_target:
                            v2_exit = "TP"
                    else:
                        if v2_exit is None and c.high >= v2_stop:
                            v2_exit = "STOP"
                        if v2_exit is None and c.low <= v2_target:
                            v2_exit = "TP"
                if v2_exit is None:
                    v2_exit = "OPEN"

                if v2_exit == "TP":
                    v2_pnl = notional * abs(v2_target - entry) / entry - notional * fee_pct
                elif v2_exit == "STOP":
                    v2_pnl = -notional * abs(entry - v2_stop) / entry - notional * fee_pct
                else:
                    v2_pnl = 0

                v2_decisions.append({
                    "coin": coin, "side": side, "ts": trade.get("timestamp", "?")[:16],
                    "v2_exit": v2_exit, "v2_pnl": v2_pnl,
                    "v2_reason": f"ENTERED (same side), R:R={v2_decision.risk_assessment}",
                    "v1_exit": v1_exit, "v1_pnl": v1_pnl,
                })
            else:
                # V2 would NOT trade this
                v2_decisions.append({
                    "coin": coin, "side": side, "ts": trade.get("timestamp", "?")[:16],
                    "v2_exit": "FILTERED",
                    "v2_pnl": 0,  # no trade = no loss, no fee
                    "v2_reason": v2_reason or f"V2 chose {v2_action}",
                    "v1_exit": v1_exit, "v1_pnl": v1_pnl,
                })
        else:
            v2_decisions.append({
                "coin": coin, "side": side, "ts": trade.get("timestamp", "?")[:16],
                "v2_exit": "NO_DATA", "v2_pnl": 0,
                "v2_reason": "insufficient candle data",
                "v1_exit": v1_exit, "v1_pnl": v1_pnl,
            })

        if (i + 1) % 10 == 0:
            print(f"  Processed {i + 1}/{len(v1_trades)}...")

    # === RESULTS ===
    print(f"\n{'='*100}")
    print("V1 vs V2 LLM-BRAIN BACKTEST")
    print(f"{'='*100}")

    # V1
    v1_closed = [r for r in v1_results if r["v1_exit"] in ("TP", "STOP")]
    v1_tp = [r for r in v1_results if r["v1_exit"] == "TP"]
    v1_sl = [r for r in v1_results if r["v1_exit"] == "STOP"]
    v1_pnl = sum(r["v1_pnl"] for r in v1_results)
    v1_fees = sum(30 * 20 * fee_pct for r in v1_closed)

    print(f"\nV1 (Original):")
    print(f"  Trades:    {len(v1_results)} ({len(v1_closed)} closed)")
    print(f"  Win Rate:  {len(v1_tp)}/{len(v1_closed)} = {len(v1_tp)/max(1,len(v1_closed))*100:.1f}%")
    print(f"  Gross PnL: ${v1_pnl:+.2f}")
    print(f"  Fees:      ${v1_fees:.2f}")
    print(f"  Net PnL:   ${v1_pnl - v1_fees:+.2f}")

    # V2
    v2_entered = [r for r in v2_decisions if r["v2_exit"] != "FILTERED" and r["v2_exit"] != "NO_DATA"]
    v2_filtered = [r for r in v2_decisions if r["v2_exit"] == "FILTERED"]
    v2_closed = [r for r in v2_entered if r["v2_exit"] in ("TP", "STOP")]
    v2_tp = [r for r in v2_entered if r["v2_exit"] == "TP"]
    v2_sl = [r for r in v2_entered if r["v2_exit"] == "STOP"]
    v2_pnl = sum(r["v2_pnl"] for r in v2_decisions)
    v2_fees = sum(30 * 20 * fee_pct for r in v2_closed)

    print(f"\nV2 (LLM Brain):")
    print(f"  Trades:    {len(v2_entered)} of {len(v1_results)} ({len(v2_filtered)} filtered)")
    print(f"  Win Rate:  {len(v2_tp)}/{len(v2_closed)} = {len(v2_tp)/max(1,len(v2_closed))*100:.1f}%")
    print(f"  Gross PnL: ${v2_pnl:+.2f}")
    print(f"  Fees:      ${v2_fees:.2f}")
    print(f"  Net PnL:   ${v2_pnl - v2_fees:+.2f}")

    # Savings
    saved_pnl = sum(r["v1_pnl"] for r in v2_filtered)
    saved_fees = sum(30 * 20 * fee_pct for r in v2_filtered if r["v1_exit"] in ("TP", "STOP"))
    print(f"\nV2 Filter Impact:")
    print(f"  Trades avoided: {len(v2_filtered)}")
    if saved_pnl < 0:
        print(f"  Losses avoided: ${-saved_pnl:.2f}")
    else:
        print(f"  Profit missed:  ${saved_pnl:.2f}")
    print(f"  Fees saved:     ${saved_fees:.2f}")

    # Per-coin
    print(f"\nPer-Coin:")
    print(f"  {'COIN':6} | {'V1':>5} | {'V1 PnL':>10} | {'V2':>5} | {'V2 PnL':>10} | {'Filt':>5}")
    coins = sorted(set(r["coin"] for r in v1_results))
    for coin in coins:
        v1c = [r for r in v1_results if r["coin"] == coin]
        v2c = [r for r in v2_entered if r["coin"] == coin]
        filt = [r for r in v2_filtered if r["coin"] == coin]
        print(
            f"  {coin:6} | {len(v1c):>5} | ${sum(r['v1_pnl'] for r in v1c):>+8.2f} | "
            f"{len(v2c):>5} | ${sum(r['v2_pnl'] for r in v2_decisions if r['coin']==coin):>+8.2f} | "
            f"{len(filt):>5}"
        )

    # Detail log
    print(f"\n{'='*100}")
    print("TRADE LOG")
    print(f"{'='*100}")
    print(f"  {'TIME':16} | {'COIN':5} | {'SIDE':5} | {'V1':>5} | {'V1 PnL':>8} | {'V2':>8} | {'V2 PnL':>8} | Reason")
    for v1r, v2r in zip(v1_results, v2_decisions):
        print(
            f"  {v1r['ts']} | {v1r['coin']:5} | {v1r['side']:5} | {v1r['v1_exit']:>5} | "
            f"${v1r['v1_pnl']:>+6.2f} | {v2r['v2_exit']:>8} | ${v2r['v2_pnl']:>+6.2f} | {v2r['v2_reason'][:50]}"
        )


def cmd_live(args, cfg: Config):
    """Live trading loop — real orders on Hyperliquid."""
    import os
    import uuid
    from .execution import Executor, LiveOrder
    from .ws_orderflow import LiveOrderflowCache

    private_key = os.environ.get("HYPERLIQUID_PRIVATE_KEY")
    if not private_key:
        log.error("HYPERLIQUID_PRIVATE_KEY not set in environment")
        print("ERROR: Set HYPERLIQUID_PRIVATE_KEY in .env or environment")
        return

    account_address = os.environ.get("HYPERLIQUID_ACCOUNT_ADDRESS")
    executor = Executor(private_key=private_key, account_address=account_address)

    client = HLClient()
    cvd = _cvd_tracker(cfg)
    policy = _policy(cfg)
    coinglass_client = _coinglass_client(cfg)
    journal = Journal(cfg.journal_path)
    risk = RiskManager(
        equity_usd=cfg.equity_usd,
        max_risk_pct=cfg.max_risk_pct,
        min_rr=cfg.min_rr,
        max_daily_losses=cfg.max_daily_losses,
        max_daily_trades=cfg.max_daily_trades,
        min_confidence=cfg.min_confidence,
        spread_max_bps=cfg.spread_max_bps,
        fixed_margin_usd=cfg.fixed_margin_usd,
        stop_atr_multiplier=cfg.stop_atr_multiplier,
    )

    # LLM brain
    api_key = os.environ.get("DEEPSEEK_API_KEY", "")
    if api_key:
        brain = LLMBrain(
            api_key=api_key,
            base_url=cfg.llm_base_url,
            model=cfg.llm_model,
            temperature=cfg.llm_temperature,
            timeout=cfg.llm_timeout,
            thinking=cfg.llm_thinking,
            equity=cfg.equity_usd,
            margin=cfg.fixed_margin_usd,
            leverage=cfg.max_leverage,
            policy=policy,
            ohlc_lessons_path=cfg.ohlc_lessons_path,
        )
        brain_type = f"real LLM (DeepSeek {cfg.llm_model})"
    else:
        log.error("DEEPSEEK_API_KEY required for live trading")
        return

    hours = args.hours if hasattr(args, 'hours') else 1
    end_time = time.time() + hours * 3600
    iteration = 0

    # Position tracking
    open_positions: dict[str, dict] = {}
    mid_prices: dict[str, float] = {}
    max_open = cfg.max_open_positions

    # Verify we're live
    if not os.environ.get("HL_ENABLE_LIVE_TRADING"):
        log.error("HL_ENABLE_LIVE_TRADING=1 not set — refusing to trade live")
        print("ERROR: Set HL_ENABLE_LIVE_TRADING=1 to enable live trading")
        return

    account_val = executor.get_account_value()
    usdc_balance = executor.get_usdc_balance()
    executor.ensure_perp_funded(min_perp_usd=cfg.fixed_margin_usd)
    log.info(f"LIVE TRADING — {brain_type} — {hours}h, interval {cfg.interval_seconds}s")
    log.info(
        "USDC total=$%.2f available=$%.2f hold=$%.2f isolatedMargin=$%.2f, Max open: %s, BE=%s, ProfitLock=%s",
        usdc_balance["total"],
        usdc_balance["available"],
        usdc_balance["hold"],
        account_val,
        max_open,
        "on" if cfg.enable_break_even else "off",
        "on" if cfg.enable_profit_lock else "off",
    )
    if coinglass_client:
        log.info(
            "CoinGlass liquidation feed: %s range=%s exchange=%s pair_first=%s",
            "enabled" if coinglass_client.available else "configured but missing COINGLASS_API_KEY",
            cfg.coinglass_range,
            cfg.coinglass_exchange,
            cfg.coinglass_prefer_pair_heatmap,
        )
    if cfg.deadman_switch_enabled:
        log.info("Dead-man scheduled-cancel enabled for flat/no-position state: %ss", cfg.deadman_cancel_seconds)
    _hydrate_daily_risk_state(executor, journal, risk)
    open_positions.update(_hydrate_live_positions(executor, cfg))
    startup_coins = _merge_unique(get_universe(client, cfg), open_positions.keys())
    ws_cache = LiveOrderflowCache(
        api_url=cfg.api_url,
        coins=startup_coins,
        intervals=("1m", "5m", "15m"),
        user=account_address,
    )
    ws_coins = list(startup_coins)
    ws_cache.start()
    log.info("WS orderflow enrichment enabled for %s", ", ".join(startup_coins))
    market_context = _market_context_map(client)
    next_market_context_refresh = time.time() + 900

    while time.time() < end_time:
        iteration += 1
        usdc_balance = executor.get_usdc_balance()
        if usdc_balance["available"] < cfg.fixed_margin_usd:
            log.warning(
                "USDC available $%.2f is below fixed margin $%.2f; waiting for margin before new live entries",
                usdc_balance["available"],
                cfg.fixed_margin_usd,
            )
            time.sleep(cfg.interval_seconds)
            continue

        universe_coins = get_universe(client, cfg)
        coins = _merge_unique(universe_coins, open_positions.keys())
        if set(coins) != set(ws_coins):
            log.info(
                "WS universe changed; refreshing subscriptions old=%s new=%s",
                ",".join(ws_coins),
                ",".join(coins),
            )
            ws_cache.stop()
            ws_cache = LiveOrderflowCache(
                api_url=cfg.api_url,
                coins=coins,
                intervals=("1m", "5m", "15m"),
                user=account_address,
            )
            ws_cache.start()
            ws_coins = list(coins)
        if time.time() >= next_market_context_refresh:
            market_context = _market_context_map(client)
            next_market_context_refresh = time.time() + 900

        # Collect data
        coin_data = []
        mid_prices = {}
        for coin in coins:
            cd = collect_coin_data(
                client,
                cvd,
                coin,
                cfg,
                ws_cache=ws_cache,
                market_context=market_context.get(coin),
                coinglass_client=coinglass_client,
            )
            if cd:
                coin_data.append(cd)
                mid_prices[coin] = cd["mid"]
            time.sleep(1.5)

        if not coin_data:
            log.warning("No data collected, skipping")
            time.sleep(cfg.interval_seconds)
            continue

        # === Position Management ===
        for coin, pos in list(open_positions.items()):
            price = mid_prices.get(coin, 0)
            if price == 0:
                continue

            exchange_pos = executor.get_position(coin)
            if exchange_pos is None:
                close_summary = _journal_exchange_close(journal, executor, coin, pos, risk)
                log.info(
                    "  LIVE SYNC %s %s closed on exchange; net=$%+.2f fills=%s; removing local manager state",
                    coin,
                    pos["side"].upper(),
                    float(close_summary.get("net_pnl_usd") or 0.0),
                    close_summary.get("fills", 0),
                )
                del open_positions[coin]
                continue
            pos["sz"] = abs(float(exchange_pos.get("szi") or pos["sz"]))
            if exchange_pos.get("entryPx"):
                pos["entry"] = float(exchange_pos["entryPx"])

            # Calculate MFE
            if pos["side"] == "long":
                mfe_pct = (price - pos["entry"]) / pos["entry"] * 100
            else:
                mfe_pct = (pos["entry"] - price) / pos["entry"] * 100

            # Track MFE
            pos["mfe_max"] = max(pos.get("mfe_max", 0), mfe_pct)

            original_stop = float(pos.get("original_stop") or pos["stop"])
            risk_px = abs(pos["entry"] - original_stop)
            favorable_px = (
                price - pos["entry"]
                if pos["side"] == "long"
                else pos["entry"] - price
            )
            r_multiple = favorable_px / risk_px if risk_px > 0 else 0.0

            # R-based profit lock from orderflow playbook: keep OCO/SL on exchange,
            # but stop giving back a full winner while waiting for distant TP.
            if (
                cfg.enable_profit_lock
                and not pos.get("profit_locked")
                and r_multiple >= float(cfg.profit_lock_trigger_r)
                and favorable_px > 0
            ):
                lock_px = favorable_px * float(cfg.profit_lock_fraction)
                fee_be = _fee_adjusted_break_even(pos["entry"], pos["side"], cfg)
                if pos["side"] == "long":
                    locked_stop = max(pos["entry"] + lock_px, fee_be)
                    valid_stop = locked_stop < price and locked_stop > float(pos["stop"])
                else:
                    locked_stop = min(pos["entry"] - lock_px, fee_be)
                    valid_stop = locked_stop > price and locked_stop < float(pos["stop"])
                if valid_stop:
                    try:
                        stop_result = executor.update_stop_loss(coin, locked_stop, pos["sz"], pos["side"] == "long")
                    except Exception as exc:
                        log.error("  LIVE PROFIT-LOCK FAILED for %s: %s", coin, exc)
                        journal.log(JournalEntry(
                            ts=int(time.time() * 1000), event="stop_update_error",
                            coin=coin, side=pos["side"],
                            entry=pos["entry"], stop=locked_stop,
                            reason=str(exc),
                            extra={"kind": "profit_lock"},
                        ))
                        continue
                    if pos.get("sl_cloid"):
                        try:
                            executor.cancel_by_cloid(coin, pos["sl_cloid"])
                        except Exception as exc:
                            log.warning("  OLD SL CANCEL FAILED for %s after profit-lock: %s", coin, exc)
                    pos["stop"] = locked_stop
                    pos["sl_cloid"] = stop_result.get("cloid")
                    pos["be_moved"] = True
                    pos["profit_locked"] = True
                    log.info(
                        f"  LIVE PROFIT-LOCK {coin} {pos['side'].upper()} | "
                        f"R={r_multiple:.2f} | Stop → {locked_stop:.6g}"
                    )
                    journal.log(JournalEntry(
                        ts=int(time.time() * 1000), event="profit_lock",
                        coin=coin, side=pos["side"],
                        entry=pos["entry"], stop=locked_stop,
                        mfe_pct=mfe_pct,
                        extra={
                            "r_multiple": r_multiple,
                            "trigger_r": cfg.profit_lock_trigger_r,
                            "lock_fraction": cfg.profit_lock_fraction,
                        },
                    ))

            # Break-Even
            if cfg.enable_break_even and not pos.get("be_moved") and mfe_pct > 0.5:
                break_even_stop = _fee_adjusted_break_even(pos["entry"], pos["side"], cfg)
                if pos["side"] == "long" and price <= break_even_stop:
                    continue
                if pos["side"] == "short" and price >= break_even_stop:
                    continue
                try:
                    stop_result = executor.update_stop_loss(coin, break_even_stop, pos["sz"], pos["side"] == "long")
                except Exception as exc:
                    log.error("  LIVE BE FAILED for %s: %s", coin, exc)
                    journal.log(JournalEntry(
                        ts=int(time.time() * 1000), event="stop_update_error",
                        coin=coin, side=pos["side"],
                        entry=pos["entry"], stop=break_even_stop,
                        reason=str(exc),
                        extra={"kind": "break_even"},
                    ))
                    continue
                if pos.get("sl_cloid"):
                    try:
                        executor.cancel_by_cloid(coin, pos["sl_cloid"])
                    except Exception as exc:
                        log.warning("  OLD SL CANCEL FAILED for %s after BE: %s", coin, exc)
                pos["stop"] = break_even_stop
                pos["sl_cloid"] = stop_result.get("cloid")
                pos["be_moved"] = True
                log.info(
                    f"  LIVE BE {coin} {pos['side'].upper()} | MFE={mfe_pct:+.2f}% | "
                    f"Stop → fee-BE {break_even_stop:.6g}"
                )
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="stop_to_break_even",
                    coin=coin, side=pos["side"],
                    entry=pos["entry"], stop=break_even_stop,
                    extra={"mfe_pct": mfe_pct, "fee_bps": cfg.fee_bps},
                ))

        # === New Entry ===
        exchange_positions = executor.open_positions()
        if cfg.deadman_switch_enabled:
            try:
                if exchange_positions:
                    executor.schedule_cancel(None)
                else:
                    executor.schedule_cancel(cfg.deadman_cancel_seconds)
            except Exception as exc:
                log.warning("Dead-man schedule_cancel refresh failed: %s", exc)
        if len(open_positions) >= max_open or len(exchange_positions) >= max_open:
            if iteration % 10 == 1:
                pos_str = ", ".join(f"{c}({p['side']})" for c, p in open_positions.items())
                log.info(f"  Max exposure: local={len(open_positions)} exchange={len(exchange_positions)}/{max_open} — {pos_str}")
            time.sleep(cfg.interval_seconds)
            continue

        decision = brain.decide(coin_data)
        _log_llm_decision(journal, decision, coin_data, policy)

        if decision.action == "enter":
            coin = decision.coin

            # Per-coin check
            if coin in open_positions:
                log.debug(f"  SKIP {coin}: already in position")
                time.sleep(cfg.interval_seconds)
                continue
            if executor.get_position(coin) is not None:
                log.info(f"  SKIP {coin}: exchange already has open position")
                time.sleep(cfg.interval_seconds)
                continue

            # Get orderflow data
            coin_cd = None
            coin_of = None
            for cd in coin_data:
                if cd["coin"] == coin:
                    coin_cd = cd
                    coin_of = cd["of"]
                    break

            if not coin_of or not coin_cd:
                log.warning(f"Brain chose {coin} but no data available")
                time.sleep(cfg.interval_seconds)
                continue

            repair = repair_trade_levels(
                decision,
                coin_cd,
                min_rr=cfg.min_rr,
                min_stop_noise_multiple=cfg.min_stop_noise_multiple,
                fee_bps=cfg.fee_bps,
                min_target_pct=cfg.min_target_pct if cfg.enforce_min_target_distance else 0.0,
                min_target_atr_multiple=cfg.min_target_atr_multiple if cfg.enforce_min_target_distance else 0.0,
                prefer_htf_targets=cfg.prefer_htf_targets,
                runner_target_rr=cfg.runner_target_rr,
                max_target_rr=cfg.max_target_rr,
                runner_min_confidence=cfg.runner_min_confidence,
            )
            if not repair.allowed:
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="risk_reject",
                    coin=coin, side=decision.side,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    evidence=decision.evidence,
                    reason=repair.reason,
                    extra={
                        "gate": "level_repair",
                        "gate_reasons": repair.reasons,
                        "gate_metrics": repair.metrics,
                        "analysis_steps": decision.analysis_steps,
                        "playbook_match": decision.playbook_match,
                        "trade_plan": decision.trade_plan,
                        "level_reasoning": decision.level_reasoning,
                        "exit_plan": decision.exit_plan,
                        "alignment_magnitude": decision.alignment_magnitude,
                        "analyst_report": decision.analyst_report,
                        "trader_proposal": decision.trader_proposal,
                        "risk_portfolio_decision": decision.risk_portfolio_decision,
                        "side_arbitration": decision.side_arbitration,
                        "strategy_policy": policy.as_dict(),
                        "selected_market_context": _coin_context_summary(coin_cd),
                    },
                ))
                log.info(f"  REJECT {coin} {decision.side}: {repair.reason}")
                time.sleep(cfg.interval_seconds)
                continue
            if repair.changed:
                log.info("  LEVEL REPAIR %s %s: %s", coin, decision.side, repair.reason)
                decision = repair.decision

            # Risk check
            from .models import Setup
            setup = Setup(
                setup_type=decision.setup_type,
                side=decision.side,
                coin=coin,
                confidence=decision.confidence,
                edge=2.0,
                evidence=decision.evidence,
                entry_ref=decision.entry_price,
                invalidation=decision.stop_price,
                target=decision.target_price,
                atr=coin_of.atr_14,
            )

            cooldown_reason = _loss_cooldown_reason(journal, decision, cfg, executor)
            if cooldown_reason:
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="risk_reject",
                    coin=coin, side=decision.side,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    evidence=decision.evidence,
                    reason=cooldown_reason,
                    extra={
                        "gate": "loss_cooldown",
                        "analysis_steps": decision.analysis_steps,
                        "playbook_match": decision.playbook_match,
                        "trade_plan": decision.trade_plan,
                        "level_reasoning": decision.level_reasoning,
                        "exit_plan": decision.exit_plan,
                        "alignment_magnitude": decision.alignment_magnitude,
                        "analyst_report": decision.analyst_report,
                        "trader_proposal": decision.trader_proposal,
                        "risk_portfolio_decision": decision.risk_portfolio_decision,
                        "side_arbitration": decision.side_arbitration,
                        "strategy_policy": policy.as_dict(),
                        "selected_market_context": _coin_context_summary(coin_cd),
                    },
                ))
                log.info(f"  REJECT {coin} {decision.side}: {cooldown_reason}")
                time.sleep(cfg.interval_seconds)
                continue

            gate = evaluate_research_gate(
                decision,
                coin_cd,
                ws_book_max_age_s=cfg.ws_book_max_age_s,
                ws_tape_max_age_s=cfg.ws_tape_max_age_s,
                policy=policy,
            )
            if not gate.allowed:
                reject = f"research_gate: {gate.reason}"
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="risk_reject",
                    coin=coin, side=decision.side,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    evidence=decision.evidence,
                    reason=reject,
                    extra={
                        "gate": "orderflow_research",
                        "gate_reasons": gate.reasons,
                        "analysis_steps": decision.analysis_steps,
                        "playbook_match": decision.playbook_match,
                        "trade_plan": decision.trade_plan,
                        "level_reasoning": decision.level_reasoning,
                        "exit_plan": decision.exit_plan,
                        "alignment_magnitude": decision.alignment_magnitude,
                        "analyst_report": decision.analyst_report,
                        "trader_proposal": decision.trader_proposal,
                        "risk_portfolio_decision": decision.risk_portfolio_decision,
                        "side_arbitration": decision.side_arbitration,
                        "strategy_policy": policy.as_dict(),
                        "selected_market_context": _coin_context_summary(coin_cd),
                    },
                ))
                log.info(f"  REJECT {coin} {decision.side}: {reject}")
                time.sleep(cfg.interval_seconds)
                continue

            exit_gate = evaluate_exit_plan(
                decision,
                coin_cd,
                min_rr=cfg.min_rr,
                stop_atr_multiplier=cfg.stop_atr_multiplier,
                min_stop_noise_multiple=cfg.min_stop_noise_multiple,
                min_target_pct=cfg.min_target_pct if cfg.enforce_min_target_distance else 0.0,
                min_target_atr_multiple=cfg.min_target_atr_multiple if cfg.enforce_min_target_distance else 0.0,
            )
            if not exit_gate.allowed:
                reject = f"exit_plan_gate: {exit_gate.reason}"
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="risk_reject",
                    coin=coin, side=decision.side,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    evidence=decision.evidence,
                    reason=reject,
                    extra={
                        "gate": "exit_plan",
                        "gate_reasons": exit_gate.reasons,
                        "gate_metrics": exit_gate.metrics,
                        "analysis_steps": decision.analysis_steps,
                        "playbook_match": decision.playbook_match,
                        "trade_plan": decision.trade_plan,
                        "level_reasoning": decision.level_reasoning,
                        "exit_plan": decision.exit_plan,
                        "alignment_magnitude": decision.alignment_magnitude,
                        "analyst_report": decision.analyst_report,
                        "trader_proposal": decision.trader_proposal,
                        "risk_portfolio_decision": decision.risk_portfolio_decision,
                        "side_arbitration": decision.side_arbitration,
                        "strategy_policy": policy.as_dict(),
                        "selected_market_context": _coin_context_summary(coin_cd),
                    },
                ))
                log.info(f"  REJECT {coin} {decision.side}: {reject}")
                time.sleep(cfg.interval_seconds)
                continue

            reject = risk.check(setup, coin_of)
            if reject:
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="risk_reject",
                    coin=coin, side=decision.side,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    reason=reject,
                    extra={
                        "playbook_match": decision.playbook_match,
                        "analysis_steps": decision.analysis_steps,
                        "trade_plan": decision.trade_plan,
                        "level_reasoning": decision.level_reasoning,
                        "exit_plan": decision.exit_plan,
                        "alignment_magnitude": decision.alignment_magnitude,
                        "analyst_report": decision.analyst_report,
                        "trader_proposal": decision.trader_proposal,
                        "risk_portfolio_decision": decision.risk_portfolio_decision,
                        "side_arbitration": decision.side_arbitration,
                        "strategy_policy": policy.as_dict(),
                        "selected_market_context": _coin_context_summary(coin_cd),
                    },
                ))
                log.info(f"  REJECT {coin} {decision.side}: {reject}")
                time.sleep(cfg.interval_seconds)
                continue

            coin_lev = min(client.max_leverage(coin, cfg.max_leverage), cfg.max_leverage)
            plan = risk.build_plan(
                setup,
                coin_of,
                sz_decimals=client.sz_decimals(coin),
                leverage=coin_lev,
            )
            entry_limit = _ioc_entry_limit(plan, decision.side, coin_of.spread_bps, cfg)
            if entry_limit != plan.entry:
                log.info(
                    "  IOC limit adjusted for fill %s %s: plan_entry=%.8g limit=%.8g rr_at_limit=%.2f spread=%.2fbps",
                    coin,
                    decision.side,
                    plan.entry,
                    entry_limit,
                    _rr_at_entry(decision.side, entry_limit, plan.stop, plan.target),
                    coin_of.spread_bps,
                )

            entry_guard_ok, entry_guard_reason, entry_guard_metrics = _execution_entry_guard(
                plan,
                decision.side,
                coin_cd,
                coin_of,
                cfg,
            )
            if not entry_guard_ok:
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="risk_reject",
                    coin=coin, side=decision.side,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    evidence=decision.evidence,
                    reason=entry_guard_reason,
                    extra={
                        "gate": "execution_entry_guard",
                        "gate_metrics": entry_guard_metrics,
                        "analysis_steps": decision.analysis_steps,
                        "playbook_match": decision.playbook_match,
                        "trade_plan": decision.trade_plan,
                        "level_reasoning": decision.level_reasoning,
                        "exit_plan": decision.exit_plan,
                        "alignment_magnitude": decision.alignment_magnitude,
                        "analyst_report": decision.analyst_report,
                        "trader_proposal": decision.trader_proposal,
                        "risk_portfolio_decision": decision.risk_portfolio_decision,
                        "side_arbitration": decision.side_arbitration,
                        "strategy_policy": policy.as_dict(),
                        "selected_market_context": _coin_context_summary(coin_cd),
                        "plan_entry": plan.entry,
                        "ioc_entry_limit": entry_limit,
                    },
                ))
                log.info("  REJECT %s %s: %s", coin, decision.side, entry_guard_reason)
                time.sleep(cfg.interval_seconds)
                continue

            # Place live order
            cloid = str(uuid.uuid4())
            live_order = LiveOrder(
                coin=coin,
                is_buy=(decision.side == "long"),
                sz=plan.qty,
                px=entry_limit,
                sl_px=plan.stop,
                tp_px=plan.target,
                leverage=coin_lev,
                margin_usd=plan.margin_usd,
                entry_tif="Ioc",
                cloid=cloid,
            )

            try:
                executor.set_leverage(coin, coin_lev)
                order_result = executor.place_order(live_order)
                log.info(f"  LIVE ORDER RESULT: {order_result}")
            except Exception as e:
                log.error(f"  LIVE ORDER FAILED for {coin}: {e}")
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="order_error",
                    coin=coin, side=decision.side,
                    extra={
                        "error": str(e),
                        "analysis_steps": decision.analysis_steps,
                        "playbook_match": decision.playbook_match,
                        "trade_plan": decision.trade_plan,
                        "level_reasoning": decision.level_reasoning,
                        "exit_plan": decision.exit_plan,
                        "alignment_magnitude": decision.alignment_magnitude,
                        "analyst_report": decision.analyst_report,
                        "trader_proposal": decision.trader_proposal,
                        "risk_portfolio_decision": decision.risk_portfolio_decision,
                        "side_arbitration": decision.side_arbitration,
                        "strategy_policy": policy.as_dict(),
                        "selected_market_context": _coin_context_summary(coin_cd),
                        "plan_entry": plan.entry,
                        "ioc_entry_limit": entry_limit,
                    },
                ))
                time.sleep(cfg.interval_seconds)
                continue

            time.sleep(0.75)
            confirmed_pos = executor.get_position(coin)
            if confirmed_pos is None:
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="order_sync_error",
                    coin=coin, side=decision.side,
                    entry=plan.entry, stop=plan.stop, target=plan.target,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    evidence=decision.evidence,
                    reason="entry order reported filled but no exchange position was found",
                    extra={
                        "order_result": order_result,
                        "analysis_steps": decision.analysis_steps,
                        "playbook_match": decision.playbook_match,
                        "trade_plan": decision.trade_plan,
                        "level_reasoning": decision.level_reasoning,
                        "exit_plan": decision.exit_plan,
                        "alignment_magnitude": decision.alignment_magnitude,
                        "analyst_report": decision.analyst_report,
                        "trader_proposal": decision.trader_proposal,
                        "risk_portfolio_decision": decision.risk_portfolio_decision,
                        "side_arbitration": decision.side_arbitration,
                        "strategy_policy": policy.as_dict(),
                        "selected_market_context": _coin_context_summary(coin_cd),
                        "plan_entry": plan.entry,
                        "ioc_entry_limit": entry_limit,
                    },
                ))
                log.error("  LIVE ORDER SYNC FAILED for %s: no exchange position after fill", coin)
                time.sleep(cfg.interval_seconds)
                continue

            entry_cloid = order_result.get("cloids", {}).get("entry")
            tp_cloid = order_result.get("cloids", {}).get("take_profit")
            sl_cloid = order_result.get("cloids", {}).get("stop_loss")
            protective_sync = _verify_protective_orders(executor, coin, tp_cloid, sl_cloid)
            if not protective_sync.get("ok"):
                log.warning("  LIVE ORDER PROTECTION WARNING %s: %s", coin, protective_sync)
                journal.log(JournalEntry(
                    ts=int(time.time() * 1000), event="order_sync_warning",
                    coin=coin, side=decision.side,
                    entry=plan.entry, stop=plan.stop, target=plan.target,
                    confidence=decision.confidence,
                    setup_type=decision.setup_type,
                    evidence=decision.evidence,
                    reason="protective TP/SL orders not fully verified after submit",
                    extra={
                        "protective_sync": protective_sync,
                        "order_result": order_result,
                        "analysis_steps": decision.analysis_steps,
                        "playbook_match": decision.playbook_match,
                        "trade_plan": decision.trade_plan,
                        "level_reasoning": decision.level_reasoning,
                        "exit_plan": decision.exit_plan,
                        "alignment_magnitude": decision.alignment_magnitude,
                        "analyst_report": decision.analyst_report,
                        "trader_proposal": decision.trader_proposal,
                        "risk_portfolio_decision": decision.risk_portfolio_decision,
                        "side_arbitration": decision.side_arbitration,
                        "strategy_policy": policy.as_dict(),
                        "selected_market_context": _coin_context_summary(coin_cd),
                        "plan_entry": plan.entry,
                        "ioc_entry_limit": entry_limit,
                    },
                ))

            actual_entry = float(confirmed_pos.get("entryPx") or plan.entry)
            actual_qty = abs(float(confirmed_pos.get("szi") or plan.qty))
            if cfg.deadman_switch_enabled:
                try:
                    executor.schedule_cancel(None)
                except Exception as exc:
                    log.warning("Dead-man schedule_cancel clear after fill failed: %s", exc)

            # Track position
            open_positions[coin] = {
                "side": decision.side,
                "entry": actual_entry,
                "stop": plan.stop,
                "original_stop": plan.stop,
                "target": plan.target,
                "sz": actual_qty,
                "margin": plan.margin_usd,
                "leverage": coin_lev,
                "be_moved": False,
                "profit_locked": False,
                "mfe_max": 0,
                "cloid": cloid,
                "entry_cloid": entry_cloid,
                "tp_cloid": tp_cloid,
                "sl_cloid": sl_cloid,
                "opened_at": time.time(),
                "setup_type": decision.setup_type,
                "confidence": decision.confidence,
                "evidence": decision.evidence,
                "analysis_steps": decision.analysis_steps,
                "playbook_match": decision.playbook_match,
                "trade_plan": decision.trade_plan,
                "level_reasoning": decision.level_reasoning,
                "exit_plan": decision.exit_plan,
                "alignment_magnitude": decision.alignment_magnitude,
                "analyst_report": decision.analyst_report,
                "trader_proposal": decision.trader_proposal,
                "risk_portfolio_decision": decision.risk_portfolio_decision,
                "side_arbitration": decision.side_arbitration,
                "selected_market_context": _coin_context_summary(coin_cd),
                "plan_entry": plan.entry,
                "ioc_entry_limit": entry_limit,
            }

            journal.log(JournalEntry(
                ts=int(time.time() * 1000), event="submit",
                coin=coin, side=decision.side,
                entry=actual_entry, stop=plan.stop, target=plan.target,
                confidence=decision.confidence,
                setup_type=decision.setup_type,
                evidence=decision.evidence,
                extra={
                    "qty": actual_qty, "planned_qty": plan.qty, "leverage": coin_lev,
                    "margin_usd": plan.margin_usd, "rr": plan.rr,
                    "mode": "live", "cloid": cloid,
                    "entry_cloid": entry_cloid,
                    "tp_cloid": tp_cloid,
                    "sl_cloid": sl_cloid,
                    "break_even_enabled": cfg.enable_break_even,
                    "profit_lock_enabled": cfg.enable_profit_lock,
                    "reason": decision.reason,
                    "analysis_steps": decision.analysis_steps,
                    "playbook_match": decision.playbook_match,
                    "trade_plan": decision.trade_plan,
                    "level_reasoning": decision.level_reasoning,
                    "exit_plan": decision.exit_plan,
                    "exit_plan_metrics": exit_gate.metrics,
                    "alignment_magnitude": decision.alignment_magnitude,
                    "analyst_report": decision.analyst_report,
                "trader_proposal": decision.trader_proposal,
                "risk_portfolio_decision": decision.risk_portfolio_decision,
                "side_arbitration": decision.side_arbitration,
                    "strategy_policy": policy.as_dict(),
                    "selected_market_context": _coin_context_summary(coin_cd),
                    "plan_entry": plan.entry,
                    "ioc_entry_limit": entry_limit,
                },
            ))
            risk.record_trade()
            log.info(
                f"  LIVE {coin} {decision.side} @ {actual_entry:.6g} "
                f"(plan={plan.entry:.6g} limit={entry_limit:.6g}) "
                f"SL={plan.stop:.6g} TP={plan.target:.6g} "
                f"R:R={plan.rr:.1f} x{coin_lev} [{decision.setup_type}]"
            )
            log.info(f"    Reason: {decision.reason[:100]}")
        else:
            log.info(f"  WAIT: {decision.reason[:80]}")

        # Sleep
        next_time = time.time() + cfg.interval_seconds
        if next_time < end_time:
            remaining = next_time - time.time()
            if remaining > 0:
                time.sleep(remaining)
        else:
            break

    ws_cache.stop()

    # Close remaining positions
    log.info(f"Live session ended. Closing {len(open_positions)} positions...")
    for coin, pos in list(open_positions.items()):
        price = mid_prices.get(coin, 0)
        if price > 0:
            executor.close_position(coin)
            if pos["side"] == "long":
                pnl = (price - pos["entry"]) / pos["entry"] * pos["margin"] * pos["leverage"]
            else:
                pnl = (pos["entry"] - price) / pos["entry"] * pos["margin"] * pos["leverage"]
            journal.log(JournalEntry(
                ts=int(time.time() * 1000), event="close",
                coin=coin, side=pos["side"], entry=pos["entry"],
                stop=pos["stop"], target=pos["target"],
                pnl_usd=pnl, exit_reason="session_end",
                mfe_pct=pos.get("mfe_max", 0),
            ))
            log.info(f"  CLOSE {coin} {pos['side'].upper()} @ {price:.4g} | PnL: ${pnl:+.2f}")

    summary = journal.summary(hours=hours)
    print(f"\nLive Session Summary ({hours}h):")
    for k, v in summary.items():
        if isinstance(v, float):
            print(f"  {k:20}: {v:.2f}")
        else:
            print(f"  {k:20}: {v}")


def cmd_report(args, cfg: Config):
    journal = Journal(cfg.journal_path)
    hours = args.hours if hasattr(args, 'hours') else 24
    summary = journal.summary(hours)
    print(f"\nJournal Report — last {hours}h")
    print("=" * 50)
    for k, v in summary.items():
        if isinstance(v, float):
            print(f"  {k:20}: {v:.2f}")
        else:
            print(f"  {k:20}: {v}")


def cmd_scout(args, cfg: Config):
    """Read-only market data scout for liquidity, OI caps, and funding dislocations."""
    client = HLClient()
    limit = int(getattr(args, "limit", 20))
    funding_limit = int(getattr(args, "funding_limit", 15))

    caps = client.perps_at_open_interest_cap()
    predicted = client.predicted_fundings()
    funding_dislocations: dict[str, dict] = {}
    for row in predicted:
        if not (isinstance(row, list) and len(row) >= 2 and isinstance(row[1], list)):
            continue
        coin = str(row[0])
        venues = {str(name): info for name, info in row[1] if info}
        hl = venues.get("HlPerp")
        if not isinstance(hl, dict):
            continue
        hl_rate = float(hl.get("fundingRate") or 0.0)
        cex_rates = []
        for venue in ("BinPerp", "BybitPerp"):
            info = venues.get(venue)
            if isinstance(info, dict) and info.get("fundingRate") is not None:
                cex_rates.append(float(info.get("fundingRate") or 0.0))
        cex_avg = sum(cex_rates) / len(cex_rates) if cex_rates else 0.0
        funding_dislocations[coin] = {
            "hl_rate": hl_rate,
            "cex_avg": cex_avg,
            "spread": abs(hl_rate - cex_avg),
        }

    data = client.meta_and_contexts()
    rows = []
    if isinstance(data, list) and len(data) >= 2:
        universe = data[0].get("universe", [])
        contexts = data[1]
        for asset, ctx in zip(universe, contexts):
            if not isinstance(asset, dict) or not isinstance(ctx, dict):
                continue
            coin = str(asset.get("name", ""))
            if not coin or coin in cfg.blocked_coins or asset.get("isDelisted"):
                continue
            if getattr(args, "crypto_only", False) and ":" in coin:
                continue
            vol = float(ctx.get("dayNtlVlm") or 0.0)
            if vol < cfg.min_day_volume_usd:
                continue
            oi = float(ctx.get("openInterest") or 0.0)
            funding = float(ctx.get("funding") or 0.0)
            premium = float(ctx.get("premium") or 0.0)
            dislocation = funding_dislocations.get(coin, {"spread": 0.0})
            rows.append({
                "coin": coin,
                "vol": vol,
                "oi": oi,
                "funding": funding,
                "premium": premium,
                "max_lev": asset.get("maxLeverage"),
                "sz_decimals": asset.get("szDecimals"),
                "at_oi_cap": coin in caps,
                "funding_spread": float(dislocation.get("spread", 0.0)),
                "hl_funding": float(dislocation.get("hl_rate", funding)),
                "cex_funding": float(dislocation.get("cex_avg", 0.0)),
            })

    rows.sort(key=lambda r: (-r["vol"], r["at_oi_cap"]))
    print("\nHyperliquid Data Scout")
    print("=" * 92)
    print(f"OI-cap perps: {', '.join(sorted(caps)) if caps else 'none'}")
    print("\nTop liquid candidates")
    print(f"{'coin':10} {'vol_usd':>13} {'OI':>13} {'fund':>10} {'prem':>10} {'f_spread':>10} {'lev':>5} {'cap':>4}")
    for row in rows[:limit]:
        print(
            f"{row['coin']:10} "
            f"{row['vol']:13,.0f} "
            f"{row['oi']:13,.0f} "
            f"{row['funding']:+10.7f} "
            f"{row['premium']:+10.7f} "
            f"{row['funding_spread']:10.7f} "
            f"{str(row['max_lev']):>5} "
            f"{'YES' if row['at_oi_cap'] else 'no':>4}"
        )

    dis_rows = sorted(
        [dict(coin=coin, **values) for coin, values in funding_dislocations.items()],
        key=lambda r: -float(r["spread"]),
    )
    print("\nTop funding dislocations")
    print(f"{'coin':10} {'HL':>12} {'CEXavg':>12} {'spread':>12}")
    for row in dis_rows[:funding_limit]:
        print(
            f"{row['coin']:10} "
            f"{row['hl_rate']:+12.8f} "
            f"{row['cex_avg']:+12.8f} "
            f"{row['spread']:12.8f}"
        )


def cmd_record_ws(args, cfg: Config):
    """Record read-only websocket orderflow to JSONL for replay/research."""
    from .ws_orderflow import record_orderflow

    client = HLClient()
    if args.coins:
        coins = [coin.strip().upper() for coin in args.coins.split(",") if coin.strip()]
    else:
        coins = get_universe(client, cfg)[: int(args.max_coins)]
    intervals = [item.strip() for item in args.intervals.split(",") if item.strip()]
    out_path = args.out or f"data/ws-orderflow-{int(time.time())}.jsonl"
    print(f"Recording {len(coins)} coins for {args.seconds}s -> {out_path}")
    print(f"Coins: {', '.join(coins)}")
    result = record_orderflow(
        api_url="https://api.hyperliquid.xyz",
        out_path=out_path,
        seconds=int(args.seconds),
        coins=coins,
        intervals=intervals,
        user=args.user or None,
    )
    print(json.dumps(result, indent=2, sort_keys=True))


def cmd_config(args, cfg: Config):
    print(json.dumps(cfg.to_dict(), indent=2))


def main():
    parser = argparse.ArgumentParser(prog="hlbot", description="HL-Bot V2 — LLM-Brain Orderflow Trading")
    parser.add_argument("--config", default="config.toml")
    parser.add_argument("--verbose", "-v", action="store_true")

    sub = parser.add_subparsers(dest="command")

    p_scan = sub.add_parser("scan", help="Show orderflow data")
    p_scan.add_argument("--coins", default="")

    p_think = sub.add_parser("think", help="LLM decides (needs API key)")
    p_think.add_argument("--coins", default="")
    p_think.add_argument("--api-key", default="")

    p_paper = sub.add_parser("paper", help="Paper trading (simulated brain)")
    p_paper.add_argument("--hours", type=float, default=1.0)

    p_live = sub.add_parser("live", help="LIVE trading (real orders)")
    p_live.add_argument("--hours", type=float, default=1.0)

    p_bt = sub.add_parser("backtest", help="V1 vs V2 backtest")
    p_bt.add_argument("--hours", type=int, default=48)

    p_report = sub.add_parser("report", help="Journal report")
    p_report.add_argument("--hours", type=int, default=24)

    p_scout = sub.add_parser("scout", help="Read-only market data scout")
    p_scout.add_argument("--limit", type=int, default=20)
    p_scout.add_argument("--funding-limit", type=int, default=15)
    p_scout.add_argument("--crypto-only", action="store_true")

    p_record = sub.add_parser("record-ws", help="Record read-only websocket orderflow")
    p_record.add_argument("--seconds", type=int, default=30)
    p_record.add_argument("--coins", default="")
    p_record.add_argument("--max-coins", type=int, default=5)
    p_record.add_argument("--intervals", default="1m,5m,15m")
    p_record.add_argument("--user", default="")
    p_record.add_argument("--out", default="")

    sub.add_parser("config", help="Show config")

    args = parser.parse_args()

    if args.verbose:
        logging.getLogger().setLevel(logging.DEBUG)

    cfg = Config.load(args.config)

    cmds = {
        "scan": cmd_scan,
        "think": cmd_think,
        "paper": cmd_paper,
        "live": cmd_live,
        "backtest": cmd_backtest,
        "report": cmd_report,
        "scout": cmd_scout,
        "record-ws": cmd_record_ws,
        "config": cmd_config,
    }

    if args.command in cmds:
        cmds[args.command](args, cfg)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()
