#!/usr/bin/env python3
"""One-command loss diagnostics for the V2 Hyperliquid bot.

Read-only. Pulls exchange fills/OHLC and joins them with local journal submits
so we can diagnose losing trades without manually grepping logs.
"""
from __future__ import annotations

import argparse
import json
import os
import time
import urllib.request
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any


BERLIN = timezone(timedelta(hours=2), name="Europe/Berlin")
HL_INFO_URL = "https://api.hyperliquid.xyz/info"


@dataclass
class Trade:
    coin: str
    side: str
    open_time: int | None = None
    close_time: int | None = None
    qty: float = 0.0
    closed_qty: float = 0.0
    entry_value: float = 0.0
    exit_value: float = 0.0
    gross_pnl: float = 0.0
    open_fees: float = 0.0
    close_fees: float = 0.0
    open_fills: list[dict[str, Any]] = field(default_factory=list)
    close_fills: list[dict[str, Any]] = field(default_factory=list)

    @property
    def entry(self) -> float:
        return self.entry_value / self.qty if self.qty else 0.0

    @property
    def exit(self) -> float:
        return self.exit_value / self.closed_qty if self.closed_qty else 0.0

    @property
    def fees(self) -> float:
        return abs(self.open_fees) + abs(self.close_fees)

    @property
    def net_pnl(self) -> float:
        return self.gross_pnl - self.fees

    @property
    def closed(self) -> bool:
        return self.close_time is not None and self.closed_qty > 0 and (
            self.qty <= 0 or self.closed_qty >= self.qty * 0.999
        )

    @property
    def legacy_or_orphan(self) -> bool:
        return self.qty <= 0 and self.closed_qty > 0


def fnum(value: Any, default: float = 0.0) -> float:
    try:
        return float(value)
    except (TypeError, ValueError):
        return default


def fmt_time(ms: int | None) -> str:
    if not ms:
        return "-"
    return datetime.fromtimestamp(ms / 1000, BERLIN).strftime("%Y-%m-%d %H:%M:%S")


def fmt_num(value: Any, digits: int = 6) -> str:
    num = fnum(value)
    if abs(num) >= 100:
        return f"{num:.2f}"
    if abs(num) >= 1:
        return f"{num:.{digits}f}".rstrip("0").rstrip(".")
    return f"{num:.8f}".rstrip("0").rstrip(".")


def one_line(value: Any, limit: int = 180) -> str:
    text = " ".join(str(value or "").split())
    if limit and len(text) > limit:
        return text[: limit - 3] + "..."
    return text


def pct(part: float, whole: float) -> float:
    return part / whole * 100.0 if whole else 0.0


def parse_time(value: str | None, hours: float) -> int:
    if value:
        raw = value.strip().replace("T", " ")
        for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
            try:
                parsed = datetime.strptime(raw, fmt)
                return int(parsed.replace(tzinfo=BERLIN).astimezone(timezone.utc).timestamp() * 1000)
            except ValueError:
                pass
        parsed = datetime.fromisoformat(value)
        if parsed.tzinfo is None:
            parsed = parsed.replace(tzinfo=BERLIN)
        return int(parsed.timestamp() * 1000)
    return int((time.time() - hours * 3600) * 1000)


