"""
Agente Risk Monitor de Leonex - Event-Driven (in vivo).

Evalua el estado de RIESGO en tiempo real, complementario al Drift Monitor
(este ultimo mira la salud estadistica de la estrategia; este de aqui mira
metricas de cartera y mercado en vivo).

Eventos monitorizados (cada uno con severidad INFO | WARNING | CRITICAL):

    DRAWDOWN          equity actual vs HWM historico
    DAILY_LOSS        P&L del dia en % del equity
    CONCENTRATION     peso de la posicion mas grande / equity
    LEVERAGE          notional total / equity
    NEAR_STOP         distancia entre precio actual y SL del Triple Barrier
                      (max WARNING por defecto: NO pausa. El Close Monitor
                      cierra la posicion cuando toca su SL; pausar el sistema
                      entero por una posicion cerca del stop creaba deadlock.
                      LEONEX_NEAR_STOP_PAUSE=1 restaura el CRITICAL antiguo.)
    VOL_SPIKE         z-score de sigma_20d vs sigma_60d (mercado nervioso)

CRITICAL  →  activa el flag PAUSE_NEW_TRADES (mismo mecanismo que el Drift
              Monitor). El executor deja de abrir posiciones nuevas hasta
              que se llame al reset.

WARNING   →  registra el evento pero NO pausa. Indica al usuario que vigile.

INFO      →  observabilidad pura, no requiere accion.

Uso:
    python agents/agente_risk_monitor.py
    python agents/agente_risk_monitor.py --max-drawdown 0.10  # 10% en vez del 15%
    python agents/agente_risk_monitor.py --reset-pause        # desactiva flag

Para reanudar tras una pausa CRITICAL, revisa primero la causa en
`risk_events` (o en el card del dashboard) y luego corre `--reset-pause`.
"""

from __future__ import annotations

import argparse
import json
import logging
import math
import os
import sqlite3
import sys
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta
try:
    from datetime import UTC  # Python 3.11+
except ImportError:  # Python 3.10
    from datetime import timezone
    UTC = timezone.utc
from pathlib import Path
from typing import Optional

import pandas as pd

sys.path.insert(0, str(Path(__file__).resolve().parent))
from alpaca_client import fetch_snapshot, snapshot_to_dict  # noqa: E402
try:
    from agente_drift_monitor import (  # noqa: E402
        set_system_state, get_system_state, get_pause_flag, ensure_schema as drift_ensure_schema,
    )
except ImportError:
    # Fallback minimal si el drift monitor no esta disponible
    def set_system_state(key, value, db_path=None): pass
    def get_system_state(key, default=None, db_path=None): return default
    def get_pause_flag(db_path=None): return False
    def drift_ensure_schema(db_path=None): pass


PROJECT_ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = PROJECT_ROOT / "data"
LOGS_DIR = PROJECT_ROOT / "logs"
DASHBOARD_DATA_DIR = PROJECT_ROOT / "dashboard" / "data"
DB_PATH = DATA_DIR / "Leonex.sqlite"
REPORT_PATH = DASHBOARD_DATA_DIR / "risk_report.json"

# ─────────────────────────────────────────────────────────────────────────────
# Umbrales por defecto. Conservadores; el usuario puede sobreescribir por CLI.
# Las cifras estan calibradas asumiendo cuenta paper $100k y sizing 5%.
# ─────────────────────────────────────────────────────────────────────────────
DEFAULT_MAX_DRAWDOWN          = 0.15   # 15% drawdown desde HWM = CRITICAL
DEFAULT_DAILY_LOSS_CRIT       = 0.05   # -5% en un dia = CRITICAL
DEFAULT_DAILY_LOSS_WARN       = 0.02   # -2% en un dia = WARNING
DEFAULT_MAX_POSITION_PCT      = 0.25   # una posicion > 25% equity = WARNING
DEFAULT_MAX_LEVERAGE          = 1.10   # notional / equity > 1.1 = CRITICAL
DEFAULT_NEAR_STOP_CRIT_FRAC   = 0.85   # >= 85% del path entry→SL = CRITICAL
DEFAULT_NEAR_STOP_WARN_FRAC   = 0.60   # >= 60% del path entry→SL = WARNING
DEFAULT_VOL_SPIKE_ZSCORE      = 2.5    # |z| >= 2.5 sigma vs baseline = INFO

