"""
Agente Executor de Leonex - Fase 1 (solo lectura).

Conecta con Alpaca paper trading y exporta a `dashboard/data/paper_report.json`:
    - Estado de la cuenta (equity, buying power, P&L del dia)
    - Posiciones abiertas
    - Ordenes recientes

Tambien genera el "plan del dia": que senales filtradas por Meta-Labeling
estan vivas hoy y se deberian abrir o cerrar. En esta fase NO se envia
ninguna orden; el plan se muestra en el dashboard para tu aprobacion.

Uso:
    python agents/agente_executor.py
    python agents/agente_executor.py --threshold 0.55
    python agents/agente_executor.py --diagnose   # solo test de conexion

Cuando confirmes que el plan es razonable, en la Fase 2 anadimos --execute
para enviar las ordenes a Alpaca paper. Cero ordenes se envian sin esa flag.
"""

from __future__ import annotations

import argparse
import json
import logging
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 (  # noqa: E402
    AlpacaSnapshot, fetch_snapshot, snapshot_to_dict,
    submit_market_order, get_latest_quote, is_crypto_symbol, has_open_position,
)
try:
    from agente_drift_monitor import get_pause_flag, get_system_state  # noqa: E402
except ImportError:
    def get_pause_flag(db_path=None) -> bool:  # fallback si el modulo no esta
        return False
    def get_system_state(key, default=None, db_path=None):  # fallback
        return default

try:
    from agente_hrp import compute_hrp_for_tickers  # noqa: E402
except ImportError:
    def compute_hrp_for_tickers(tickers, window_days=60, db_path=None):  # fallback
        if not tickers:
            return {}
        eq = 1.0 / len(tickers)
        return {t: eq for t in tickers}

# Sizing avanzado: Kelly fraccional + Volatility targeting (Thorp/Carver)
import sizing as sz_module  # noqa: E402

# Judge cualitativo + Narrator (LLM local + plantilla fallback)
try:
    from agente_judge import judge_plan, serialize_report as serialize_judge  # noqa: E402
    from narrator import narrate_action  # noqa: E402
    _JUDGE_AVAILABLE = True
except ImportError:
    _JUDGE_AVAILABLE = False
    def judge_plan(*a, **k): return None
    def serialize_judge(r): return {}
    def narrate_action(*a, **k): return ""

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 / "paper_report.json"

DEFAULT_STRATEGY = "regime_adaptive_v1_lo"
DEFAULT_META_THRESHOLD = 0.55
# Modo del Meta-Labeling como filtro del plan. "soft" por defecto porque el Meta
# actual tiene AUC OOF ~0.51 (≈ azar): no debe VETAR senales del primario, solo
# modular el sizing. Cambiar a "gate" cuando el Meta recupere edge (AUC > ~0.55).
DEFAULT_META_MODE = "soft"
# Fallback NEUTRAL de prob_win cuando un ticker no tiene prediccion Meta.
DEFAULT_MISSING_PROB = 0.5
DEFAULT_SIZING_PCT = 0.05   # 5% del equity por trade (fallback, modo "fixed")
# Tope de posiciones simultaneas. Bajado de 50 a 25 tras el incidente del
# 6-jul-2026 en el que el sistema abrio 74 posiciones con concentracion PGR
# al 58% del equity (sobreapalancamiento 2.5x). Configurable por env
# LEONEX_MAX_POSITIONS. Pon LEONEX_MAX_POSITIONS=0 para desactivarlo (no
# recomendado). El control de riesgo REAL es el Vol-Target de Carver + el
# max_position_pct por trade. El contador lee posiciones LIVE de Alpaca
# (snap.positions en build_actions), nunca una tabla local: si Alpaca tiene
# 0 abiertas no puede quedarse "pegado" ni bloquear.
MAX_POSITIONS_OPEN = int(os.getenv("LEONEX_MAX_POSITIONS", "25"))

# Sizing avanzado (Thorp + Carver)
DEFAULT_SIZING_METHOD = "kelly_vol_hrp"  # fixed | hrp | kelly | kelly_vol | kelly_vol_hrp
DEFAULT_KELLY_FRACTION = 0.5             # Half-Kelly (Thorp clasico)
DEFAULT_TARGET_VOL_ANNUAL = 0.20         # 20% vol anual portfolio (Carver default)
# Cap por trade bajado de 25% a 15% tras el incidente del 6-jul-2026 con PGR
# al 58% del equity. Si el sizing Kelly quisiera dar mas, se corta aqui.
DEFAULT_MAX_POSITION_PCT = 0.15          # 15% equity max por trade individual
DEFAULT_PAYOFF_B = 2.0                   # TP=2sigma, SL=1sigma → b=2.0
DEFAULT_AVG_CORRELATION = 0.30           # fallback si no tenemos matriz de correlaciones

# Riesgo fijo por trade (Van Tharp): cap que limita la posicion para que, si
# salta el stop del Triple Barrier, la perdida no supere este % del equity.
# 0.01 = 1%. Pon 0 para desactivarlo. RISK_STOP_SIGMA_MULT debe coincidir con
# el sl_mult de register_open_trade para que el cap refleje el stop real.
DEFAULT_RISK_PER_TRADE = 0.01
RISK_STOP_SIGMA_MULT = 1.0

# Universo Alpaca fallback: solo se usa si la query a la API Alpaca falla
# (Alpaca down, sin credenciales, sin SDK). En operacion normal pedimos la
# lista REAL a Alpaca via alpaca_client.get_tradable_symbols() — son
# miles de equities, no solo estos 12.
ALPACA_TRADABLE_FALLBACK = {
    "SPY", "QQQ", "IWM", "AAPL", "MSFT", "NVDA",
    "AMZN", "META", "GOOGL", "IBM", "BRK-B", "JNJ", "JPM", "V", "PG",
    "BTC-USD", "ETH-USD", "SOL-USD",
}


def _build_tradable_universe(logger: Optional[logging.Logger] = None) -> set[str]:
    """Devuelve el set de tickers operables. Estrategia:
        1) Pedimos la lista REAL a Alpaca via alpaca_client (con cache 1h).
        2) Si Alpaca falla / no esta configurado, fallback a la lista
           ALPACA_TRADABLE_FALLBACK + cualquier ticker que tengamos en
           `prices` (asumimos que si tenemos historico es porque lo
           descargamos como universo operable).
    Mantenemos crypto en formato Leonex (BTC-USD) — el alpaca_client lo
    traduce a Alpaca format (BTC/USD) al enviar la orden.
    """
    if logger is None:
        logger = logging.getLogger("executor")
    try:
        from alpaca_client import get_tradable_symbols
        real = get_tradable_symbols()
        if real:
            logger.info("Universo Alpaca: %d tickers reales desde API.", len(real))
            return real
    except Exception as exc:
        logger.warning("No se pudo leer get_tradable_symbols: %r", exc)

    # Fallback: hardcoded + lo que tengamos en BD
    universe = set(ALPACA_TRADABLE_FALLBACK)
    try:
        with sqlite3.connect(DB_PATH) as conn:
            for (tk,) in conn.execute("SELECT DISTINCT ticker FROM prices"):
                if tk:
                    universe.add(tk)
    except Exception:
        pass
    logger.warning("Universo Alpaca fallback: %d tickers (hardcoded + prices).",
                   len(universe))
    return universe


# Alias retrocompatible: si alguien importa el nombre antiguo, sigue funcionando
# como un set, solo que ahora es el fallback (no la lista que usa el executor
# en runtime).
ALPACA_TRADABLE_UNIVERSE = ALPACA_TRADABLE_FALLBACK
ALPACA_EQUITY_UNIVERSE = ALPACA_TRADABLE_FALLBACK

# Minimo notional Alpaca crypto (~$1 por orden), pero exigimos un piso util
MIN_CRYPTO_NOTIONAL_USD = 10.0


@dataclass
class PlanEntry:
    ticker: str
    side: int                  # +1 long
    entry_date: str
    signal_strategy: str
    prob_win: float
    regime: str
    rsi_14: float
    momentum_20: float
    in_universe: bool
    note: str = ""
    # Marca "soy del bridge de promovidas". Sin este campo, PlanEntry(**d)
    # lanzaba TypeError con los dicts del bridge (que traen is_bridge=True
    # desde el 6-jul) y el except descartaba TODAS las promovidas en
    # silencio — el carril validado llevaba dias sin llegar al executor.
    is_bridge: bool = False


@dataclass
class ExecutorAction:
    timestamp: str
    mode: str               # "dry_run" | "execute"
    ticker: str
    side: str               # "buy" | "sell"
    qty: float              # cantidad calculada
    sizing_pct: float
    requested_price: float
    prob_win: float
    strategy: str
    status: str             # "planned"|"submitted"|"filled"|"rejected"|"skipped"|"vetoed"
    alpaca_order_id: Optional[str] = None
    notes: str = ""
    # Judge cualitativo + narrativa
    judge_verdict: str = ""           # "APPROVE" | "CAUTION" | "VETO" | ""
    judge_reasons_against: list = field(default_factory=list)
    narrative: str = ""