def post_info(payload: dict[str, Any]) -> Any:
    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        HL_INFO_URL,
        data=body,
        headers={"content-type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=20) as response:
        return json.loads(response.read().decode("utf-8"))


def load_journal(path: Path, since_ms: int) -> list[dict[str, Any]]:
    if not path.exists():
        raise SystemExit(f"journal not found: {path}")
    rows: list[dict[str, Any]] = []
    for line in path.read_text(errors="replace").splitlines():
        try:
            row = json.loads(line)
        except json.JSONDecodeError:
            continue
        if int(row.get("ts") or 0) >= since_ms:
            rows.append(row)
    return rows


def fill_side(fill: dict[str, Any]) -> str:
    direction = str(fill.get("dir") or "").lower()
    if "long" in direction:
        return "long"
    if "short" in direction:
        return "short"
    return ""


def fill_is_open(fill: dict[str, Any]) -> bool:
    return str(fill.get("dir") or "").lower().startswith("open")


def fill_is_close(fill: dict[str, Any]) -> bool:
    direction = str(fill.get("dir") or "").lower()
    return direction.startswith("close") or abs(fnum(fill.get("closedPnl"))) > 0


def fetch_fills(address: str, since_ms: int, end_ms: int) -> list[dict[str, Any]]:
    result = post_info(
        {
            "type": "userFillsByTime",
            "user": address,
            "startTime": since_ms,
            "endTime": end_ms,
            "aggregateByTime": True,
        }
    )
    fills = result if isinstance(result, list) else []
    return sorted(fills, key=lambda fill: (int(fill.get("time") or 0), str(fill.get("tid") or "")))


def fetch_open_positions(address: str) -> list[dict[str, Any]]:
    state = post_info({"type": "clearinghouseState", "user": address})
    if not isinstance(state, dict):
        return []
    out: list[dict[str, Any]] = []
    for item in state.get("assetPositions", []):
        pos = item.get("position") or {}
        szi = fnum(pos.get("szi"))
        if abs(szi) <= 0:
            continue
        out.append(
            {
                "coin": pos.get("coin"),
                "side": "long" if szi > 0 else "short",
                "szi": szi,
                "entry": fnum(pos.get("entryPx")),
                "unrealized": fnum(pos.get("unrealizedPnl")),
                "liq": fnum(pos.get("liquidationPx")),
            }
        )
    return out


def fetch_candles(coin: str, start_ms: int, end_ms: int, interval: str) -> list[dict[str, Any]]:
    result = post_info(
        {
            "type": "candleSnapshot",
            "req": {
                "coin": coin,
                "interval": interval,
                "startTime": max(0, start_ms),
                "endTime": end_ms,
            },
        }
    )
    return result if isinstance(result, list) else []


def reconstruct_trades(fills: list[dict[str, Any]]) -> tuple[list[Trade], list[Trade]]:
    active: dict[tuple[str, str], Trade] = {}
    closed: list[Trade] = []
    orphan_groups: dict[tuple[str, str, int], Trade] = {}

    for fill in fills:
        coin = str(fill.get("coin") or "?")
        side = fill_side(fill)
        if not side:
            continue
        key = (coin, side)
        ts = int(fill.get("time") or 0)
        px = fnum(fill.get("px"))
        sz = fnum(fill.get("sz"))
        fee = abs(fnum(fill.get("fee")))

        if fill_is_open(fill):
            trade = active.get(key)
            if trade is None:
                trade = Trade(coin=coin, side=side, open_time=ts)
                active[key] = trade
            trade.qty += sz
            trade.entry_value += px * sz
            trade.open_fees += fee
            trade.open_fills.append(fill)
            trade.open_time = min(trade.open_time or ts, ts)
            continue

        if not fill_is_close(fill):
            continue

        trade = active.get(key)
        if trade is None:
            # Group legacy/manual close fills by same second to avoid SOL-style split fills.
            group_key = (coin, side, ts // 1000)
            trade = orphan_groups.get(group_key)
            if trade is None:
                trade = Trade(coin=coin, side=side, close_time=ts)
                orphan_groups[group_key] = trade
        trade.close_time = ts
        trade.closed_qty += sz
        trade.exit_value += px * sz
        trade.gross_pnl += fnum(fill.get("closedPnl"))
        trade.close_fees += fee
        trade.close_fills.append(fill)
        if not trade.legacy_or_orphan and trade.closed:
            closed.append(trade)
            active.pop(key, None)

    closed.extend(sorted(orphan_groups.values(), key=lambda row: row.close_time or 0))
    open_trades = list(active.values())
    return sorted(closed, key=lambda row: row.close_time or row.open_time or 0), open_trades


def match_submit(trade: Trade, journal: list[dict[str, Any]]) -> dict[str, Any] | None:
    if trade.legacy_or_orphan or trade.open_time is None:
        return None
    candidates: list[tuple[int, dict[str, Any]]] = []
    for row in journal:
        if row.get("event") != "submit":
            continue
        if row.get("coin") != trade.coin or row.get("side") != trade.side:
            continue
        distance = abs(int(row.get("ts") or 0) - trade.open_time)
        if distance <= 20 * 60 * 1000:
            candidates.append((distance, row))
    candidates.sort(key=lambda pair: pair[0])
    return candidates[0][1] if candidates else None


def candle_mfe_mae(trade: Trade, candles: list[dict[str, Any]]) -> dict[str, float]:
    if trade.entry <= 0 or not trade.open_time:
        return {}
    end = trade.close_time or int(time.time() * 1000)
    selected = [
        row
        for row in candles
        if trade.open_time <= int(row.get("t") or 0) <= end
    ]
    if not selected:
        return {}
    high = max(fnum(row.get("h")) for row in selected)
    low = min(fnum(row.get("l")) for row in selected)
    if trade.side == "long":
        mfe = high - trade.entry
        mae = trade.entry - low
    else:
        mfe = trade.entry - low
        mae = high - trade.entry
    return {
        "high": high,
        "low": low,
        "mfe_pct": max(0.0, pct(mfe, trade.entry)),
        "mae_pct": max(0.0, pct(mae, trade.entry)),
        "mfe_usd": max(0.0, mfe * max(trade.qty, trade.closed_qty)),
        "mae_usd": max(0.0, mae * max(trade.qty, trade.closed_qty)),
    }


def candidate_levels(row: dict[str, Any]) -> dict[str, float]:
    metrics = row.get("gate_metrics") if isinstance(row.get("gate_metrics"), dict) else {}
    if not metrics:
        metrics = row.get("exit_plan_metrics") if isinstance(row.get("exit_plan_metrics"), dict) else {}
    entry = fnum(metrics.get("entry") or row.get("entry"))
    stop = fnum(metrics.get("stop") or row.get("stop"))
    target = fnum(metrics.get("target") or row.get("target"))
    return {"entry": entry, "stop": stop, "target": target}


def candidate_ohlc_outcome(row: dict[str, Any], candles: list[dict[str, Any]]) -> dict[str, Any]:
    levels = candidate_levels(row)
    entry = levels["entry"]
    stop = levels["stop"]
    target = levels["target"]
    side = str(row.get("side") or "").lower()
    ts = int(row.get("ts") or 0)
    if entry <= 0 or stop <= 0 or target <= 0 or side not in ("long", "short"):
        return {}

    selected = [c for c in candles if int(c.get("t") or 0) >= ts]
    if not selected:
        return {}
    high = max(fnum(c.get("h")) for c in selected)
    low = min(fnum(c.get("l")) for c in selected)
    if side == "long":
        mfe = max(0.0, high - entry)
        mae = max(0.0, entry - low)
    else:
        mfe = max(0.0, entry - low)
        mae = max(0.0, high - entry)

    first_hit = "none"
    first_hit_time = None
    for candle in selected:
        candle_ts = int(candle.get("t") or 0)
        c_high = fnum(candle.get("h"))
        c_low = fnum(candle.get("l"))
        if side == "long":
            hit_stop = c_low <= stop
            hit_target = c_high >= target
        else:
            hit_stop = c_high >= stop
            hit_target = c_low <= target
        if hit_stop and hit_target:
            first_hit = "both_same_candle"
            first_hit_time = candle_ts
            break
        if hit_target:
            first_hit = "target"
            first_hit_time = candle_ts
            break
        if hit_stop:
            first_hit = "stop"
            first_hit_time = candle_ts
            break

    return {
        **levels,
        "first_hit": first_hit,
        "first_hit_time": first_hit_time,
        "mfe_pct": pct(mfe, entry),
        "mae_pct": pct(mae, entry),
        "high": high,
        "low": low,
        "candles": len(selected),
    }


def near(px: float, level: float, tolerance: float) -> bool:
    return level > 0 and abs(px - level) <= tolerance


def classify_exit(trade: Trade, submit: dict[str, Any] | None) -> str:
    if not trade.closed:
        return "open"
    if trade.legacy_or_orphan:
        return "legacy_or_manual_close"
    if not submit or trade.exit <= 0:
        return "unknown_exchange_close"
    tolerance = max(trade.entry * 0.0008, abs(trade.entry) * 0.0001)
    stop = fnum(submit.get("stop"))
    target = fnum(submit.get("target"))
    if near(trade.exit, stop, tolerance):
        return "bot_sl"
    if near(trade.exit, target, tolerance):
        return "bot_tp"
    return "manual_or_unknown"


def trade_flags(trade: Trade, submit: dict[str, Any] | None, metrics: dict[str, float]) -> list[str]:
    flags: list[str] = []
    if trade.closed and trade.net_pnl < 0:
        flags.append("loss_after_fees")
    if trade.legacy_or_orphan:
        flags.append("legacy_or_manual_close")
        return flags
    if metrics:
        if trade.net_pnl < 0 and metrics.get("mfe_pct", 0.0) < 0.25:
            flags.append("wrong_quickly_no_mfe")
        if trade.net_pnl < 0 and metrics.get("mfe_usd", 0.0) > abs(trade.net_pnl):
            flags.append("gave_back_mfe")
    if not submit:
        flags.append("no_matching_submit")
        return flags

    context = submit.get("selected_market_context") if isinstance(submit.get("selected_market_context"), dict) else {}
    plan_metrics = submit.get("exit_plan_metrics") if isinstance(submit.get("exit_plan_metrics"), dict) else {}
    noise = plan_metrics.get("noise_state") if isinstance(plan_metrics.get("noise_state"), dict) else {}
    regime = context.get("regime") if isinstance(context.get("regime"), dict) else {}
    align = submit.get("alignment_magnitude") if isinstance(submit.get("alignment_magnitude"), dict) else {}

    entry = fnum(submit.get("entry"))
    stop = fnum(submit.get("stop"))
    stop_dist = abs(entry - stop)
    floor = fnum(noise.get("dynamic_stop_floor"))
    if floor and stop_dist < floor * 1.25:
        flags.append(f"borderline_noise_stop_{stop_dist / floor:.2f}x_floor")

    price_vs_vwap = fnum(context.get("price_vs_vwap"))
    if trade.side == "short" and price_vs_vwap > 0.005:
        flags.append("short_above_vwap")
    if trade.side == "long" and price_vs_vwap < -0.005:
        flags.append("long_below_vwap")

    trend_4h = str(regime.get("trend_4h") or "").lower()
    if trade.side == "short" and trend_4h == "up":
        flags.append("short_against_4h_up")
    if trade.side == "long" and trend_4h == "down":
        flags.append("long_against_4h_down")

    spread_bps = fnum(context.get("spread_bps"))
    if spread_bps > 5:
        flags.append(f"wide_spread_{spread_bps:.1f}bps")

    weakest = " ".join(str(x) for x in align.get("weakest_evidence") or []).lower()
    if trade.side == "short" and "near_below_vwap fails" in weakest:
        flags.append("short_alignment_failed_vwap")
    if trade.side == "long" and "near_above_vwap fails" in weakest:
        flags.append("long_alignment_failed_vwap")

    playbook = submit.get("playbook_match") if isinstance(submit.get("playbook_match"), dict) else {}
    if playbook.get("playbook") == "liquidity_sweep_reclaim":
        response = json.dumps(playbook, sort_keys=True).lower()
        if "retest" not in response and "accept" not in response:
            flags.append("sweep_without_retest_acceptance_text")

    return flags


def print_trade_detail(trade: Trade, submit: dict[str, Any] | None, metrics: dict[str, float]) -> None:
    status = "CLOSED" if trade.closed else "OPEN"
    exit_kind = classify_exit(trade, submit)
    duration = "-"
    if trade.open_time and trade.close_time:
        duration = f"{(trade.close_time - trade.open_time) / 60000:.1f}m"
    print(
        f"{status:6} {trade.coin:8} {trade.side:5} "
        f"{fmt_time(trade.open_time)} -> {fmt_time(trade.close_time)} "
        f"dur={duration} exit={exit_kind}"
    )
    print(
        f"  px: entry={fmt_num(trade.entry) if trade.entry else '-'} "
        f"exit={fmt_num(trade.exit) if trade.exit else '-'} "
        f"qty={fmt_num(max(trade.qty, trade.closed_qty))} "
        f"gross={trade.gross_pnl:+.4f} fees={trade.fees:.4f} net={trade.net_pnl:+.4f}"
    )
    if metrics:
        print(
            f"  ohlc: MFE={metrics['mfe_pct']:.2f}% (${metrics['mfe_usd']:.2f}) "
            f"MAE={metrics['mae_pct']:.2f}% (${metrics['mae_usd']:.2f}) "
            f"high={fmt_num(metrics['high'])} low={fmt_num(metrics['low'])}"
        )

    if submit:
        context = submit.get("selected_market_context") if isinstance(submit.get("selected_market_context"), dict) else {}
        playbook = submit.get("playbook_match") if isinstance(submit.get("playbook_match"), dict) else {}
        exit_metrics = submit.get("exit_plan_metrics") if isinstance(submit.get("exit_plan_metrics"), dict) else {}
        noise = exit_metrics.get("noise_state") if isinstance(exit_metrics.get("noise_state"), dict) else {}
        align = submit.get("alignment_magnitude") if isinstance(submit.get("alignment_magnitude"), dict) else {}
        stop = fnum(submit.get("stop"))
        target = fnum(submit.get("target"))
        entry = fnum(submit.get("entry"))
        print(
            f"  plan: setup={submit.get('setup_type')} conf={submit.get('confidence')} "
            f"stop={fmt_num(stop)} target={fmt_num(target)} rr={fnum(submit.get('rr')):.2f} "
            f"stop_pct={pct(abs(entry - stop), entry):.2f}%"
        )
        print(f"  reason: {one_line(submit.get('reason'), 240)}")
        print(f"  evidence: {one_line(', '.join(str(x) for x in submit.get('evidence') or []), 240)}")
        print(
            "  market: "
            f"spread={fnum(context.get('spread_bps')):.2f}bps "
            f"book={fnum(context.get('book_imbalance')):+.3f} "
            f"tape={fnum(context.get('tape_delta_pct')):+.3f} "
            f"price_vs_vwap={fnum(context.get('price_vs_vwap')):+.4f} "
            f"rsi={fnum(context.get('rsi_14')):.1f} "
            f"macd={fnum(context.get('macd_histogram')):+.4f}"
        )
        regime = context.get("regime") if isinstance(context.get("regime"), dict) else {}
        print(
            f"  regime: overall={regime.get('overall')} "
            f"15m={regime.get('trend_15m')} 1h={regime.get('trend_1h')} 4h={regime.get('trend_4h')}"
        )
        if playbook:
            print(
                f"  playbook: {playbook.get('playbook')} trigger={playbook.get('trigger_present')} "
                f"level={one_line(playbook.get('trigger_level'), 180)}"
            )
            print(f"  response: {one_line(playbook.get('price_response'), 180)}")
        if align:
            print(
                f"  alignment: {align.get('score')} strength={align.get('strength')} "
                f"weakest={one_line(align.get('weakest_evidence'), 160)}"
            )
        if noise:
            stop_dist = abs(entry - stop)
            floor = fnum(noise.get("dynamic_stop_floor"))
            print(
                f"  noise: stop_dist={fmt_num(stop_dist)} floor={fmt_num(floor)} "
                f"stop/floor={(stop_dist / floor if floor else 0):.2f} "
                f"ATR={fmt_num(noise.get('atr14'))} 1mP80={fmt_num(noise.get('range_p80_1m'))} "
                f"5mP80={fmt_num(noise.get('range_p80_5m'))}"
            )

    flags = trade_flags(trade, submit, metrics)
    print(f"  flags: {', '.join(flags) if flags else 'none'}")
    print()


def print_order_errors(journal: list[dict[str, Any]], limit: int) -> None:
    errors = [row for row in journal if row.get("event") == "order_error"]
    if not errors:
        return
    print("Order Errors")
    print("-" * 80)
    for row in errors[-limit:]:
        extra = row.get("extra") if isinstance(row.get("extra"), dict) else {}
        print(
            f"{row.get('time')} {row.get('coin')} {row.get('side')} "
            f"reason={one_line(row.get('reason') or row.get('error') or extra.get('error') or extra, 220)}"
        )
    print()


def collect_missed_candidates(
    journal: list[dict[str, Any]],
    *,
    limit: int,
    lookahead_hours: float,
    interval: str,
    end_ms: int,
) -> list[dict[str, Any]]:
    """Backtest rejected LLM candidates to find missed opportunities.

    This intentionally focuses on candidates with concrete gate metrics. WAIT
    rows without levels cannot be fairly scored.
    """
    candidates: list[dict[str, Any]] = []
    for row in journal:
        if row.get("event") != "risk_reject":
            continue
        levels = candidate_levels(row)
        if not all(levels.values()):
            continue
        candidates.append(row)
    candidates = candidates[-limit:]

    out: list[dict[str, Any]] = []
    for row in candidates:
        ts = int(row.get("ts") or 0)
        finish = min(end_ms, ts + int(lookahead_hours * 3600_000))
        if finish <= ts:
            continue
        candles = fetch_candles(str(row.get("coin")), ts - 60_000, finish, interval)
        time.sleep(0.10)
        outcome = candidate_ohlc_outcome(row, candles)
        if not outcome:
            continue
        out.append({"row": row, "outcome": outcome})
    return out


def print_missed_candidates(missed: list[dict[str, Any]]) -> None:
    if not missed:
        return
    print("Missed Candidate OHLC Backtest")
    print("-" * 80)
    for item in missed:
        row = item["row"]
        outcome = item["outcome"]
        print(
            f"{row.get('time')} {row.get('coin')} {row.get('side')} {row.get('setup_type')} "
            f"gate={row.get('gate')} first_hit={outcome['first_hit']} "
            f"entry={fmt_num(outcome['entry'])} stop={fmt_num(outcome['stop'])} target={fmt_num(outcome['target'])} "
            f"MFE={outcome['mfe_pct']:.2f}% MAE={outcome['mae_pct']:.2f}%"
        )
        print(f"  reject_reason: {one_line(row.get('reason'), 220)}")
        if outcome.get("first_hit_time"):
            print(f"  first_hit_time: {fmt_time(outcome['first_hit_time'])}")
    print()


def build_lessons(
    *,
    since_ms: int,
    end_ms: int,
    closed: list[Trade],
    open_trades: list[Trade],
    journal: list[dict[str, Any]],
    candle_cache: dict[str, list[dict[str, Any]]],
    missed: list[dict[str, Any]],
) -> dict[str, Any]:
    flag_counts: Counter[str] = Counter()
    recent_trades: list[dict[str, Any]] = []
    coin_side: dict[str, dict[str, Any]] = defaultdict(lambda: {"wins": 0, "losses": 0, "net": 0.0})
    for trade in closed + open_trades:
        submit = match_submit(trade, journal)
        metrics = candle_mfe_mae(trade, candle_cache.get(trade.coin, []))
        flags = trade_flags(trade, submit, metrics)
        flag_counts.update(flags)
        key = f"{trade.coin}:{trade.side}"
        coin_side[key]["net"] += round(trade.net_pnl, 6)
        if trade.closed and trade.net_pnl > 0:
            coin_side[key]["wins"] += 1
        if trade.closed and trade.net_pnl < 0:
            coin_side[key]["losses"] += 1
        if submit:
            recent_trades.append(
                {
                    "time": fmt_time(trade.open_time),
                    "coin": trade.coin,
                    "side": trade.side,
                    "setup": submit.get("setup_type"),
                    "net_pnl": round(trade.net_pnl, 4),
                    "mfe_pct": round(metrics.get("mfe_pct", 0.0), 3) if metrics else None,
                    "mae_pct": round(metrics.get("mae_pct", 0.0), 3) if metrics else None,
                    "flags": flags,
                }
            )

    missed_rows: list[dict[str, Any]] = []
    for item in missed:
        row = item["row"]
        outcome = item["outcome"]
        missed_rows.append(
            {
                "time": row.get("time"),
                "coin": row.get("coin"),
                "side": row.get("side"),
                "setup": row.get("setup_type"),
                "gate": row.get("gate"),
                "reject_reason": one_line(row.get("reason"), 260),
                "first_hit": outcome.get("first_hit"),
                "mfe_pct": round(float(outcome.get("mfe_pct") or 0.0), 3),
                "mae_pct": round(float(outcome.get("mae_pct") or 0.0), 3),
            }
        )

    rules = [
        "Reject entries whose stop is only barely outside OHLC noise; require stop_distance >= min_stop_noise_multiple * dynamicStopFloor.",
        "For liquidity_sweep_reclaim, require real retest/reclaim/acceptance and price response; sell/buy tape alone caused wrong_quickly losses.",
        "Counter-HTF sweep trades need stronger evidence: acceptance beyond VWAP/value or clear absorption at the level.",
        "If recent similar coin:side shows repeated wrong_quickly_no_mfe, lower confidence or WAIT until a higher-quality trigger appears.",
        "Review missed candidates where first_hit=target: gate may be too strict or IOC execution missed a valid opportunity.",
        "Review missed candidates where first_hit=stop: the rejection protected capital and should stay strict.",
    ]
    return {
        "generated_at": fmt_time(int(time.time() * 1000)),
        "window": {"start": fmt_time(since_ms), "end": fmt_time(end_ms)},
        "rules": rules,
        "flag_counts": dict(flag_counts.most_common()),
        "coin_side": dict(coin_side),
        "recent_trades": recent_trades[-20:],
        "missed_opportunities": missed_rows[-20:],
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Read-only losing-trade diagnosis for hl-bot-v2.")
    parser.add_argument("--since", help="Berlin time, e.g. '2026-05-24 13:27:53'")
    parser.add_argument("--hours", type=float, default=24.0)
    parser.add_argument("--address", help="Defaults to HYPERLIQUID_ACCOUNT_ADDRESS")
    parser.add_argument("--journal", default="data/journal.jsonl")
    parser.add_argument("--interval", default="1m", help="OHLC interval for MFE/MAE, default 1m")
    parser.add_argument("--order-errors", type=int, default=8)
    parser.add_argument("--show-candles", action="store_true", help="Print OHLC candles around matched trades")
    parser.add_argument("--missed-candidates", type=int, default=12, help="Backtest recent rejected candidates with concrete levels")
    parser.add_argument("--missed-lookahead-hours", type=float, default=4.0)
    parser.add_argument("--write-lessons", help="Write compact OHLC lesson JSON for the LLM prompt")
    args = parser.parse_args()

    address = args.address or os.environ.get("HYPERLIQUID_ACCOUNT_ADDRESS")
    if not address:
        raise SystemExit("Set --address or HYPERLIQUID_ACCOUNT_ADDRESS")

    since_ms = parse_time(args.since, args.hours)
    end_ms = int(time.time() * 1000)
    journal = load_journal(Path(args.journal), since_ms)
    fills = fetch_fills(address, since_ms, end_ms)
    closed, open_trades = reconstruct_trades(fills)
    positions = fetch_open_positions(address)

    submits = [row for row in journal if row.get("event") == "submit"]
    closes = [row for row in journal if row.get("event") == "close"]
    counts = Counter(str(row.get("event") or "?") for row in journal)

    closed_net = sum(trade.net_pnl for trade in closed)
    closed_gross = sum(trade.gross_pnl for trade in closed)
    closed_fees = sum(trade.fees for trade in closed)
    wins = sum(1 for trade in closed if trade.net_pnl > 0)
    losses = sum(1 for trade in closed if trade.net_pnl < 0)

    print(f"Loss Diagnosis since {fmt_time(since_ms)} -> {fmt_time(end_ms)}")
    print("=" * 80)
    print(f"Address: {address}")
    print(
        f"Exchange fills={len(fills)} closed_trades={len(closed)} open_trades={len(open_trades)} "
        f"W/L={wins}/{losses}"
    )
    print(f"Closed gross={closed_gross:+.4f} fees={closed_fees:.4f} net={closed_net:+.4f} USDC")
    print(
        f"Journal submits={len(submits)} closes={len(closes)} "
        f"risk_rejects={counts.get('risk_reject', 0)} order_errors={counts.get('order_error', 0)}"
    )
    if positions:
        print("Open exchange positions:")
        for pos in positions:
            print(
                f"  {pos['coin']} {pos['side']} szi={fmt_num(pos['szi'])} "
                f"entry={fmt_num(pos['entry'])} uPnL={pos['unrealized']:+.4f} liq={fmt_num(pos['liq'])}"
            )
    else:
        print("Open exchange positions: none")
    print()

    print("Trades")
    print("-" * 80)
    if not closed and not open_trades:
        print("none")
    candle_cache: dict[str, list[dict[str, Any]]] = {}
    for trade in closed + open_trades:
        if not trade.open_time:
            continue
        start = trade.open_time - 10 * 60_000
        finish = (trade.close_time or end_ms) + 10 * 60_000
        candle_cache[trade.coin] = fetch_candles(trade.coin, start, finish, args.interval)
        time.sleep(0.15)

    for trade in closed + open_trades:
        submit = match_submit(trade, journal)
        metrics = candle_mfe_mae(trade, candle_cache.get(trade.coin, []))
        print_trade_detail(trade, submit, metrics)
        if args.show_candles and trade.open_time and candle_cache.get(trade.coin):
            print("  candles:")
            for candle in candle_cache[trade.coin]:
                print(
                    f"    {fmt_time(int(candle.get('t') or 0))} "
                    f"O={fmt_num(candle.get('o'))} H={fmt_num(candle.get('h'))} "
                    f"L={fmt_num(candle.get('l'))} C={fmt_num(candle.get('c'))} "
                    f"V={fmt_num(candle.get('v'))}"
                )
            print()

    print_order_errors(journal, args.order_errors)
    missed = collect_missed_candidates(
        journal,
        limit=args.missed_candidates,
        lookahead_hours=args.missed_lookahead_hours,
        interval=args.interval,
        end_ms=end_ms,
    )
    print_missed_candidates(missed)

    flag_counts: Counter[str] = Counter()
    for trade in closed + open_trades:
        submit = match_submit(trade, journal)
        metrics = candle_mfe_mae(trade, candle_cache.get(trade.coin, []))
        flag_counts.update(trade_flags(trade, submit, metrics))
    if flag_counts:
        print("Flag Summary")
        print("-" * 80)
        for flag, count in flag_counts.most_common():
            print(f"{flag}: {count}")
        print()

    print("Interpretation Cheatsheet")
    print("-" * 80)
    print("wrong_quickly_no_mfe: trade almost never went in our favor; entry/trigger likely false or too early.")
    print("short_above_vwap / long_below_vwap: direction fights intraday value unless rejection is very clear.")
    print("borderline_noise_stop: stop is only slightly beyond current noise floor; easy wick-out zone.")
    print("legacy_or_manual_close: close fill is not attributable to a new submit in this window.")
    if args.write_lessons:
        lessons = build_lessons(
            since_ms=since_ms,
            end_ms=end_ms,
            closed=closed,
            open_trades=open_trades,
            journal=journal,
            candle_cache=candle_cache,
            missed=missed,
        )
        out = Path(args.write_lessons)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(json.dumps(lessons, indent=2, ensure_ascii=False) + "\n")
        print(f"\nwrote OHLC lessons: {out}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