SEVERITY_LEVELS = {"INFO": 0, "WARNING": 1, "CRITICAL": 2}


@dataclass
class RiskEvent:
    timestamp: str
    kind: str               # DRAWDOWN, DAILY_LOSS, CONCENTRATION, LEVERAGE, NEAR_STOP, VOL_SPIKE
    severity: str           # INFO | WARNING | CRITICAL
    value: float
    threshold: float
    ticker: Optional[str] = None
    message: str = ""


@dataclass
class RiskReport:
    generated_at: str
    alpaca_configured: bool
    pause_new_trades: bool
    pause_reason: Optional[str]
    overall_severity: str   # INFO | WARNING | CRITICAL
    # Metricas crudas
    equity: float
    hwm_equity: float
    drawdown_pct: float
    pnl_today_pct: float
    concentration_pct: float
    hhi: float
    leverage: float
    avg_distance_to_sl_pct: Optional[float]
    vol_zscore_recent: Optional[float]
    # Eventos
    events: list[RiskEvent] = field(default_factory=list)
    recent_events_history: list[dict] = field(default_factory=list)
    equity_curve: list[dict] = field(default_factory=list)


# ─────────────────────────────────────────────────────────────────────────────
# Schema
# ─────────────────────────────────────────────────────────────────────────────

def ensure_schema(db_path: Path = DB_PATH) -> None:
    """Crea las tablas necesarias si no existen. Tambien hace drift_ensure_schema
    para que system_state este disponible (compartido con el Drift Monitor)."""
    drift_ensure_schema(db_path)
    with sqlite3.connect(db_path) as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS equity_snapshots (
                date TEXT PRIMARY KEY,
                equity REAL NOT NULL,
                cash REAL,
                buying_power REAL,
                pnl_today REAL,
                pnl_today_pct REAL,
                positions_count INTEGER,
                n_long INTEGER,
                n_short INTEGER
            )
            """
        )
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS risk_events (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT NOT NULL,
                kind TEXT NOT NULL,
                severity TEXT NOT NULL,
                value REAL,
                threshold REAL,
                ticker TEXT,
                message TEXT
            )
            """
        )
        conn.commit()


# ─────────────────────────────────────────────────────────────────────────────
# Equity curve / HWM
# ─────────────────────────────────────────────────────────────────────────────

def save_equity_snapshot(snap, db_path: Path = DB_PATH) -> None:
    """Persiste el snapshot diario (UPSERT por fecha). Idempotente: ejecutar
    el agente varias veces el mismo dia solo actualiza el ultimo valor."""
    if not snap.configured or snap.account is None:
        return
    today = datetime.now(UTC).strftime("%Y-%m-%d")
    a = snap.account
    n_long = sum(1 for p in snap.positions if p.side == "long")
    n_short = sum(1 for p in snap.positions if p.side == "short")
    with sqlite3.connect(db_path) as conn:
        conn.execute(
            """
            INSERT INTO equity_snapshots
                (date, equity, cash, buying_power, pnl_today, pnl_today_pct,
                 positions_count, n_long, n_short)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(date) DO UPDATE SET
                equity=excluded.equity,
                cash=excluded.cash,
                buying_power=excluded.buying_power,
                pnl_today=excluded.pnl_today,
                pnl_today_pct=excluded.pnl_today_pct,
                positions_count=excluded.positions_count,
                n_long=excluded.n_long,
                n_short=excluded.n_short
            """,
            (today, float(a.equity), float(a.cash), float(a.buying_power),
             float(a.pnl_today), float(a.pnl_today_pct),
             len(snap.positions), n_long, n_short),
        )
        conn.commit()


def load_equity_curve(db_path: Path = DB_PATH, days: int = 90) -> pd.DataFrame:
    """Carga la curva de equity de los ultimos N dias."""
    if not db_path.exists():
        return pd.DataFrame(columns=["date", "equity"])
    cutoff = (datetime.now(UTC) - timedelta(days=days)).strftime("%Y-%m-%d")
    with sqlite3.connect(db_path) as conn:
        df = pd.read_sql(
            "SELECT date, equity, pnl_today, pnl_today_pct, positions_count "
            "FROM equity_snapshots WHERE date >= ? ORDER BY date ASC",
            conn, params=(cutoff,),
        )
    return df