@dataclass
class PaperReport:
    generated_at: str
    strategy: str
    meta_threshold: float
    sizing_pct: float
    mode: str               # "read_only" | "dry_run" | "execute"
    alpaca: dict
    # Sizing avanzado (Thorp + Carver)
    sizing_method: str = "fixed"          # fixed | hrp | kelly | kelly_vol
    kelly_fraction: float = 0.0
    target_vol_annual: float = 0.0
    max_position_pct: float = 0.0
    payoff_b: float = 0.0
    # Estado del flag PAUSE_NEW_TRADES (compartido con Drift/Risk Monitor)
    pause_new_trades: bool = False
    pause_reason: str = ""                # "drift_monitor: ..." | "risk_monitor: ..." | ""
    plan_today: list[PlanEntry] = field(default_factory=list)
    actions: list[ExecutorAction] = field(default_factory=list)
    universe: list[str] = field(default_factory=list)
    recent_actions_history: list[dict] = field(default_factory=list)
    # Trade journal: narrativa por accion + veredicto Judge
    judge_strictness: str = "liberal"
    auto_approve: bool = False
    # True salvo que el executor haya corrido realmente en modo "execute".
    # Es la unica fuente de verdad para el dashboard (Risk/Logs/Paper Trading).
    simulated_only: bool = True
    trade_journal: list[dict] = field(default_factory=list)


def build_plan_today(
    strategy: str = DEFAULT_STRATEGY,
    threshold: float = DEFAULT_META_THRESHOLD,
    db_path: Path = DB_PATH,
    meta_mode: str = DEFAULT_META_MODE,
    missing_prob: float = DEFAULT_MISSING_PROB,
) -> tuple[list[PlanEntry], Optional[str]]:
    """
    Calcula el plan de hoy: senales vivas hoy (signal != 0) filtradas por el
    Meta-Labeling segun `meta_mode`:

      - "gate": comportamiento clasico. Excluye la senal si prob_win < threshold.
        Solo tiene sentido cuando el Meta tiene edge real (AUC OOF > ~0.55).
      - "soft": el Meta NUNCA excluye; todas las senales del primario entran al
        plan y la prob_win solo modula el sizing (Kelly aguas abajo). Es el modo
        correcto cuando el Meta esta cerca del azar (AUC ~0.50) y no debe vetar.
      - "off": ignora el Meta por completo; prob_win neutral para todas.

    Ademas, si un ticker NO tiene prediccion Meta (caso normal de una senal nueva
    sin trade Triple-Barrier cerrado todavia), se usa `missing_prob` como fallback
    NEUTRAL en lugar del antiguo 0.0 — que descartaba la senal en silencio aunque
    el gate estuviera desactivado.
    """
    if not db_path.exists():
        return [], f"DB no existe: {db_path}"

    # Universo Alpaca DINAMICO: la API real de Alpaca (cache 1h en alpaca_client).
    # Lo calculamos ANTES de elegir la barra del plan para poder ignorar los
    # instrumentos NO operables (p.ej. los pares forex EURUSD=X/GBPUSD=X/USDJPY=X
    # que viven en active_universe como legacy).
    tradable = _build_tradable_universe()

    with sqlite3.connect(db_path) as conn:
        try:
            # Traemos las senales vivas (signal != 0) de los ultimos ~10 dias.
            # OJO: el forex cotiza dom-vie y el crypto 24/7, asi que MAX(date)
            # GLOBAL puede caer en una barra de fin de semana donde SOLO hay
            # forex/crypto y NINGUNA equity. Si tomaramos esa fecha como "hoy",
            # el plan quedaria secuestrado por un par FX no operable: ese es el
            # bug que dejaba el sistema plano dia tras dia con USDJPY=X como
            # unica fila (in_universe=False -> el executor la salta siempre).
            recent = pd.read_sql(
                "SELECT ticker, date, signal, regime, momentum_20, rsi_14 "
                "FROM signals WHERE strategy = ? AND signal != 0 "
                "AND date >= date((SELECT MAX(date) FROM signals "
                "WHERE strategy = ?), '-10 days')",
                conn, params=(strategy, strategy),
                parse_dates=["date"],
            )
        except Exception as exc:
            return [], f"signals_query_failed: {exc}"

        if recent.empty:
            return [], "no_signals_in_db"

        # Buscar el ultimo trade del Triple Barrier en esa fecha como entry_date
        # Si no existe (caso normal: el trade aun no ha sido evaluado por TB),
        # tomamos prob de la fila mas reciente del ticker en meta_predictions
        try:
            metas = pd.read_sql(
                "SELECT ticker, entry_date, prob_win FROM meta_predictions "
                "WHERE strategy = ?",
                conn, params=(strategy,),
                parse_dates=["entry_date"],
            )
        except Exception:
            metas = pd.DataFrame(columns=["ticker", "entry_date", "prob_win"])

    # Nos quedamos SOLO con senales de tickers operables en Alpaca y elegimos
    # como "barra de hoy" la fecha mas reciente que tenga al menos una senal
    # operable. Asi una barra FX-only de fin de semana no vacia el plan ni lo
    # llena de entradas que el executor siempre salta por fuera_de_universo.
    recent = recent[recent["ticker"].isin(tradable)]
    if recent.empty:
        return [], "sin_senales_operables_recientes"

    last_date = recent["date"].max()
    signals_today = recent[recent["date"] == last_date].copy()

    plan: list[PlanEntry] = []
    for _, row in signals_today.iterrows():
        ticker = str(row["ticker"])
        # Probabilidad: buscamos la mas reciente del ticker en metas (puede ser
        # de una entrada similar). Si no hay, usamos `missing_prob` (fallback
        # NEUTRAL), NO 0.0: una senal sin prediccion Meta no es una senal
        # "rechazada con confianza", solo es una sin dato.
        has_meta = False
        if not metas.empty:
            sub = metas[metas["ticker"] == ticker].sort_values("entry_date")
            if not sub.empty:
                prob = float(sub["prob_win"].iloc[-1])
                has_meta = True
            else:
                prob = missing_prob
        else:
            prob = missing_prob

        if meta_mode == "off":
            # Meta ignorado: prob neutral para todas, ningun descarte.
            prob = max(prob, missing_prob)
        elif meta_mode == "gate":
            # Gate clasico: solo excluye cuando hay prediccion Meta real y queda
            # por debajo del umbral. Las senales sin Meta pasan con prob neutral
            # (antes morian en silencio con prob=0.0).
            if has_meta and prob < threshold:
                continue
        # meta_mode == "soft": no se excluye nada; prob solo modula el sizing.
        plan.append(PlanEntry(
            ticker=ticker,
            side=int(row["signal"]),
            entry_date=str(pd.to_datetime(row["date"]).strftime("%Y-%m-%d")),
            signal_strategy=strategy,
            prob_win=round(prob, 4),
            regime=str(row["regime"]),
            rsi_14=round(float(row["rsi_14"]) if pd.notna(row["rsi_14"]) else 0.0, 2),
            momentum_20=round(float(row["momentum_20"]) if pd.notna(row["momentum_20"]) else 0.0, 4),
            in_universe=(ticker in tradable),
            note="ok" if ticker in tradable else "fuera_de_universo_alpaca",
        ))

    return plan, None