def compute_hwm(eq_curve: pd.DataFrame, current_equity: float) -> float:
    """High Water Mark: maximo equity historico observado, incluyendo el actual."""
    if eq_curve.empty:
        return float(current_equity)
    return float(max(eq_curve["equity"].max(), current_equity))


# ─────────────────────────────────────────────────────────────────────────────
# Metricas de riesgo
# ─────────────────────────────────────────────────────────────────────────────

def compute_concentration(snap, equity: float) -> tuple[float, float]:
    """Devuelve (max_position_pct, HHI). HHI sobre |market_value|."""
    if equity <= 0 or not snap.positions:
        return 0.0, 0.0
    weights = [abs(p.market_value) / equity for p in snap.positions]
    max_w = max(weights) if weights else 0.0
    # HHI: solo significativo si normalizamos sobre suma de pesos (no equity)
    total_w = sum(weights)
    if total_w <= 0:
        return max_w, 0.0
    norm = [w / total_w for w in weights]
    hhi = sum(w * w for w in norm)
    return float(max_w), float(hhi)


def compute_leverage(snap, equity: float) -> float:
    """Notional bruto / equity. >1 indica margen efectivo."""
    if equity <= 0:
        return 0.0
    notional = sum(abs(p.market_value) for p in snap.positions)
    return float(notional / equity)


def compute_distance_to_sl(snap, db_path: Path = DB_PATH
                           ) -> tuple[Optional[float], dict[str, float]]:
    """Para cada posicion abierta, calcula a que fraccion del path entry→SL ha
    llegado el precio actual. 0.0 = en el entry, 1.0 = en el SL.

    Une snap.positions con open_trades (Triple Barrier vivo). Si la posicion
    no esta en open_trades, se ignora.

    Devuelve (media o None, dict ticker→fraccion).
    """
    if not db_path.exists() or not snap.positions:
        return None, {}
    with sqlite3.connect(db_path) as conn:
        try:
            rows = conn.execute(
                "SELECT ticker, side, entry_price, sl_pct FROM open_trades "
                "WHERE status = 'open'"
            ).fetchall()
        except sqlite3.OperationalError:
            return None, {}
    open_trades = {r[0]: {"side": int(r[1]), "entry_price": float(r[2]),
                          "sl_pct": float(r[3])} for r in rows}
    fractions: dict[str, float] = {}
    for p in snap.positions:
        # alpaca_client devuelve symbol en formato Alpaca; reconvertir crypto si toca
        sym_norm = p.symbol.replace("/USD", "-USD") if "/" in p.symbol else p.symbol
        ot = open_trades.get(sym_norm) or open_trades.get(p.symbol)
        if not ot or ot["entry_price"] <= 0 or ot["sl_pct"] <= 0:
            continue
        entry = ot["entry_price"]
        price_now = p.current_price
        if ot["side"] == 1:
            # LONG: SL en entry * (1 - sl_pct). Path negativo.
            sl_price = entry * (1.0 - ot["sl_pct"])
            traveled = (entry - price_now) / max(entry - sl_price, 1e-9)
        else:
            sl_price = entry * (1.0 + ot["sl_pct"])
            traveled = (price_now - entry) / max(sl_price - entry, 1e-9)
        fractions[p.symbol] = float(max(0.0, min(traveled, 2.0)))  # cap 2x para info
    if not fractions:
        return None, {}
    avg = float(sum(fractions.values()) / len(fractions))
    return avg, fractions


def compute_vol_zscore(db_path: Path = DB_PATH,
                       recent_days: int = 20,
                       baseline_days: int = 60) -> Optional[float]:
    """Z-score del sigma del universo ponderado por equity reciente vs baseline.

    Toma la tabla `prices` y calcula la sigma EWM sobre el log-return
    del SPY (proxy de mercado) en las dos ventanas. Si no hay SPY,
    cae al primer ticker disponible. Devuelve None si insuficientes datos.
    """
    if not db_path.exists():
        return None
    try:
        with sqlite3.connect(db_path) as conn:
            df = pd.read_sql(
                "SELECT date, close FROM prices WHERE ticker='SPY' ORDER BY date ASC",
                conn,
            )
            if len(df) < baseline_days + 10:
                # Fallback: cualquier ticker con suficientes filas
                df = pd.read_sql(
                    "SELECT ticker, date, close FROM prices "
                    "WHERE ticker IN (SELECT ticker FROM prices GROUP BY ticker "
                    "HAVING COUNT(*) >= ? LIMIT 1) ORDER BY date ASC",
                    conn, params=(baseline_days + 10,),
                )
        if len(df) < baseline_days + 10:
            return None
        import numpy as np
        rets = np.log(df["close"] / df["close"].shift(1)).dropna()
        if len(rets) < baseline_days + 5:
            return None
        sigma_recent = float(rets.iloc[-recent_days:].std())
        sigma_baseline = float(rets.iloc[-baseline_days:].std())
        sigma_std = float(rets.iloc[-baseline_days:].std(ddof=1))
        if sigma_baseline <= 0 or sigma_std <= 0:
            return None
        # z-score normalizado: cuanto se desvia sigma_recent del baseline
        # como factor multiplicativo
        z = (sigma_recent - sigma_baseline) / (sigma_baseline / math.sqrt(baseline_days))
        if not math.isfinite(z):
            return None
        return float(z)
    except Exception:
        return None


# ─────────────────────────────────────────────────────────────────────────────
# Evaluacion de eventos
# ─────────────────────────────────────────────────────────────────────────────

def evaluate_events(
    equity: float, hwm_equity: float, pnl_today_pct: float,
    concentration_pct: float, leverage: float,
    avg_distance_to_sl: Optional[float],
    per_ticker_distance: dict[str, float],
    vol_zscore: Optional[float],
    *,
    max_drawdown: float = DEFAULT_MAX_DRAWDOWN,
    daily_loss_crit: float = DEFAULT_DAILY_LOSS_CRIT,
    daily_loss_warn: float = DEFAULT_DAILY_LOSS_WARN,
    max_position_pct: float = DEFAULT_MAX_POSITION_PCT,
    max_leverage: float = DEFAULT_MAX_LEVERAGE,
    near_stop_crit_frac: float = DEFAULT_NEAR_STOP_CRIT_FRAC,
    near_stop_warn_frac: float = DEFAULT_NEAR_STOP_WARN_FRAC,
    vol_spike_z: float = DEFAULT_VOL_SPIKE_ZSCORE,
    near_stop_pause: bool = False,
) -> tuple[list[RiskEvent], float]:
    """Aplica umbrales y devuelve lista de eventos disparados + drawdown calculado."""
    ts = datetime.now(UTC).isoformat()
    events: list[RiskEvent] = []
    drawdown = 0.0 if hwm_equity <= 0 else (equity / hwm_equity) - 1.0

    # 1) DRAWDOWN
    if drawdown <= -max_drawdown:
        events.append(RiskEvent(
            timestamp=ts, kind="DRAWDOWN", severity="CRITICAL",
            value=drawdown, threshold=-max_drawdown,
            message=f"Drawdown {drawdown*100:+.2f}% >= limite {-max_drawdown*100:.1f}%",
        ))
    elif drawdown <= -max_drawdown * 0.66:
        events.append(RiskEvent(
            timestamp=ts, kind="DRAWDOWN", severity="WARNING",
            value=drawdown, threshold=-max_drawdown * 0.66,
            message=f"Drawdown {drawdown*100:+.2f}% acercandose al limite",
        ))

    # 2) DAILY_LOSS
    if pnl_today_pct <= -daily_loss_crit:
        events.append(RiskEvent(
            timestamp=ts, kind="DAILY_LOSS", severity="CRITICAL",
            value=pnl_today_pct, threshold=-daily_loss_crit,
            message=f"Perdida diaria {pnl_today_pct*100:+.2f}% >= -{daily_loss_crit*100:.1f}%",
        ))
    elif pnl_today_pct <= -daily_loss_warn:
        events.append(RiskEvent(
            timestamp=ts, kind="DAILY_LOSS", severity="WARNING",
            value=pnl_today_pct, threshold=-daily_loss_warn,
            message=f"Perdida diaria {pnl_today_pct*100:+.2f}% — vigilar",
        ))

    # 3) CONCENTRATION
    if concentration_pct >= max_position_pct:
        events.append(RiskEvent(
            timestamp=ts, kind="CONCENTRATION", severity="WARNING",
            value=concentration_pct, threshold=max_position_pct,
            message=f"Posicion mas grande = {concentration_pct*100:.1f}% del equity",
        ))

    # 4) LEVERAGE
    if leverage > max_leverage:
        events.append(RiskEvent(
            timestamp=ts, kind="LEVERAGE", severity="CRITICAL",
            value=leverage, threshold=max_leverage,
            message=f"Leverage {leverage:.2f}x > {max_leverage:.2f}x permitido",
        ))

    # 5) NEAR_STOP (por ticker)
    # Por defecto NEAR_STOP ya NO escala a CRITICAL (no pausa el sistema):
    # con el scheduler interno + close_monitor en execute, la posicion que
    # toca su SL se cierra sola en el siguiente tick (<=15 min). Pausar TODAS
    # las entradas nuevas porque UNA posicion esta cerca de su stop creaba un
    # deadlock permanente: los trailing stops mantienen posiciones viviendo
    # en el 85-100% del path entry→SL, la pausa nunca se levantaba y el
    # executor saltaba el plan entero (todo SKIPPED). El riesgo por-posicion
    # lo gestiona el Close Monitor; el freno global queda para DRAWDOWN,
    # DAILY_LOSS y LEVERAGE. Set LEONEX_NEAR_STOP_PAUSE=1 (o --near-stop-pause)
    # para restaurar el comportamiento antiguo (CRITICAL → pausa).
    near_stop_top_severity = "CRITICAL" if near_stop_pause else "WARNING"
    for ticker, frac in per_ticker_distance.items():
        if frac >= near_stop_crit_frac:
            events.append(RiskEvent(
                timestamp=ts, kind="NEAR_STOP", severity=near_stop_top_severity,
                value=frac, threshold=near_stop_crit_frac, ticker=ticker,
                message=f"{ticker} a {frac*100:.0f}% del path entry→SL",
            ))
        elif frac >= near_stop_warn_frac:
            events.append(RiskEvent(
                timestamp=ts, kind="NEAR_STOP", severity="WARNING",
                value=frac, threshold=near_stop_warn_frac, ticker=ticker,
                message=f"{ticker} a {frac*100:.0f}% del path entry→SL",
            ))

    # 6) VOL_SPIKE (info, no pausa)
    if vol_zscore is not None and abs(vol_zscore) >= vol_spike_z:
        events.append(RiskEvent(
            timestamp=ts, kind="VOL_SPIKE", severity="INFO",
            value=vol_zscore, threshold=vol_spike_z,
            message=f"Vol z-score = {vol_zscore:+.2f} (mercado fuera de rango)",
        ))

    return events, drawdown