def ensure_actions_schema(db_path: Path) -> None:
    """Crea tablas executor_actions (historial) y open_trades (Triple Barrier vivo)."""
    with sqlite3.connect(db_path) as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS executor_actions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT NOT NULL,
                mode TEXT NOT NULL,
                ticker TEXT NOT NULL,
                side TEXT NOT NULL,
                qty REAL,
                sizing_pct REAL,
                requested_price REAL,
                prob_win REAL,
                strategy TEXT,
                status TEXT,
                alpaca_order_id TEXT,
                notes TEXT
            )
            """
        )
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS trade_journal (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT NOT NULL,
                mode TEXT NOT NULL,
                ticker TEXT NOT NULL,
                side TEXT NOT NULL,
                qty REAL,
                requested_price REAL,
                prob_win REAL,
                strategy TEXT,
                status TEXT,
                judge_verdict TEXT,
                judge_reasons_against TEXT,
                narrative TEXT
            )
            """
        )
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS open_trades (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                ticker TEXT NOT NULL,
                strategy TEXT NOT NULL,
                side INTEGER NOT NULL,
                qty REAL NOT NULL,
                entry_date TEXT NOT NULL,
                entry_price REAL NOT NULL,
                sigma_entry REAL NOT NULL,
                pt_pct REAL NOT NULL,
                sl_pct REAL NOT NULL,
                vert_barrier_days INTEGER NOT NULL,
                alpaca_buy_order_id TEXT,
                status TEXT NOT NULL DEFAULT 'open',
                exit_date TEXT,
                exit_price REAL,
                pnl_pct REAL,
                alpaca_sell_order_id TEXT,
                closed_at TEXT
            )
            """
        )
        conn.commit()


def save_actions(actions: list[ExecutorAction], db_path: Path = DB_PATH) -> None:
    if not actions:
        return
    with sqlite3.connect(db_path) as conn:
        conn.executemany(
            """
            INSERT INTO executor_actions
                (timestamp, mode, ticker, side, qty, sizing_pct, requested_price,
                 prob_win, strategy, status, alpaca_order_id, notes)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            [
                (a.timestamp, a.mode, a.ticker, a.side, float(a.qty),
                 float(a.sizing_pct), float(a.requested_price), float(a.prob_win),
                 a.strategy, a.status, a.alpaca_order_id, a.notes)
                for a in actions
            ],
        )
        # Trade journal con narrativa + veredicto del Judge
        conn.executemany(
            """
            INSERT INTO trade_journal
                (timestamp, mode, ticker, side, qty, requested_price,
                 prob_win, strategy, status, judge_verdict,
                 judge_reasons_against, narrative)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            [
                (a.timestamp, a.mode, a.ticker, a.side, float(a.qty),
                 float(a.requested_price), float(a.prob_win), a.strategy,
                 a.status, a.judge_verdict or "",
                 "|".join(a.judge_reasons_against or []),
                 a.narrative or "")
                for a in actions
            ],
        )
        conn.commit()


def load_recent_journal(db_path: Path = DB_PATH, limit: int = 50) -> list[dict]:
    """Devuelve las entradas mas recientes del trade_journal para el dashboard."""
    if not db_path.exists():
        return []
    with sqlite3.connect(db_path) as conn:
        try:
            rows = conn.execute(
                """
                SELECT timestamp, mode, ticker, side, qty, requested_price,
                       prob_win, strategy, status, judge_verdict,
                       judge_reasons_against, narrative
                FROM trade_journal
                ORDER BY id DESC LIMIT ?
                """,
                (limit,),
            ).fetchall()
        except sqlite3.OperationalError:
            return []
    cols = ["timestamp", "mode", "ticker", "side", "qty", "requested_price",
            "prob_win", "strategy", "status", "judge_verdict",
            "judge_reasons_against", "narrative"]
    return [dict(zip(cols, r)) for r in rows]


def load_recent_actions(db_path: Path = DB_PATH, limit: int = 60) -> list[dict]:
    """Carga las acciones del executor para el dashboard. Comportamiento
    reescrito el 11-jul-2026 tras la queja del usuario 'todo dice skipped
    max_posiciones cuando no hay posiciones'.

    Bug viejo: LIMIT 30 ORDER BY id DESC daba las 30 filas mas recientes
    por id. El executor procesa el plan ordenado por prob DESC e inserta
    en ese orden: las primeras 25 filas planeadas (planned/submitted) y
    luego decenas de skipped por 'cupo lleno'. Como las skipped tienen id
    mas alto, LIMIT 30 DESC solo devolvia skipped y las planned quedaban
    invisibles — el usuario nunca veia que iba a comprar.

    Comportamiento nuevo: devuelve TODAS las acciones del ULTIMO ciclo
    (ultimo timestamp del executor), con las mas importantes primero:
    planned/submitted/filled arriba, luego rejected, luego vetoed, luego
    skipped. Asi el usuario ve primero que iba a comprar y luego el resto
    como contexto. Cap a `limit` filas por si acaso."""
    if not db_path.exists():
        return []
    with sqlite3.connect(db_path) as conn:
        # 1) Timestamp del ultimo ciclo del executor
        row = conn.execute(
            "SELECT MAX(timestamp) FROM executor_actions"
        ).fetchone()
        last_ts = row[0] if row and row[0] else None
        if not last_ts:
            return []
        # 2) Todas las acciones de ese timestamp, priorizadas por status
        #    (planned/submitted/filled arriba, skipped abajo).
        rows = conn.execute(
            """
            SELECT timestamp, mode, ticker, side, qty, sizing_pct,
                   requested_price, prob_win, strategy, status, alpaca_order_id, notes
            FROM executor_actions
            WHERE timestamp = ?
            ORDER BY
                CASE status
                    WHEN 'submitted' THEN 1
                    WHEN 'filled' THEN 2
                    WHEN 'planned' THEN 3
                    WHEN 'rejected' THEN 4
                    WHEN 'vetoed' THEN 5
                    WHEN 'skipped' THEN 6
                    ELSE 7
                END,
                prob_win DESC
            LIMIT ?
            """,
            (last_ts, limit),
        ).fetchall()
    cols = ["timestamp","mode","ticker","side","qty","sizing_pct",
            "requested_price","prob_win","strategy","status","alpaca_order_id","notes"]
    return [dict(zip(cols, r)) for r in rows]


def compute_qty(equity: float, sizing_pct: float, price: float,
                is_crypto: bool = False) -> float:
    """Cantidad para invertir sizing_pct del equity en este ticker.

    - Equities: redondeada hacia abajo a entero (Alpaca paper rechaza
      fracciones para muchas ordenes equity). Si qty < 1, devuelve 0 (skip).
    - Crypto: se permiten fracciones (1 BTC vale ~$80k). Redondeo a 6
      decimales — Alpaca acepta hasta 9, pero 6 sobra para nuestros sizing.
      Si el notional < MIN_CRYPTO_NOTIONAL_USD, devuelve 0 (skip).
    """
    if price <= 0 or equity <= 0 or sizing_pct <= 0:
        return 0.0
    target_usd = equity * sizing_pct
    if is_crypto:
        qty = target_usd / price
        if qty * price < MIN_CRYPTO_NOTIONAL_USD:
            return 0.0
        # 6 decimales: para BTC = unidad mas pequena ~ $0.08, mas que suficiente
        return float(round(qty, 6))
    qty = int(target_usd / price)
    return float(qty)


def fetch_reentry_cooldown_tickers(db_path: Path,
                                   hours: float) -> dict[str, str]:
    """Tickers cuya ultima salida fue por STOP-LOSS (o rotura de tendencia)
    hace menos de `hours` horas. Devuelve {ticker: closed_at_iso}.

    Evita el bucle churn/martingala del scheduler interno: el close_monitor
    cierra por SL, la senal sigue viva en el daily plan, y el siguiente tick
    de 15 min recompraba el MISMO ticker con mas tamano (Kelly redimensiona
    con el capital liberado). Con este cooldown, un ticker cerrado por SL no
    es re-comprable hasta que pase la ventana.

    Config: env LEONEX_REENTRY_COOLDOWN_H (default 24; 0 desactiva).
    Solo aplica a cierres por close_sl y close_regime — un cierre por TP o
    timeout no indica que la senal fuera mala.
    """
    if hours <= 0 or not db_path.exists():
        return {}
    cutoff = (datetime.now(UTC) - timedelta(hours=hours)).isoformat()
    try:
        with sqlite3.connect(db_path) as conn:
            rows = conn.execute(
                "SELECT ticker, MAX(closed_at) FROM open_trades "
                "WHERE status IN ('closed_sl', 'closed_regime', "
                "                 'closed_close_sl', 'closed_close_regime') "
                "  AND closed_at IS NOT NULL AND closed_at >= ? "
                "GROUP BY ticker",
                (cutoff,),
            ).fetchall()
    except sqlite3.OperationalError:
        return {}
    return {str(r[0]): str(r[1]) for r in rows if r[0]}


def build_actions(
    plan: list[PlanEntry],
    snap: AlpacaSnapshot,
    sizing_pct: float,
    mode: str,
    strategy: str,
    logger: logging.Logger,
    sizing_method: str = DEFAULT_SIZING_METHOD,
    kelly_fraction_mult: float = DEFAULT_KELLY_FRACTION,
    target_vol_annual: float = DEFAULT_TARGET_VOL_ANNUAL,
    max_position_pct: float = DEFAULT_MAX_POSITION_PCT,
    payoff_b: float = DEFAULT_PAYOFF_B,
    db_path: Path = DB_PATH,
    judge_strictness: str = "liberal",
    meta_threshold: float = DEFAULT_META_THRESHOLD,
    narrator_mode: str = "ollama_or_template",
    risk_per_trade: float = DEFAULT_RISK_PER_TRADE,
) -> list[ExecutorAction]:
    """Para cada PlanEntry valida si se podria operar y devuelve la accion correspondiente.

    sizing_method:
        - "fixed"     → sizing_pct fijo (compatibilidad, 5% por defecto)
        - "hrp"       → HRP weights sobre budget = sizing_pct * N_operables
        - "kelly"     → Half-Kelly fraccional por trade (capeado a max_position_pct)
        - "kelly_vol" → Kelly fraccional + vol target del portfolio (Thorp+Carver),
                        combinado con HRP cuando hay >= 2 operables.

    No envia ordenes aqui — solo prepara la lista. El envio se decide en main()
    segun el modo.
    """
    actions: list[ExecutorAction] = []

    # ── FAIL-SAFE ANTI-PIRAMIDE: si el snapshot no pudo leer positions,
    # abortamos TODAS las BUYs. Sin este check, el executor cree que Alpaca
    # esta vacio y apila sobre posiciones existentes. Bug real del 6-jul-2026
    # (PGR 246 uds sobre 30). Marcamos cada entry como skipped y devolvemos.
    if getattr(snap, "positions_failed", False):
        logger.error(
            "ABORTO: snap.positions_failed=True (Alpaca fallo al leer "
            "posiciones). NO se enviaran BUYs para evitar apilar sobre "
            "posiciones existentes. Reintentar en el proximo ciclo.")
        timestamp = datetime.now(UTC).isoformat()
        for entry in plan:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy" if entry.side == 1 else "sell",
                qty=0.0, sizing_pct=sizing_pct, requested_price=0.0,
                prob_win=entry.prob_win, strategy=strategy,
                status="skipped",
                notes="fail_safe|snap_positions_failed_no_puedo_confirmar_posiciones_actuales",
            ))
        return actions

    open_symbols = {p.symbol for p in snap.positions}

    # ── Ordenes BUY aun vivas: NO re-enviar el mismo ticker ──────────────
    # Sin este guard, cuando una orden market queda 'accepted' sin fill
    # (mercado cerrado por festivo, p.ej. 3-jul-2026, o simbolo ilquido),
    # cada tick del scheduler re-enviaba la MISMA compra (GWW/ALL duplicadas
    # cada 15 min) y al reabrir el mercado se ejecutaban todas a la vez.
    _PENDING_STATES = ("accepted", "new", "pending_new", "partially_filled",
                       "held", "accepted_for_bidding", "pending_replace")
    pending_buy_symbols: set[str] = set()
    for _o in (snap.recent_orders or []):
        _st = str(_o.status).lower()
        _sd = str(_o.side).lower()
        if _sd.endswith("buy") and any(s in _st for s in _PENDING_STATES):
            pending_buy_symbols.add(str(_o.symbol).replace("/", "-"))
    timestamp = datetime.now(UTC).isoformat()
    equity = snap.account.equity if snap.account else 0.0
    buying_power = snap.account.buying_power if snap.account else 0.0

    # ── DESACOPLAMIENTO Drift Monitor ↔ Bridge promovido ─────────────────────
    # Una entrada del bridge tiene signal_strategy en formato trigger|filter|exit
    # (3 partes separadas por pipe; identico al formato de asset_strategies).
    # Las del carril legacy traen el nombre de la estrategia meta-labeling
    # (e.g. "regime_adaptive_v1_lo") sin pipes. Lo usamos abajo para decidir
    # a quien afecta cada tipo de pausa.
    def _is_bridge_entry(entry) -> bool:
        # 1) Marca explicita is_bridge=True puesta por build_promoted_plan_today.
        # Cubre tanto las 3-part trigger|filter|exit como las CUSTOM single-name
        # (box_prevday_hammer_long, etc.) que vienen del Custom Lab.
        if getattr(entry, "is_bridge", False):
            return True
        try:
            if isinstance(entry, dict) and entry.get("is_bridge"):
                return True
        except Exception:                                       # noqa: BLE001
            pass
        # 2) Compat con entries antiguas sin la marca: detectamos 3-part.
        s = (getattr(entry, "signal_strategy", "") or "").strip()
        if s.count("|") != 2:
            return False
        parts = s.split("|")
        return len(parts) == 3 and all(p.strip() for p in parts)

    # PAUSE_NEW_TRADES: si el Drift Monitor o el Risk Monitor lo han activado,
    # decidimos a quien afecta segun la fuente:
    #
    #   risk_monitor → PAUSA TOTAL (drawdown / leverage / vol spike son
    #                  riesgo de cuenta; afecta a TODOS los carriles).
    #   drift_monitor → solo bloquea LEGACY. Las promovidas tienen su propia
    #                   validacion (DSR + Permutation + walk-forward del Lab)
    #                   y no comparten edge con el carril legacy; castigarlas
    #                   por el drift del legacy seria un falso positivo.
    #   sin razon / desconocida → conservador: pausa total.
    paused = get_pause_flag()
    pause_reason = get_system_state("pause_reason", "") or ""
    if paused:
        is_drift_only = pause_reason.startswith("drift_monitor:")
        bridge_entries = [e for e in plan if _is_bridge_entry(e)]
        legacy_entries = [e for e in plan if not _is_bridge_entry(e)]

        if pause_reason.startswith("risk_monitor:"):
            reset_cmd = "python agents/agente_risk_monitor.py --reset-pause"
        elif is_drift_only:
            reset_cmd = "python agents/agente_drift_monitor.py --reset-pause"
        else:
            reset_cmd = ("python agents/agente_drift_monitor.py --reset-pause   "
                         "(o agente_risk_monitor.py --reset-pause)")

        note_compact = f"PAUSE_NEW_TRADES|{pause_reason}" if pause_reason else "PAUSE_NEW_TRADES"

        if is_drift_only and bridge_entries:
            # Bloqueamos solo el legacy; dejamos las promovidas continuar al
            # sizing normal. El drift mide rendimiento del carril legacy, no
            # del bridge — son edges independientes y validados aparte.
            logger.warning(
                "PAUSE_NEW_TRADES (drift_monitor) | %d entrada(s) legacy "
                "bloqueada(s), %d promovida(s) sigue(n) al sizing (drift "
                "mide solo carril legacy). Reanuda legacy con: %s",
                len(legacy_entries), len(bridge_entries), reset_cmd,
            )
            for entry in legacy_entries:
                actions.append(ExecutorAction(
                    timestamp=timestamp, mode=mode, ticker=entry.ticker,
                    side="buy" if entry.side == 1 else "sell",
                    qty=0.0, sizing_pct=sizing_pct, requested_price=0.0,
                    prob_win=entry.prob_win, strategy=strategy,
                    status="skipped",
                    notes=note_compact,
                ))
            # Sustituimos el plan por solo las promovidas y CONTINUAMOS al
            # sizing/envio. NO retornamos aqui.
            plan = bridge_entries
        else:
            # Pausa total: risk_monitor o causa desconocida — bloqueamos todo.
            logger.warning(
                "PAUSE_NEW_TRADES=true | reason=%s — no se abriran posiciones "
                "(ni legacy ni promovidas). Reanuda con: %s",
                pause_reason or "unknown", reset_cmd,
            )
            for entry in plan:
                actions.append(ExecutorAction(
                    timestamp=timestamp, mode=mode, ticker=entry.ticker,
                    side="buy" if entry.side == 1 else "sell",
                    qty=0.0, sizing_pct=sizing_pct, requested_price=0.0,
                    prob_win=entry.prob_win, strategy=strategy,
                    status="skipped",
                    notes=note_compact,
                ))
            return actions

    # ── Sizing avanzado: orquestamos Kelly + Vol Target + HRP segun metodo
    operable_entries = [
        e for e in plan
        if e.in_universe and e.side == 1 and e.ticker not in open_symbols
    ]
    operable_tickers = [e.ticker for e in operable_entries]
    n_operable = len(operable_entries)

    # Sigmas diarias estimadas desde la tabla prices (necesarias para vol target
    # y para registrar Triple Barrier al abrir)
    sigmas_daily: dict[str, float] = {}
    for t in operable_tickers:
        try:
            sigmas_daily[t] = estimate_sigma_for_ticker(t, db_path)
        except Exception:
            sigmas_daily[t] = sz_module.DEFAULT_FALLBACK_SIGMA_DAILY

    # ── Branch por metodo ────────────────────────────────────────────────────
    kelly_vol_sizes: dict[str, sz_module.SizingResult] = {}
    hrp_weights: dict[str, float] = {}
    total_budget = 0.0
    use_hrp = False
    use_kelly = sizing_method in ("kelly", "kelly_vol", "kelly_vol_hrp")

    # Vol target activo solo en los metodos que lo incluyen
    _target_vol_for_sizing = (
        target_vol_annual
        if sizing_method in ("kelly_vol", "kelly_vol_hrp")
        else 99.0  # kelly puro → scalar siempre = 1.0
    )

    if use_kelly and operable_entries:
        try:
            kelly_vol_sizes = sz_module.compute_kelly_vol_sizes(
                plan=operable_entries, equity=equity, sigmas_daily=sigmas_daily,
                payoff_b=payoff_b, kelly_fraction_mult=kelly_fraction_mult,
                max_position_pct=max_position_pct,
                target_vol_annual=_target_vol_for_sizing,
                avg_correlation=DEFAULT_AVG_CORRELATION,
            )
            total_kelly_budget = sum(r.final_pct for r in kelly_vol_sizes.values())
            logger.info("Sizing %s: %d tickers, budget total=%.2f%% equity "
                        "(half-kelly=%.2f, target_vol=%.1f%%)",
                        sizing_method, len(kelly_vol_sizes), total_kelly_budget * 100,
                        kelly_fraction_mult, target_vol_annual * 100)
        except Exception as exc:
            logger.warning("Kelly+Vol fallo: %s — fallback a sizing fijo", exc)
            kelly_vol_sizes = {}
            use_kelly = False

    # HRP:
    # - metodo "hrp" explicito → HRP solo
    # - metodo "kelly_vol_hrp" → HRP como diversification multiplier sobre Kelly
    use_hrp_multiplier = False
    if sizing_method == "hrp" and n_operable >= 2:
        try:
            hrp_weights = compute_hrp_for_tickers(operable_tickers, window_days=60)
            _cap = MAX_POSITIONS_OPEN if MAX_POSITIONS_OPEN > 0 else n_operable
            total_budget = sizing_pct * min(n_operable, _cap)
            use_hrp = True
            logger.info("HRP activado para %d tickers, budget total=%.2f%% equity",
                        n_operable, total_budget * 100)
        except Exception as exc:
            logger.warning("HRP fallo: %s — fallback a sizing fijo", exc)
            hrp_weights = {}
            use_hrp = False
    elif (sizing_method == "kelly_vol_hrp" and n_operable >= 2
          and kelly_vol_sizes):
        # Aplica HRP como factor de diversificacion: si HRP pondera mas un ticker
        # (porque diversifica mejor), su Kelly se escala arriba; si menos, abajo.
        try:
            hrp_weights = compute_hrp_for_tickers(operable_tickers, window_days=60)
            if hrp_weights:
                n_hrp = len(hrp_weights)
                equal_w = 1.0 / n_hrp if n_hrp > 0 else 0.0
                if equal_w > 0:
                    for t, r in kelly_vol_sizes.items():
                        if t not in hrp_weights:
                            continue
                        # multiplier en (0.3, 3.0): no permitimos cambios extremos
                        raw_mult = hrp_weights[t] / equal_w
                        mult = max(0.3, min(raw_mult, 3.0))
                        new_pct = r.final_pct * mult
                        # Re-cap individual a max_position_pct para no superar el tope
                        r.final_pct = round(min(new_pct, max_position_pct), 6)
                        r.final_usd = round(r.final_pct * equity, 2)
                        # Reportamos el multiplicador en notas del propio SizingResult
                        suffix = f"|hrp_mult={mult:.2f}"
                        r.notes = (r.notes + suffix) if r.notes else suffix
                    use_hrp_multiplier = True
                    logger.info(
                        "kelly_vol_hrp activo: HRP multipliers aplicados a %d tickers",
                        len(kelly_vol_sizes),
                    )
        except Exception as exc:
            logger.warning("HRP multiplier fallo: %s — sigue solo kelly_vol", exc)
            hrp_weights = {}
            use_hrp_multiplier = False

    # ── LEONEX_ONLY_PROMOTED: operar SOLO estrategias promovidas ─────────
    # Con el flag activo, las senales del plan generico (regime_adaptive
    # sobre todo el universo, Meta con AUC ~0.51 en modo soft) se registran
    # en el journal pero NO se envian: solo operan las entradas del bridge
    # de promovidas (SILVER/GOLD validadas con DSR en el Strategy Lab).
    # Consecuencia deliberada: dias sin entradas promovidas vivas = sistema
    # plano por diseno.
    only_promoted = os.environ.get(
        "LEONEX_ONLY_PROMOTED", "").strip().lower() in ("1", "true", "yes")
    if only_promoted:
        logger.info("LEONEX_ONLY_PROMOTED=1 — solo se operan entradas del "
                    "bridge de promovidas; el plan generico queda en el "
                    "journal como referencia pero no se envia.")

    # ── LEONEX_GENERIC_MIN_PROB: umbral de prob_win para el plan generico ──
    # Alternativa mas suave a ONLY_PROMOTED: las promovidas del bridge operan
    # siempre; las senales genericas solo si prob_win >= este umbral. Con
    # 0.53, hoy habria operado 1 de 110. OJO: el Meta tiene AUC ~0.51, asi
    # que este filtro es un limitador de actividad/friccion, no una garantia
    # de calidad. 0 o vacia = desactivado. Si ONLY_PROMOTED=1, este umbral
    # es irrelevante (las genericas nunca operan).
    try:
        generic_min_prob = float(os.environ.get(
            "LEONEX_GENERIC_MIN_PROB", "0").strip() or "0")
    except ValueError:
        generic_min_prob = 0.0
    if generic_min_prob > 0 and not only_promoted:
        logger.info("LEONEX_GENERIC_MIN_PROB=%.3f — senales genericas con "
                    "prob_win inferior quedan en el journal sin enviarse; "
                    "las promovidas del bridge no se ven afectadas.",
                    generic_min_prob)

    # ── Cooldown de re-entrada tras stop-loss ────────────────────────────
    try:
        _cooldown_h = float(os.environ.get(
            "LEONEX_REENTRY_COOLDOWN_H", "24").strip() or "0")
    except ValueError:
        _cooldown_h = 24.0
    cooldown_tickers = fetch_reentry_cooldown_tickers(db_path, _cooldown_h)
    if cooldown_tickers:
        logger.info("Cooldown re-entrada (%sh) activo para %d ticker(s): %s",
                    _cooldown_h, len(cooldown_tickers),
                    ", ".join(sorted(cooldown_tickers)))

    new_buys = 0
    for entry in plan:
        # Filtros previos
        if not _is_bridge_entry(entry):
            if only_promoted:
                actions.append(ExecutorAction(
                    timestamp=timestamp, mode=mode, ticker=entry.ticker,
                    side="buy" if entry.side == 1 else "sell", qty=0.0,
                    sizing_pct=sizing_pct, requested_price=0.0,
                    prob_win=entry.prob_win, strategy=strategy,
                    status="skipped",
                    notes="solo_promovidas|senal_generica_no_operable",
                ))
                continue
            if generic_min_prob > 0 and entry.prob_win < generic_min_prob:
                actions.append(ExecutorAction(
                    timestamp=timestamp, mode=mode, ticker=entry.ticker,
                    side="buy" if entry.side == 1 else "sell", qty=0.0,
                    sizing_pct=sizing_pct, requested_price=0.0,
                    prob_win=entry.prob_win, strategy=strategy,
                    status="skipped",
                    notes=(f"prob_win_{entry.prob_win:.3f}_bajo_umbral_"
                           f"generico_{generic_min_prob:.2f}"),
                ))
                continue
        if not entry.in_universe:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=0.0, sizing_pct=sizing_pct,
                requested_price=0.0, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes="fuera_de_universo_alpaca",
            ))
            continue
        if entry.side != 1:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="sell" if entry.side == -1 else "buy",
                qty=0.0, sizing_pct=sizing_pct, requested_price=0.0,
                prob_win=entry.prob_win, strategy=strategy,
                status="skipped",
                notes="solo_long_en_fase2_inicial",
            ))
            continue
        if entry.ticker in open_symbols:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=0.0, sizing_pct=sizing_pct,
                requested_price=0.0, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes="ya_existe_posicion_abierta",
            ))
            continue
        if entry.ticker in cooldown_tickers:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=0.0, sizing_pct=sizing_pct,
                requested_price=0.0, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes=(f"cooldown_reentrada_sl|cerrado="
                       f"{cooldown_tickers[entry.ticker][:16]}"),
            ))
            continue
        if entry.ticker in pending_buy_symbols:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=0.0, sizing_pct=sizing_pct,
                requested_price=0.0, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes="orden_buy_pendiente_no_duplicar",
            ))
            continue
        if MAX_POSITIONS_OPEN > 0 and new_buys >= MAX_POSITIONS_OPEN - len(open_symbols):
            # NOTA: 'new_buys' es el contador local del ciclo (cuantas BUYs
            # nuevas ya se planearon en ESTA corrida del executor), NO el
            # numero de posiciones abiertas ahora mismo en Alpaca (esa
            # cifra es len(open_symbols)). El mensaje viejo decia
            # 'max_posiciones_alcanzado_N' que era engañoso: sonaba a "hay
            # N posiciones abiertas" cuando realmente significa "en este
            # ciclo ya se planearon N nuevas, salto la siguiente".
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=0.0, sizing_pct=sizing_pct,
                requested_price=0.0, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes=(f"cupo_ciclo_lleno|planeadas_este_ciclo={new_buys}|"
                       f"abiertas_alpaca={len(open_symbols)}|"
                       f"cap={MAX_POSITIONS_OPEN}"),
            ))
            continue

        # Obtener precio actual del activo (live quote de Alpaca)
        quote = get_latest_quote(entry.ticker)
        if not quote or quote["mid"] <= 0:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=0.0, sizing_pct=sizing_pct,
                requested_price=0.0, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes="sin_cotizacion_alpaca",
            ))
            continue
        price = quote["mid"]
        is_crypto = is_crypto_symbol(entry.ticker)
        sizing_breakdown = ""
        # Kelly modo activo pero ticker NO esta en kelly_vol_sizes → edge <= 0
        # (compute_kelly_vol_sizes ya filtro f_cap <= 0). Saltamos explicitamente.
        if use_kelly and entry.ticker not in kelly_vol_sizes:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=0.0, sizing_pct=sizing_pct,
                requested_price=price, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes=f"kelly_edge_negativo|prob_win={entry.prob_win:.3f}|method={sizing_method}",
            ))
            continue

        if use_kelly and entry.ticker in kelly_vol_sizes:
            r = kelly_vol_sizes[entry.ticker]
            effective_pct = r.final_pct
            extra = ""
            if use_hrp_multiplier and "hrp_mult=" in (r.notes or ""):
                # r.notes ya contiene "|hrp_mult=...", lo extraemos para mostrarlo
                hrp_part = next(
                    (s for s in (r.notes or "").split("|") if s.startswith("hrp_mult=")),
                    "",
                )
                if hrp_part:
                    extra = f"|{hrp_part}"
            sizing_breakdown = (
                f"kelly_full={r.kelly_full:.3f}|"
                f"kelly_half={r.kelly_fractional:.3f}|"
                f"sigma_ann={r.sigma_annual:.2f}|"
                f"vol_scalar={r.vol_scalar:.2f}"
                f"{extra}|"
                f"final={effective_pct*100:.2f}%"
            )
        elif use_hrp and entry.ticker in hrp_weights:
            # Sizing por contribucion HRP: peso_hrp * budget_total del equity
            effective_pct = hrp_weights[entry.ticker] * total_budget
            sizing_breakdown = (
                f"hrp_w={hrp_weights[entry.ticker]:.3f}|"
                f"budget={total_budget*100:.2f}%|"
                f"final={effective_pct*100:.2f}%"
            )
        else:
            effective_pct = sizing_pct
            sizing_breakdown = f"fixed={effective_pct*100:.2f}%"

        # Si Kelly devuelve 0 (edge negativo o capeado a 0), skip
        if effective_pct <= 0:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=0.0, sizing_pct=sizing_pct,
                requested_price=price, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes=f"sizing_zero|method={sizing_method}|{sizing_breakdown}",
            ))
            continue

        qty = compute_qty(equity, effective_pct, price, is_crypto=is_crypto)
        # ── Cap de riesgo fijo por trade (Van Tharp) ───────────────────────────
        # Limita la posicion para que, si salta el stop del Triple Barrier
        # (RISK_STOP_SIGMA_MULT x sigma diaria), la perdida <= risk_per_trade del
        # equity. El stop real lo pone register_open_trade con el MISMO sl_mult,
        # asi que el riesgo queda acotado de verdad (no es un stop del 1%, es
        # tamano para que el stop por volatilidad equivalga al 1% de la cuenta).
        if risk_per_trade and risk_per_trade > 0:
            sigma = sigmas_daily.get(entry.ticker) or sz_module.DEFAULT_FALLBACK_SIGMA_DAILY
            stop_dist = RISK_STOP_SIGMA_MULT * sigma * price  # $/accion hasta el stop
            if stop_dist > 0:
                max_qty_risk = (risk_per_trade * equity) / stop_dist
                max_qty_risk = round(max_qty_risk, 6) if is_crypto else float(int(max_qty_risk))
                if 0 < max_qty_risk < qty:
                    qty = max_qty_risk
                    sizing_breakdown += (f"|risk_cap={risk_per_trade*100:.1f}%eq"
                                         f"(stop={RISK_STOP_SIGMA_MULT:.1f}sigma~{stop_dist/price*100:.1f}%)")
        notional = qty * price

        # Filtro qty: para equities qty < 1 = skip; para crypto, qty <= 0 (compute_qty
        # ya aplica MIN_CRYPTO_NOTIONAL_USD internamente).
        if (is_crypto and qty <= 0) or (not is_crypto and qty < 1):
            reason = (
                f"crypto_notional<${MIN_CRYPTO_NOTIONAL_USD:.0f}"
                if is_crypto else
                f"qty<1_precio_${price:.2f}_sizing_${equity*sizing_pct:.2f}"
            )
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=0.0, sizing_pct=sizing_pct,
                requested_price=price, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes=reason,
            ))
            continue
        if notional > buying_power:
            actions.append(ExecutorAction(
                timestamp=timestamp, mode=mode, ticker=entry.ticker,
                side="buy", qty=qty, sizing_pct=sizing_pct,
                requested_price=price, prob_win=entry.prob_win,
                strategy=strategy, status="skipped",
                notes=f"notional_${notional:.2f}>buying_power_${buying_power:.2f}",
            ))
            continue

        # Accion candidata: la nota incluye el breakdown completo del sizing
        actions.append(ExecutorAction(
            timestamp=timestamp, mode=mode, ticker=entry.ticker,
            side="buy", qty=qty, sizing_pct=effective_pct,
            requested_price=price, prob_win=entry.prob_win,
            strategy=strategy, status="planned",
            notes=f"notional=${notional:.2f}|method={sizing_method}|{sizing_breakdown}",
        ))
        new_buys += 1

    # ── Judge cualitativo (post-sizing, pre-envio) ──────────────────────────
    if judge_strictness != "off" and _JUDGE_AVAILABLE:
        try:
            jreport = judge_plan(
                plan_entries=plan, kelly_sizes=kelly_vol_sizes,
                sizing_method=sizing_method, meta_threshold=meta_threshold,
                open_symbols=list(open_symbols), strategy=strategy,
                db_path=db_path, strictness=judge_strictness, logger=logger,
            )
            verdicts_by_ticker = {
                v.ticker: v for v in (jreport.verdicts_by_ticker if jreport else [])
            }
            # Aplicamos el veredicto SOLO a los planned (no a los ya skipped)
            for a in actions:
                if a.ticker not in verdicts_by_ticker:
                    continue
                v = verdicts_by_ticker[a.ticker]
                a.judge_verdict = v.verdict
                a.judge_reasons_against = v.reasons_against
                if a.status == "planned" and v.verdict == "VETO":
                    a.status = "vetoed"
                    cons = " · ".join(v.reasons_against[:2])
                    a.notes = f"{a.notes}|judge=VETO|{cons}"
            logger.info("Judge aplicado (strictness=%s): %d APPROVE, %d CAUTION, %d VETO",
                        judge_strictness,
                        jreport.n_approved if jreport else 0,
                        jreport.n_caution if jreport else 0,
                        jreport.n_vetoed if jreport else 0)
        except Exception as exc:
            logger.warning("Judge fallo: %s — sigue sin filtro cualitativo", exc)

    # ── Narrativa por accion ─────────────────────────────────────────────────
    if narrator_mode != "off":
        plan_by_ticker = {e.ticker: e for e in plan}
        verdicts_lookup: dict[str, object] = {}
        if _JUDGE_AVAILABLE:
            # Reconstruimos un mini-objeto para narrate desde lo que ya guardamos
            for a in actions:
                if a.judge_verdict:
                    class _V:  # objeto duck-type para narrator
                        pass
                    v = _V()
                    v.verdict = a.judge_verdict
                    v.reasons_pro = []
                    v.reasons_against = list(a.judge_reasons_against or [])
                    verdicts_lookup[a.ticker] = v
        for a in actions:
            try:
                a.narrative = narrate_action(
                    a,
                    plan_entry=plan_by_ticker.get(a.ticker),
                    judge_verdict=verdicts_lookup.get(a.ticker),
                    ollama_model=(None if narrator_mode == "ollama_or_template" else False),
                    force_template=(narrator_mode == "template_only"),
                    logger=logger,
                )
            except Exception as exc:
                a.narrative = f"(narrative_error: {exc!r})"

    return actions


def estimate_sigma_for_ticker(ticker: str, db_path: Path, lookback: int = 20) -> float:
    """Sigma diaria estimada (EWM std de retornos log) sobre los precios recientes.

    Lee la tabla prices del propio Leonex. Si no hay suficientes datos
    devuelve 0.02 (2%) como fallback conservador."""
    try:
        with sqlite3.connect(db_path) as conn:
            df = pd.read_sql(
                "SELECT date, close FROM prices WHERE ticker = ? ORDER BY date ASC",
                conn, params=(ticker,),
            )
        if len(df) < lookback + 5:
            return 0.02
        import numpy as np
        rets = np.log(df["close"] / df["close"].shift(1)).dropna()
        sigma = float(rets.ewm(span=lookback, adjust=False).std().iloc[-1])
        if sigma <= 0 or sigma != sigma:  # NaN check
            return 0.02
        return sigma
    except Exception:
        return 0.02


def register_open_trade(
    action: ExecutorAction,
    db_path: Path,
    pt_mult: float = 2.0,
    sl_mult: float = 1.0,
    vert_barrier_days: int = 5,
) -> None:
    """Registra una entrada en open_trades con los parametros Triple Barrier vivos.

    Sigma estimada al momento del envio sobre los retornos recientes del activo.
    """
    sigma = estimate_sigma_for_ticker(action.ticker, db_path)
    pt_pct = pt_mult * sigma
    sl_pct = sl_mult * sigma
    # ── Suelo minimo de barreras ─────────────────────────────────────────
    # Con sigma minuscula (activos muy tranquilos) salian barreras absurdas
    # (p.ej. EA TP +0.47% / SL -0.24%, DVA similar) que el propio spread
    # bid/ask tocaba a los SEGUNDOS de comprar → compra 18:15:32, venta
    # 18:15:48. El suelo garantiza que el SL quede siempre por encima del
    # coste friccional realista y mantiene el payoff ratio b=pt/sl.
    # Config: LEONEX_MIN_SL_PCT (default 0.01 = 1%).
    try:
        _min_sl = float(os.environ.get("LEONEX_MIN_SL_PCT", "0.01"))
    except ValueError:
        _min_sl = 0.01
    if _min_sl > 0 and sl_pct < _min_sl:
        _ratio = pt_mult / max(sl_mult, 1e-9)
        sl_pct = _min_sl
        pt_pct = max(pt_pct, _min_sl * _ratio)
    side = 1 if action.side == "buy" else -1
    with sqlite3.connect(db_path) as conn:
        conn.execute(
            """
            INSERT INTO open_trades (
                ticker, strategy, side, qty, entry_date, entry_price,
                sigma_entry, pt_pct, sl_pct, vert_barrier_days,
                alpaca_buy_order_id, status
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open')
            """,
            (
                action.ticker, action.strategy, side, float(action.qty),
                action.timestamp, float(action.requested_price),
                float(sigma), float(pt_pct), float(sl_pct), int(vert_barrier_days),
                action.alpaca_order_id,
            ),
        )
        conn.commit()


def execute_actions(actions: list[ExecutorAction], logger: logging.Logger,
                    db_path: Path = DB_PATH,
                    auto_approve_only: bool = False) -> list[ExecutorAction]:
    """Envia a Alpaca las acciones planned y registra cada apertura en open_trades.

    Selecciona el time_in_force apropiado por tipo de activo:
        - equities: 'day' (clasico intradia)
        - crypto:   'gtc'  (Alpaca rechaza 'day' en crypto; ademas el mercado es 24x7)
    El submit_market_order del alpaca_client tambien aplica esta correccion como
    salvaguarda doble.

    auto_approve_only=True: solo envia trades con judge_verdict=APPROVE.
        Los CAUTION se marcan como skipped (pending_user_review) para que tu los
        revises manualmente desde el dashboard.

        Excepcion (modo manos-libres): si LEONEX_AUTO_SEND_CAUTION=1 (default),
        los CAUTION tambien se envian — solo VETO bloquea. Ponlo a 0 para
        volver al modo revision manual de CAUTIONs.
    """
    send_caution = os.environ.get(
        "LEONEX_AUTO_SEND_CAUTION", "1").strip().lower() in ("1", "true", "yes")
    for a in actions:
        if a.status != "planned":
            continue
        allowed_verdicts = ("APPROVE", "CAUTION") if send_caution else ("APPROVE",)
        if auto_approve_only and a.judge_verdict not in allowed_verdicts:
            # VETO / sin veredicto (o CAUTION si send_caution=0) → no enviamos
            a.status = "skipped"
            a.notes = (f"{a.notes}|auto_approve_blocked|"
                       f"judge={a.judge_verdict or 'unknown'}_requires_review")
            continue
        if auto_approve_only and a.judge_verdict == "CAUTION" and send_caution:
            a.notes = f"{a.notes}|auto_sent_with_caution"
        is_crypto = is_crypto_symbol(a.ticker)
        tif = "gtc" if is_crypto else "day"
        asset_tag = "CRYPTO" if is_crypto else "EQUITY"

        # ── ANTI-PIRAMIDE CHECK (2do cinturon de seguridad) ─────────────────
        # Consulta EN TIEMPO REAL a Alpaca justo antes de enviar. Si ya hay
        # posicion en ese ticker, NO enviamos. Redundante con el filtro
        # open_symbols de build_actions, pero cubre el caso donde el snapshot
        # cached quedo obsoleto o el filtro fallo. Bug real del 6-jul-2026.
        # Si Alpaca error al consultar -> tambien no enviamos (fail-safe).
        if a.side == "buy":
            exists, chk_err = has_open_position(a.ticker)
            if exists:
                logger.warning("Anti-piramide: %s ya tiene posicion en Alpaca, "
                               "NO envio BUY (habria apilado sobre existente).",
                               a.ticker)
                a.status = "skipped"
                a.notes = (f"{a.notes}|anti_piramide_alpaca_position_exists").lstrip("|")
                continue
            if chk_err:
                logger.warning("Anti-piramide: no puedo verificar posicion de "
                               "%s (%s). Fail-safe: NO envio BUY.",
                               a.ticker, chk_err)
                a.status = "skipped"
                a.notes = (f"{a.notes}|anti_piramide_check_failed:{chk_err}").lstrip("|")
                continue

        logger.info("Enviando orden BUY %s [%s] qty=%s @ ~$%.2f (prob=%.3f, tif=%s)",
                    a.ticker, asset_tag, a.qty, a.requested_price, a.prob_win, tif)
        result = submit_market_order(a.ticker, a.qty, side=a.side, time_in_force=tif)
        if result["ok"]:
            a.status = result["status"] or "submitted"
            a.alpaca_order_id = result["order_id"]
            a.notes += f" | sent={a.alpaca_order_id} | tif={tif}"
            if result.get("alpaca_symbol") and result["alpaca_symbol"] != a.ticker:
                a.notes += f" | alpaca_symbol={result['alpaca_symbol']}"
            # Registrar open trade con sus parametros Triple Barrier
            try:
                register_open_trade(a, db_path)
                a.notes += " | tb_registered"
            except Exception as exc:
                logger.warning("No se pudo registrar open_trade para %s: %s",
                               a.ticker, exc)
                a.notes += f" | tb_register_failed={exc!r}"
        else:
            a.status = "rejected"
            a.notes += f" | error={result['error']}"
    return actions


def main() -> int:
    parser = argparse.ArgumentParser(description="Agente Executor de Leonex")
    parser.add_argument("--strategy", type=str, default=DEFAULT_STRATEGY)
    parser.add_argument("--threshold", type=float, default=DEFAULT_META_THRESHOLD,
                        help="Umbral prob_win del Meta-Labeling para incluir trade en el plan")
    parser.add_argument("--meta-mode", type=str, default=DEFAULT_META_MODE,
                        choices=["gate", "soft", "off"],
                        help="Como usa el Meta-Labeling para filtrar el plan. 'soft' (default): "
                             "el Meta NO veta, solo modula sizing — correcto con AUC ~0.50. "
                             "'gate': excluye senales con prob_win < umbral (solo si el Meta tiene edge). "
                             "'off': ignora el Meta.")
    parser.add_argument("--missing-prob", type=float, default=DEFAULT_MISSING_PROB,
                        help="prob_win neutral de fallback cuando un ticker no tiene prediccion Meta "
                             "(antes era 0.0, que descartaba la senal en silencio).")
    parser.add_argument("--sizing", type=float, default=DEFAULT_SIZING_PCT,
                        help="Porcentaje del equity por trade (default 0.05 = 5%%) — solo si --sizing-method fixed/hrp")
    parser.add_argument("--sizing-method", type=str, default=DEFAULT_SIZING_METHOD,
                        choices=["fixed", "hrp", "kelly", "kelly_vol", "kelly_vol_hrp"],
                        help="Metodo de sizing. Default kelly_vol_hrp (Half-Kelly + Vol Target 20%% "
                             "+ HRP diversification cuando hay 2+ trades).")
    parser.add_argument("--kelly-fraction", type=float, default=DEFAULT_KELLY_FRACTION,
                        help="Multiplicador del Kelly (0.5 = Half-Kelly clasico; 0.25 = Quarter-Kelly conservador)")
    parser.add_argument("--target-vol", type=float, default=DEFAULT_TARGET_VOL_ANNUAL,
                        help="Vol anualizada objetivo del portfolio (0.20 = 20%%, Carver default)")
    parser.add_argument("--max-position-pct", type=float, default=DEFAULT_MAX_POSITION_PCT,
                        help="Tope individual por trade (0.25 = max 25%% del equity)")
    parser.add_argument("--payoff-b", type=float, default=DEFAULT_PAYOFF_B,
                        help="Ratio TP/SL del Triple Barrier (default 2.0)")
    parser.add_argument("--risk-per-trade", type=float, default=DEFAULT_RISK_PER_TRADE,
                        help="Riesgo fijo maximo por trade como fraccion del equity "
                             "(0.01 = 1%%). Capa el tamano para que el stop por "
                             "volatilidad equivalga a esta perdida. 0 para desactivar.")
    parser.add_argument("--diagnose", action="store_true",
                        help="Solo test de conexion con Alpaca. No genera plan ni acciones.")
    parser.add_argument("--dry-run", action="store_true",
                        help="Calcula plan + cantidades pero NO envia ordenes a Alpaca.")
    parser.add_argument("--execute", action="store_true",
                        help="Envia ordenes reales a Alpaca paper. Requiere ademas --confirm.")
    parser.add_argument("--confirm", action="store_true",
                        help="Confirmacion explicita necesaria junto a --execute para evitar accidentes.")
    parser.add_argument("--auto-approve", action="store_true",
                        help="Modo automatico para Task Scheduler: ejecuta SOLO los trades que "
                             "el Judge marca como APPROVE. Equivale a --execute --confirm + filtro Judge.")
    parser.add_argument("--judge-strictness", type=str, default="liberal",
                        choices=["conservative", "balanced", "liberal", "off"],
                        help="Strictness del Judge cualitativo. Default 'liberal'. 'off' desactiva el Judge.")
    parser.add_argument("--narrator", type=str, default="ollama_or_template",
                        choices=["ollama_or_template", "template_only", "off"],
                        help="Backend de narrativa por trade. Default usa Ollama si esta disponible.")
    parser.add_argument("--no-promoted", action="store_true",
                        help="NO incluir en el plan las entradas vivas de las estrategias "
                             "promovidas (asset_strategies). Por defecto SI se incluyen: el "
                             "bridge recompila cada estrategia promovida sobre la ultima barra "
                             "y anade al daily plan las que disparan hoy.")
    args = parser.parse_args()

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

    if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
        import io
        sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

    # Determinar modo
    if args.execute and not args.confirm:
        log.error("--execute requiere ademas --confirm para enviar ordenes reales. Aborto.")
        print("\n[!] --execute requiere ademas --confirm para evitar accidentes.")
        print("    Uso correcto: python agents/agente_executor.py --execute --confirm")
        return 2
    # --auto-approve es equivalente a --execute --confirm pero ademas filtra por Judge
    if args.auto_approve:
        mode = "execute"
        log.info("AUTO-APPROVE: solo se enviaran ordenes con Judge verdict=APPROVE")
    elif args.execute and args.confirm:
        mode = "execute"
    elif args.dry_run:
        mode = "dry_run"
    else:
        mode = "read_only"

    # ── LEONEX_DRY_RUN_MODE: modo observacion global ─────────────────────
    # Si esta activo, NINGUNA orden automatica sale hacia Alpaca aunque el
    # invocador pida --execute/--auto-approve (scheduler interno, endpoints,
    # cron n8n, CLI). El pipeline y el journal funcionan al 100% y registran
    # que habria hecho. Esta capa vive AQUI (en el agente) a proposito: todo
    # camino automatizado acaba ejecutando este script y hereda el env, asi
    # que no hay puerta que se la salte.
    if mode == "execute" and os.environ.get(
            "LEONEX_DRY_RUN_MODE", "").strip().lower() in ("1", "true", "yes"):
        mode = "dry_run"
        log.warning("LEONEX_DRY_RUN_MODE=1 — modo forzado a dry_run: se "
                    "planifica y journalea todo pero NO se envia ninguna "
                    "orden a Alpaca.")
        print("[DRY-RUN GLOBAL] LEONEX_DRY_RUN_MODE=1 activo — ordenes NO se envian.")

    # 1) Snapshot Alpaca
    log.info("Conectando con Alpaca (modo=%s)...", mode)
    snap = fetch_snapshot()
    if not snap.configured:
        log.warning("Alpaca no configurado: %s", snap.error)
    elif snap.error:
        log.warning("Alpaca configurado pero hubo un error: %s", snap.error)
    else:
        log.info("Conexion Alpaca OK | paper=%s | equity=$%.2f | cash=$%.2f | posiciones=%d",
                 snap.account.is_paper, snap.account.equity, snap.account.cash,
                 len(snap.positions))

    if args.diagnose:
        print(json.dumps(snapshot_to_dict(snap), indent=2, default=str))
        return 0

    # 2) Plan del dia (pipeline legacy: regime_adaptive + meta-labeling)
    plan, plan_err = build_plan_today(
        strategy=args.strategy, threshold=args.threshold,
        meta_mode=args.meta_mode, missing_prob=args.missing_prob,
    )
    if plan_err:
        log.warning("No se pudo generar plan legacy: %s", plan_err)
    else:
        log.info("Plan legacy: %d senales (meta_mode=%s, umbral=%.2f)",
                 len(plan), args.meta_mode, args.threshold)

    # 2b) Bridge: entradas VIVAS de estrategias promovidas (asset_strategies).
    # Recompila cada estrategia promovida sobre la ultima barra y, si dispara
    # hoy, la anade al daily plan. El sizing / Judge / Triple Barrier de mas
    # abajo las trata igual que a las legacy — no hay caso especial.
    if not args.no_promoted:
        try:
            from agente_bridge_promoted import build_promoted_plan_today
            prom_entries, _prom_eval, prom_note = build_promoted_plan_today(DB_PATH, log)
            # Prioridad PROMOVIDA > generica: si el ticker ya esta en el plan
            # legacy, la entrada del bridge lo REEMPLAZA (antes se descartaba
            # la promovida, que es exactamente la prioridad invertida: hoy
            # 14-jul EXPE estaba LIVE con prob 0.58 y se tiraba por existir
            # una senal generica de 0.459 que luego caia por el umbral).
            idx_by_ticker = {e.ticker: i for i, e in enumerate(plan)}
            added = 0
            replaced = 0
            for d in prom_entries:
                pe = PlanEntry(**d)
                if pe.ticker in idx_by_ticker:
                    plan[idx_by_ticker[pe.ticker]] = pe
                    replaced += 1
                else:
                    plan.append(pe)
                    idx_by_ticker[pe.ticker] = len(plan) - 1
                    added += 1
            if replaced:
                log.info("Bridge promovidas: %d senal(es) generica(s) "
                         "reemplazada(s) por su version promovida", replaced)
            log.info("Bridge promovidas: %s (%d vivas, %d anadidas al plan)",
                     prom_note, len(prom_entries), added)
        except Exception as exc:
            log.warning("Bridge de promovidas fallo: %s — sigo solo con plan legacy", exc)

    # 2c) Priorizar el plan: con el tope MAX_POSITIONS_OPEN, queremos que las
    # posiciones que se abran sean las senales mas fuertes, no las primeras que
    # devuelva la DB. Orden: mayor prob_win (Meta) y luego mayor momentum_20.
    plan.sort(key=lambda e: (e.prob_win, abs(e.momentum_20)), reverse=True)

    # 3) Construir acciones (solo si Alpaca esta operativo y hay plan)
    actions: list[ExecutorAction] = []
    if snap.configured and snap.account is not None and plan:
        actions = build_actions(
            plan=plan, snap=snap, sizing_pct=args.sizing, mode=mode,
            strategy=args.strategy, logger=log,
            sizing_method=args.sizing_method,
            kelly_fraction_mult=args.kelly_fraction,
            target_vol_annual=args.target_vol,
            max_position_pct=args.max_position_pct,
            payoff_b=args.payoff_b,
            db_path=DB_PATH,
            judge_strictness=args.judge_strictness,
            meta_threshold=args.threshold,
            narrator_mode=args.narrator,
            risk_per_trade=args.risk_per_trade,
        )
        log.info("Acciones generadas: %d (planned=%d, skipped=%d)",
                 len(actions),
                 sum(1 for a in actions if a.status == "planned"),
                 sum(1 for a in actions if a.status == "skipped"))

        # Reetiquetar cada accion con la estrategia REAL de su entrada de plan.
        # build_actions usa el --strategy global; aqui le devolvemos a cada
        # accion su signal_strategy (legacy o el trigger|filtro|salida de la
        # promovida) para que el historial, el trade_journal y open_trades
        # reflejen la estrategia que de verdad genero la senal.
        strat_by_ticker = {e.ticker: e.signal_strategy
                           for e in plan if e.signal_strategy}
        for a in actions:
            real = strat_by_ticker.get(a.ticker)
            if real:
                a.strategy = real

        if mode == "execute":
            log.warning("MODO EXECUTE: enviando ordenes reales a Alpaca paper")
            actions = execute_actions(
                actions, log,
                auto_approve_only=bool(args.auto_approve),
            )
        elif mode == "dry_run":
            log.info("MODO DRY-RUN: ninguna orden se enviara")

        # Persistir TODAS las acciones (planned/skipped/submitted/rejected)
        save_actions(actions, DB_PATH)

    # 4) Export al dashboard
    report = PaperReport(
        generated_at=datetime.now(UTC).isoformat(),
        strategy=args.strategy,
        meta_threshold=args.threshold,
        sizing_pct=args.sizing,
        mode=mode,
        alpaca=snapshot_to_dict(snap),
        sizing_method=args.sizing_method,
        kelly_fraction=args.kelly_fraction,
        target_vol_annual=args.target_vol,
        max_position_pct=args.max_position_pct,
        payoff_b=args.payoff_b,
        pause_new_trades=get_pause_flag(DB_PATH),
        pause_reason=(get_system_state("pause_reason", "", DB_PATH) or ""),
        judge_strictness=args.judge_strictness,
        auto_approve=bool(args.auto_approve),
        simulated_only=(mode != "execute"),
        trade_journal=load_recent_journal(DB_PATH, limit=50),
        plan_today=plan,
        actions=actions,
        universe=sorted(ALPACA_TRADABLE_UNIVERSE),
        recent_actions_history=load_recent_actions(DB_PATH, limit=80),
    )
    payload = {
        **{k: v for k, v in asdict(report).items() if k not in ("plan_today", "actions")},
        "plan_today": [asdict(p) for p in report.plan_today],
        "actions": [asdict(a) for a in report.actions],
    }
    REPORT_PATH.write_text(json.dumps(payload, indent=2, ensure_ascii=False, default=str),
                           encoding="utf-8")
    log.info("Informe exportado → %s", REPORT_PATH)

    # 5) Resumen consola
    sep = "=" * 64
    print(f"\n{sep}")
    print(f"Leonex -- Executor (modo={mode})")
    print(sep)
    if not snap.configured:
        print("[ALPACA] No configurado. Crea cuenta paper en alpaca.markets y")
        print("         rellena .env (ver .env.example).")
    elif snap.error:
        print(f"[ALPACA] Error: {snap.error}")
    else:
        a = snap.account
        print(f"[ALPACA] Cuenta {'PAPER' if a.is_paper else 'LIVE'} | status={a.status}")
        print(f"[ALPACA] equity=${a.equity:,.2f} | cash=${a.cash:,.2f} | "
              f"buying_power=${a.buying_power:,.2f}")
        print(f"[ALPACA] P&L hoy=${a.pnl_today:+,.2f} ({a.pnl_today_pct*100:+.2f}%)")
        print(f"[ALPACA] Posiciones abiertas: {len(snap.positions)}")
        for p in snap.positions[:10]:
            print(f"          {p.symbol:<6} {p.qty:>7.2f} @ ${p.avg_entry_price:.2f} "
                  f"-> ${p.current_price:.2f} | PnL ${p.unrealized_pl:+,.2f} "
                  f"({p.unrealized_plpc*100:+.2f}%)")
    print()
    print(f"[PLAN] Estrategia: {args.strategy} | umbral Meta: {args.threshold} | sizing: {args.sizing*100:.1f}%")
    if not plan:
        print(f"[PLAN] Sin senales filtradas hoy ({plan_err or 'plan vacio'}).")
    else:
        for p in plan:
            flag = "[OK]" if p.in_universe else "[--]"
            print(f"  {flag} {p.ticker:<6} side={p.side:+d} prob={p.prob_win:.3f} "
                  f"regime={p.regime} rsi={p.rsi_14:5.1f}")

    if actions:
        print()
        print(f"[ACCIONES] modo={mode}")
        for ac in actions:
            status_tag = {
                "planned": "PLANNED",
                "submitted": "SUBMITTED",
                "filled": "FILLED",
                "rejected": "REJECTED",
                "skipped": "SKIPPED",
            }.get(ac.status, ac.status.upper())
            line = (f"  [{status_tag:<9}] {ac.ticker:<6} {ac.side.upper():<4} "
                    f"qty={ac.qty:>6.2f} @ ${ac.requested_price:.2f} "
                    f"(notional=${ac.qty * ac.requested_price:.2f})")
            if ac.alpaca_order_id:
                line += f" id={ac.alpaca_order_id[:8]}..."
            if ac.notes:
                line += f"  {ac.notes}"
            print(line)
    print(sep)
    if mode == "read_only":
        print("Modo solo lectura. Para preview anade --dry-run, para ejecutar --execute --confirm.")
    elif mode == "dry_run":
        print("DRY-RUN: ninguna orden enviada. Cuando confirmes lo que ves, anade --execute --confirm.")
    else:
        print("EXECUTE: ordenes enviadas a Alpaca paper. Revisa estado en el dashboard.")
    return 0


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

# ─────────────────────────────────────────────────────────────────────────────
# Sizing methods reference (Thorp + Carver)
# ─────────────────────────────────────────────────────────────────────────────
#
# --sizing-method fixed      → sizing fijo (5% default). Compatibilidad.
# --sizing-method hrp        → HRP weights * (sizing_pct * N_operables). Bueno
#                              cuando hay multiples senales correlacionadas.
# --sizing-method kelly      → Half-Kelly por trade, capeado al 25%.
# --sizing-method kelly_vol  → Half-Kelly + Vol Target 20% portfolio. RECOMENDADO.
#
# Half-Kelly = f* * 0.5 (recomendacion clasica de Thorp por robustez al sesgo
# en la estimacion de prob_win).
#
# Vol Target = 20% vol anualizada del portfolio (Carver, Systematic Trading).
# Si la vol implicita ya esta bajo el target, el scalar queda en 1.0 (Carver
# no apalanca por encima de Kelly).
#
# Edge negativo (prob_win demasiado baja para b=2.0, p < 1/3) → trade saltado
# con notes="kelly_edge_negativo".