def severity_max(events: list[RiskEvent]) -> str:
    """Devuelve la severidad maxima de la lista. Si vacia, INFO."""
    if not events:
        return "INFO"
    levels = [SEVERITY_LEVELS.get(e.severity, 0) for e in events]
    max_level = max(levels)
    for name, lvl in SEVERITY_LEVELS.items():
        if lvl == max_level:
            return name
    return "INFO"


# ─────────────────────────────────────────────────────────────────────────────
# Persistencia
# ─────────────────────────────────────────────────────────────────────────────

def save_events(events: list[RiskEvent], db_path: Path = DB_PATH) -> None:
    if not events:
        return
    with sqlite3.connect(db_path) as conn:
        conn.executemany(
            """
            INSERT INTO risk_events
                (timestamp, kind, severity, value, threshold, ticker, message)
            VALUES (?, ?, ?, ?, ?, ?, ?)
            """,
            [(e.timestamp, e.kind, e.severity, float(e.value), float(e.threshold),
              e.ticker, e.message) for e in events],
        )
        conn.commit()


def load_recent_events(db_path: Path = DB_PATH, limit: int = 50) -> list[dict]:
    if not db_path.exists():
        return []
    with sqlite3.connect(db_path) as conn:
        rows = conn.execute(
            "SELECT timestamp, kind, severity, value, threshold, ticker, message "
            "FROM risk_events ORDER BY id DESC LIMIT ?",
            (limit,),
        ).fetchall()
    cols = ["timestamp", "kind", "severity", "value", "threshold", "ticker", "message"]
    return [dict(zip(cols, r)) for r in rows]


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

def main() -> int:
    parser = argparse.ArgumentParser(description="Agente Risk Monitor de Leonex")
    parser.add_argument("--max-drawdown", type=float, default=DEFAULT_MAX_DRAWDOWN)
    parser.add_argument("--daily-loss-crit", type=float, default=DEFAULT_DAILY_LOSS_CRIT)
    parser.add_argument("--daily-loss-warn", type=float, default=DEFAULT_DAILY_LOSS_WARN)
    parser.add_argument("--max-position-pct", type=float, default=DEFAULT_MAX_POSITION_PCT)
    parser.add_argument("--max-leverage", type=float, default=DEFAULT_MAX_LEVERAGE)
    parser.add_argument("--near-stop-crit", type=float, default=DEFAULT_NEAR_STOP_CRIT_FRAC)
    parser.add_argument("--near-stop-warn", type=float, default=DEFAULT_NEAR_STOP_WARN_FRAC)
    parser.add_argument("--vol-spike-z", type=float, default=DEFAULT_VOL_SPIKE_ZSCORE)
    parser.add_argument("--near-stop-pause", action="store_true",
                        default=os.environ.get("LEONEX_NEAR_STOP_PAUSE", "").strip()
                        in ("1", "true", "yes"),
                        help="Restaura el comportamiento antiguo: NEAR_STOP >= "
                             "crit-frac escala a CRITICAL y pausa nuevas entradas. "
                             "Por defecto NEAR_STOP es solo WARNING (el Close "
                             "Monitor cierra la posicion; no se frena el sistema). "
                             "Tambien via env LEONEX_NEAR_STOP_PAUSE=1.")
    parser.add_argument("--reset-pause", action="store_true",
                        help="Desactiva PAUSE_NEW_TRADES y limpia la razon.")
    args = parser.parse_args()

    LOGS_DIR.mkdir(parents=True, exist_ok=True)
    DASHBOARD_DATA_DIR.mkdir(parents=True, exist_ok=True)
    ensure_schema(DB_PATH)
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
        handlers=[
            logging.FileHandler(LOGS_DIR / "agente_risk_monitor.log", encoding="utf-8"),
            logging.StreamHandler(),
        ],
    )
    log = logging.getLogger("agente_risk_monitor")

    if args.reset_pause:
        set_system_state("pause_new_trades", "false", DB_PATH)
        set_system_state("pause_reason", "", DB_PATH)
        print("[OK] PAUSE_NEW_TRADES desactivado y razon limpiada.")
        return 0

    # 1) Snapshot Alpaca
    snap = fetch_snapshot()
    if not snap.configured or snap.account is None:
        log.warning("Alpaca no configurado o sin cuenta: %s", snap.error)
        # No podemos evaluar nada sin cuenta — solo escribimos un reporte vacio
        report = RiskReport(
            generated_at=datetime.now(UTC).isoformat(),
            alpaca_configured=bool(snap.configured),
            pause_new_trades=get_pause_flag(DB_PATH),
            pause_reason=get_system_state("pause_reason", "", DB_PATH) or None,
            overall_severity="INFO",
            equity=0.0, hwm_equity=0.0, drawdown_pct=0.0,
            pnl_today_pct=0.0, concentration_pct=0.0, hhi=0.0,
            leverage=0.0, avg_distance_to_sl_pct=None,
            vol_zscore_recent=None,
            events=[],
            recent_events_history=load_recent_events(DB_PATH),
            equity_curve=[],
        )
        REPORT_PATH.write_text(json.dumps(_serialize_report(report), indent=2,
                                         ensure_ascii=False, default=str),
                               encoding="utf-8")
        return 0

    # 2) Guardar snapshot diario y leer historial
    save_equity_snapshot(snap, DB_PATH)
    eq_df = load_equity_curve(DB_PATH, days=120)
    equity = float(snap.account.equity)
    hwm = compute_hwm(eq_df, equity)
    pnl_today_pct = float(snap.account.pnl_today_pct)

    # 3) Metricas
    conc, hhi = compute_concentration(snap, equity)
    lev = compute_leverage(snap, equity)
    avg_dist, per_ticker_dist = compute_distance_to_sl(snap, DB_PATH)
    vol_z = compute_vol_zscore(DB_PATH)

    # 4) Eventos
    events, drawdown = evaluate_events(
        equity=equity, hwm_equity=hwm, pnl_today_pct=pnl_today_pct,
        concentration_pct=conc, leverage=lev,
        avg_distance_to_sl=avg_dist, per_ticker_distance=per_ticker_dist,
        vol_zscore=vol_z,
        max_drawdown=args.max_drawdown,
        daily_loss_crit=args.daily_loss_crit,
        daily_loss_warn=args.daily_loss_warn,
        max_position_pct=args.max_position_pct,
        max_leverage=args.max_leverage,
        near_stop_crit_frac=args.near_stop_crit,
        near_stop_warn_frac=args.near_stop_warn,
        vol_spike_z=args.vol_spike_z,
        near_stop_pause=bool(args.near_stop_pause),
    )
    save_events(events, DB_PATH)
    overall = severity_max(events)

    # 5) Sync pausa con estado REAL (simetrico):
    #   - Si hay CRITICAL -> activar pausa con la razon actual.
    #   - Si NO hay CRITICAL y la pausa vigente fue puesta por risk_monitor
    #     (razon empieza con "risk_monitor:"), DESACTIVARLA. Esto evita el bug
    #     donde el pause queda vivo con near_stops fantasma despues de que
    #     close_monitor cerro esas posiciones. Otras razones (drift, executor,
    #     manual) NO se tocan aqui.
    critical = [e for e in events if e.severity == "CRITICAL"]
    prev_reason = get_system_state("pause_reason", "", DB_PATH) or ""
    if critical:
        reasons = "; ".join(f"{e.kind}({e.message})" for e in critical[:3])
        set_system_state("pause_new_trades", "true", DB_PATH)
        set_system_state("pause_reason", f"risk_monitor: {reasons}", DB_PATH)
        log.warning("CRITICAL detectado — PAUSE_NEW_TRADES activado: %s", reasons)
    else:
        if prev_reason.startswith("risk_monitor:"):
            set_system_state("pause_new_trades", "false", DB_PATH)
            set_system_state("pause_reason", "", DB_PATH)
            log.info("Sin CRITICAL — PAUSE_NEW_TRADES desactivado (era: %s)",
                     prev_reason)
    paused = get_pause_flag(DB_PATH)
    pause_reason = get_system_state("pause_reason", "", DB_PATH) or None

    # 6) Exportar JSON al dashboard
    report = RiskReport(
        generated_at=datetime.now(UTC).isoformat(),
        alpaca_configured=True,
        pause_new_trades=paused,
        pause_reason=pause_reason,
        overall_severity=overall,
        equity=equity, hwm_equity=hwm, drawdown_pct=drawdown,
        pnl_today_pct=pnl_today_pct,
        concentration_pct=conc, hhi=hhi, leverage=lev,
        avg_distance_to_sl_pct=avg_dist,
        vol_zscore_recent=vol_z,
        events=events,
        recent_events_history=load_recent_events(DB_PATH),
        equity_curve=eq_df.to_dict(orient="records") if not eq_df.empty else [],
    )
    REPORT_PATH.write_text(json.dumps(_serialize_report(report), indent=2,
                                     ensure_ascii=False, default=str),
                           encoding="utf-8")
    log.info("Risk report exportado → %s | severity=%s | events=%d",
             REPORT_PATH, overall, len(events))

    # 7) Resumen consola
    sep = "=" * 64
    print(f"\n{sep}")
    print(f"Leonex -- Risk Monitor (severity={overall})")
    print(sep)
    print(f"Equity     : ${equity:,.2f}  |  HWM ${hwm:,.2f}")
    print(f"Drawdown   : {drawdown*100:+.2f}%")
    print(f"P&L hoy    : {pnl_today_pct*100:+.2f}%")
    print(f"Max pos    : {conc*100:.1f}% del equity  |  HHI={hhi:.3f}")
    print(f"Leverage   : {lev:.2f}x")
    if avg_dist is not None:
        print(f"Avg→SL     : {avg_dist*100:.0f}% del path")
    if vol_z is not None:
        print(f"Vol z      : {vol_z:+.2f}")
    print(f"PAUSED     : {paused}  ({pause_reason or '—'})")
    if events:
        print("\n[Eventos disparados]")
        for e in events:
            tag = {"INFO": "[INFO]", "WARNING": "[WARN]", "CRITICAL": "[CRIT]"}[e.severity]
            t = f" {e.ticker}" if e.ticker else ""
            print(f"  {tag} {e.kind}{t}: {e.message}")
    else:
        print("\nNingun evento disparado. Riesgo dentro de rangos normales.")
    print(sep)
    return 0


def _serialize_report(report: RiskReport) -> dict:
    payload = {k: v for k, v in asdict(report).items() if k not in ("events",)}
    payload["events"] = [asdict(e) for e in report.events]
    return payload


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