"""
Agente Universe de Leonex.

Construye el universo activo del sistema rotando entre los componentes del S&P 500
segun momentum. Reemplaza al universo fijo hardcodeado: ahora Leonex elige los
mejores 30 cada semana segun rendimiento reciente.

Logica:
    1. Descarga la lista de componentes del S&P 500 (cacheada localmente).
    2. Calcula momentum 60d con yfinance para cada ticker.
    3. Top-30 por momentum descendente.
    4. Anade SIEMPRE los tickers con posiciones abiertas (aunque caigan del top).
    5. Anade opcionalmente activos "legacy" para vigilancia (forex / crypto)
       que quedan fuera del scope del executor de Alpaca pero siguen
       generando senales y trades historicos.

Frecuencia:
    Por defecto rebalancea cada 7 dias. Si la ultima ejecucion fue hace
    menos, devuelve el universo existente sin re-escanear (rapido).
    --force ignora el cache y siempre re-escanea.

Salidas:
    - SQLite tablas active_universe y universe_history.
    - JSON dashboard/data/universe_report.json.

Uso:
    python agents/agente_universe.py                # rebalancea si toca
    python agents/agente_universe.py --force        # rebalancea siempre
    python agents/agente_universe.py --size 50      # top-50 en vez de top-30
    python agents/agente_universe.py --no-legacy    # no incluir BTC/ETH/forex
"""

from __future__ import annotations

import argparse
import json
import logging
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 numpy as np
import pandas as pd
import yfinance as yf


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 / "universe_report.json"
SP500_CACHE_PATH = DATA_DIR / "sp500_components.csv"

DEFAULT_SIZE = 30
DEFAULT_MOMENTUM_LOOKBACK = 60      # dias para calcular momentum
REBALANCE_DAYS = 7                  # cada cuanto rotar el universo
SP500_CACHE_MAX_DAYS = 30           # refrescar lista S&P 500 mensualmente

# Activos "legacy" que mantenemos en el universo. Solo crypto: BTC/ETH/SOL SI
# son operables en Alpaca (el alpaca_client traduce BTC-USD -> BTC/USD).
# El forex (EURUSD/GBPUSD/USDJPY) se RETIRO: no es operable en esta cuenta
# Alpaca y, al cotizar dom-vie, sus barras de fin de semana secuestraban el
# plan del executor (eran la unica senal "viva" y siempre se saltaban por
# fuera_de_universo_alpaca, dejando el sistema plano dia tras dia).
LEGACY_TICKERS = [
    ("BTC-USD", "crypto"),
    ("ETH-USD", "crypto"),
    ("SOL-USD", "crypto"),
]


@dataclass
class UniverseEntry:
    ticker: str
    score: float
    rank: int
    asset_class: str
    reason: str                  # 'top_momentum' | 'open_position' | 'legacy'


@dataclass
class UniverseChange:
    ticker: str
    change: str                  # 'entry' | 'exit'
    score_now: Optional[float] = None
    rank_now: Optional[int] = None


@dataclass
class UniverseReport:
    generated_at: str
    rebalanced: bool
    universe_size: int
    sp500_size: int
    rebalance_reason: str
    selected_at: str
    entries: list[UniverseEntry] = field(default_factory=list)
    changes_vs_previous: list[UniverseChange] = field(default_factory=list)


# ── S&P 500 components ────────────────────────────────────────────────────

# Top 150 del S&P 500 por capitalizacion (snapshot manual de 2024-2025).
# Estos solos representan ~80% del market cap del indice. Usados como
# fallback robusto si Wikipedia bloquea peticiones (HTTP 403) o falla
# la red. Refrescables editando esta lista o reactivando el scrape.
SP500_FALLBACK_TOP_150 = [
    # Mega caps tech
    "AAPL", "MSFT", "NVDA", "GOOGL", "GOOG", "AMZN", "META", "TSLA", "AVGO",
    "ORCL", "ADBE", "CRM", "AMD", "QCOM", "INTC", "CSCO", "TXN", "INTU", "IBM",
    "NOW", "PANW", "MU", "AMAT", "LRCX", "KLAC", "ADI", "MRVL", "WDC", "STX",
    # Financials
    "BRK-B", "JPM", "V", "MA", "BAC", "WFC", "GS", "MS", "AXP", "BLK", "C",
    "SCHW", "SPGI", "MMC", "PGR", "CB", "AON", "ICE", "CME", "USB", "PNC",
    "TFC", "COF", "BK", "FIS",
    # Health / Pharma
    "LLY", "UNH", "JNJ", "ABBV", "MRK", "PFE", "TMO", "ABT", "DHR", "AMGN",
    "BMY", "ELV", "GILD", "ISRG", "MDT", "CVS", "REGN", "VRTX", "BSX", "SYK",
    "ZTS", "BDX", "HUM",
    # Consumer / Retail
    "WMT", "COST", "HD", "PG", "KO", "PEP", "MCD", "NKE", "SBUX", "TGT", "LOW",
    "MO", "PM", "MDLZ", "TJX", "CL", "KMB", "EL", "GIS", "K", "CHTR",
    # Communications / Media
    "NFLX", "DIS", "CMCSA", "TMUS", "T", "VZ", "EA", "ATVI",
    # Industrial / Defense
    "BA", "CAT", "GE", "HON", "RTX", "LMT", "DE", "UPS", "FDX", "UNP", "CSX",
    "NSC", "ETN", "ITW", "MMM", "EMR", "PH",
    # Energy
    "XOM", "CVX", "COP", "EOG", "SLB", "PSX", "MPC", "VLO", "OXY", "PXD",
    # Utilities & REITs
    "NEE", "DUK", "SO", "AMT", "PLD", "CCI", "EQIX", "O", "SPG", "PSA",
    # Otros grandes
    "BX", "KKR",
]


def _http_get_with_ua(url: str, timeout: int = 15) -> Optional[str]:
    """Descarga URL con User-Agent realista. None si falla."""
    headers = {
        "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                       "AppleWebKit/537.36 (KHTML, like Gecko) "
                       "Chrome/124.0.0.0 Safari/537.36"),
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9",
        "Accept-Language": "en-US,en;q=0.9",
    }
    # Primero intentamos con requests (es la libreria mas fiable de Python para esto)
    try:
        import requests
        r = requests.get(url, headers=headers, timeout=timeout)
        if r.status_code == 200:
            return r.text
    except Exception:
        pass
    # Fallback con urllib + headers manuales
    try:
        import urllib.request
        req = urllib.request.Request(url, headers=headers)
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.read().decode("utf-8", errors="replace")
    except Exception:
        return None


def _parse_sp500_from_html(html: str) -> list[str]:
    """Parsea el HTML de la pagina de Wikipedia y extrae tickers."""
    from io import StringIO
    try:
        dfs = pd.read_html(StringIO(html))
    except Exception:
        return []
    for df in dfs:
        # La tabla buena tiene la columna Symbol o Ticker
        for col in df.columns:
            if str(col).strip() in ("Symbol", "Ticker symbol", "Ticker"):
                tickers = df[col].astype(str).str.replace(".", "-", regex=False).tolist()
                # Filtrar valores raros
                return [t for t in tickers if t and len(t) <= 6 and t.replace("-", "").isalpha()]
    return []


def fetch_sp500_components(cache_path: Path = SP500_CACHE_PATH,
                           max_age_days: int = SP500_CACHE_MAX_DAYS,
                           force: bool = False) -> list[str]:
    """Devuelve la lista de tickers del S&P 500 con tres caminos:

    1. Cache local fresca (si existe y no esta caducada).
    2. Scrape de Wikipedia con User-Agent realista.
    3. Fallback hardcodeado: top-150 del indice (cobertura ~80% market cap).

    Nunca lanza excepciones — siempre devuelve al menos el fallback.
    """
    # 1) Cache fresca
    if cache_path.exists() and not force:
        age = datetime.now(UTC) - datetime.fromtimestamp(cache_path.stat().st_mtime, UTC)
        if age.days <= max_age_days:
            try:
                return pd.read_csv(cache_path)["Symbol"].astype(str).tolist()
            except Exception:
                pass

    # 2) Scrape Wikipedia con headers reales
    url = "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
    html = _http_get_with_ua(url)
    tickers: list[str] = []
    if html:
        tickers = _parse_sp500_from_html(html)
    if tickers and len(tickers) >= 400:
        # Cachear y devolver
        try:
            cache_path.parent.mkdir(parents=True, exist_ok=True)
            pd.DataFrame({"Symbol": tickers}).to_csv(cache_path, index=False)
        except Exception:
            pass
        return tickers

    # 3) Fallback hardcodeado: top 150
    # Si llegamos aqui es porque la red falla o Wikipedia esta bloqueando
    # (tipico en IP de datacenter: 403). GUARD ANTI-ENCOGIMIENTO: NO sobrescribir
    # un cache existente que ya tenga mas tickers que el fallback. Sin esto, cada
    # run en el contenedor clobbereaba el CSV de 503 -> 150 y el agente de datos
    # dejaba de refrescar ~360 tickers (bug 147/510 del 25-27/06). Preferimos un
    # cache "viejo" pero COMPLETO a uno fresco pero recortado.
    if cache_path.exists():
        try:
            existing = pd.read_csv(cache_path)["Symbol"].astype(str).tolist()
            existing = [s for s in existing if s and s.lower() != "nan"]
            if len(existing) > len(SP500_FALLBACK_TOP_150):
                return existing
        except Exception:
            pass
    # Cacheamos el fallback solo si no habia un cache mas grande que conservar.
    try:
        cache_path.parent.mkdir(parents=True, exist_ok=True)
        pd.DataFrame({"Symbol": SP500_FALLBACK_TOP_150}).to_csv(cache_path, index=False)
    except Exception:
        pass
    return list(SP500_FALLBACK_TOP_150)


# ── Schema y persistencia ─────────────────────────────────────────────────

def ensure_schema(db_path: Path) -> None:
    with sqlite3.connect(db_path) as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS active_universe (
                ticker TEXT PRIMARY KEY,
                score REAL,
                rank INTEGER,
                selected_at TEXT NOT NULL,
                reason TEXT,
                asset_class TEXT
            )
            """
        )
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS universe_history (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                week_start_date TEXT NOT NULL,
                ticker TEXT NOT NULL,
                score REAL,
                rank INTEGER,
                asset_class TEXT,
                reason TEXT,
                UNIQUE(week_start_date, ticker)
            )
            """
        )
        conn.commit()


def get_last_rebalance(db_path: Path) -> Optional[datetime]:
    if not db_path.exists():
        return None
    with sqlite3.connect(db_path) as conn:
        try:
            row = conn.execute(
                "SELECT MAX(selected_at) FROM active_universe"
            ).fetchone()
        except sqlite3.OperationalError:
            return None
    if not row or not row[0]:
        return None
    try:
        return datetime.fromisoformat(row[0].replace("Z", "+00:00"))
    except Exception:
        return None


def load_active_universe(db_path: Path) -> list[dict]:
    if not db_path.exists():
        return []
    with sqlite3.connect(db_path) as conn:
        try:
            conn.row_factory = sqlite3.Row
            rows = conn.execute(
                "SELECT * FROM active_universe ORDER BY rank ASC NULLS LAST, ticker ASC"
            ).fetchall()
        except sqlite3.OperationalError:
            return []
    return [dict(r) for r in rows]


def get_open_positions_tickers(db_path: Path) -> list[str]:
    """Tickers que tienen al menos una posicion abierta (open_trades.status='open')."""
    if not db_path.exists():
        return []
    with sqlite3.connect(db_path) as conn:
        try:
            rows = conn.execute(
                "SELECT DISTINCT ticker FROM open_trades WHERE status = 'open'"
            ).fetchall()
        except sqlite3.OperationalError:
            return []
    return [r[0] for r in rows]


# ── Momentum scanning ─────────────────────────────────────────────────────

def fetch_momentum_batch(tickers: list[str], lookback: int,
                         logger: logging.Logger) -> dict[str, float]:
    """Calcula momentum log de N dias usando yf.Ticker(tk).history().

    Usamos la API yf.Ticker en vez de yf.download porque devuelve un
    DataFrame estable con columnas planas (Open, High, Low, Close, Volume)
    sin MultiIndex, sin importar la version de yfinance instalada.

    Mas lento que batch (no paraleliza nativamente) pero infinitamente mas
    robusto: para 150 tickers son ~2-3 min, para 500 son ~5-7 min. Aceptable
    en un rebalanceo semanal.
    """
    if not tickers:
        return {}
    # period_days es en dias CALENDARIO, pero los closes son dias de TRADING.
    # Ratio: ~252 dias trading / 365 calendario = 0.69. Para garantizar
    # `lookback` closes con margen, pedimos lookback * 1.6 + 30 dias calendario.
    period_days = max(int(lookback * 1.6) + 30, 120)
    end = datetime.now(UTC)
    start = end - timedelta(days=period_days)
    scores: dict[str, float] = {}
    failures: dict[str, str] = {}

    for j, tk in enumerate(tickers):
        try:
            t = yf.Ticker(tk)
            try:
                df = t.history(start=start, end=end, auto_adjust=True,
                               interval="1d", actions=False, repair=True)
            except TypeError:
                df = t.history(start=start, end=end, auto_adjust=True,
                               interval="1d", actions=False)
        except Exception as exc:
            failures[tk] = f"download_exception: {exc!r}"
            continue

        if df is None or len(df) == 0:
            failures[tk] = "empty_dataframe"
            continue

        # yf.Ticker.history devuelve columnas planas Open,High,Low,Close,Volume
        col = None
        for name in ("Close", "close", "Adj Close"):
            if name in df.columns:
                col = name
                break
        if col is None:
            failures[tk] = f"no_close_column (cols: {list(df.columns)[:6]})"
            continue

        closes = df[col].dropna()
        if len(closes) < lookback + 1:
            failures[tk] = f"insufficient_data ({len(closes)} closes, need {lookback + 1})"
            continue

        try:
            ret = float(np.log(closes.iloc[-1] / closes.iloc[-lookback - 1]))
            if np.isnan(ret) or np.isinf(ret):
                failures[tk] = "nan_or_inf_return"
                continue
            scores[tk] = round(ret, 6)
        except Exception as exc:
            failures[tk] = f"compute_failed: {exc!r}"
            continue

        if (j + 1) % 25 == 0:
            logger.info("Procesados %d/%d (OK acumulado: %d)",
                        j + 1, len(tickers), len(scores))

    # Resumen y diagnostico de fallos
    if failures:
        # Agrupar fallos por motivo para no spamear el log
        from collections import Counter
        reason_counts = Counter()
        for reason in failures.values():
            # Truncamos a la primera frase del motivo para agrupar
            key = reason.split(" (")[0].split(":")[0]
            reason_counts[key] += 1
        logger.warning("Tickers con fallo (%d/%d):", len(failures), len(tickers))
        for reason, cnt in reason_counts.most_common():
            logger.warning("  %s: %d tickers", reason, cnt)
        # Mostrar algunos ejemplos concretos para debug
        examples = list(failures.items())[:5]
        for tk, reason in examples:
            logger.warning("  ejemplo: %s → %s", tk, reason)

    logger.info("Momentum scoring final: %d/%d tickers con score valido",
                len(scores), len(tickers))
    return scores


# ── Logica principal ──────────────────────────────────────────────────────

def should_rebalance(db_path: Path, force: bool = False) -> tuple[bool, str]:
    if force:
        return True, "force"
    last = get_last_rebalance(db_path)
    if last is None:
        return True, "primera_ejecucion"
    age = datetime.now(UTC) - last
    if age.days >= REBALANCE_DAYS:
        return True, f"han_pasado_{age.days}d_>=_rebalance_{REBALANCE_DAYS}d"
    return False, f"todavia_no_({age.days}d < {REBALANCE_DAYS}d) — usando cache"


def select_top_n(scores: dict[str, float], n: int) -> list[tuple[str, float, int]]:
    ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
    return [(tk, score, rank + 1) for rank, (tk, score) in enumerate(ranked[:n])]


def merge_universe(
    top_n: list[tuple[str, float, int]],
    open_positions: list[str],
    include_legacy: bool,
    now_iso: str,
) -> list[UniverseEntry]:
    """Une top-momentum + posiciones abiertas + legacy (sin duplicados)."""
    entries: dict[str, UniverseEntry] = {}
    for tk, score, rank in top_n:
        entries[tk] = UniverseEntry(
            ticker=tk, score=score, rank=rank,
            asset_class="equity", reason="top_momentum",
        )
    for tk in open_positions:
        if tk not in entries:
            entries[tk] = UniverseEntry(
                ticker=tk, score=0.0, rank=0,
                asset_class="equity" if "-" not in tk and "=" not in tk else "other",
                reason="open_position",
            )
    if include_legacy:
        for tk, cls in LEGACY_TICKERS:
            if tk not in entries:
                entries[tk] = UniverseEntry(
                    ticker=tk, score=0.0, rank=0,
                    asset_class=cls, reason="legacy",
                )
    return list(entries.values())


def persist_universe(entries: list[UniverseEntry], db_path: Path) -> None:
    now_iso = datetime.now(UTC).isoformat()
    week_start = (datetime.now(UTC) - timedelta(days=datetime.now(UTC).weekday())).date().isoformat()
    with sqlite3.connect(db_path) as conn:
        # Reemplazamos por completo
        conn.execute("DELETE FROM active_universe")
        conn.executemany(
            """
            INSERT INTO active_universe (ticker, score, rank, selected_at, reason, asset_class)
            VALUES (?, ?, ?, ?, ?, ?)
            """,
            [(e.ticker, e.score, e.rank, now_iso, e.reason, e.asset_class) for e in entries],
        )
        # Histórico (UNIQUE evita duplicados misma semana)
        for e in entries:
            conn.execute(
                """
                INSERT OR IGNORE INTO universe_history
                    (week_start_date, ticker, score, rank, asset_class, reason)
                VALUES (?, ?, ?, ?, ?, ?)
                """,
                (week_start, e.ticker, e.score, e.rank, e.asset_class, e.reason),
            )
        conn.commit()


def compute_changes(previous_tickers: set[str],
                    new_entries: list[UniverseEntry]) -> list[UniverseChange]:
    new_set = {e.ticker for e in new_entries}
    changes: list[UniverseChange] = []
    for e in new_entries:
        if e.ticker not in previous_tickers:
            changes.append(UniverseChange(
                ticker=e.ticker, change="entry",
                score_now=e.score, rank_now=e.rank,
            ))
    for tk in previous_tickers - new_set:
        changes.append(UniverseChange(ticker=tk, change="exit"))
    return changes


class AgenteUniverse:
    def __init__(self, db_path: Path = DB_PATH, report_path: Path = REPORT_PATH,
                 size: int = DEFAULT_SIZE,
                 momentum_lookback: int = DEFAULT_MOMENTUM_LOOKBACK,
                 include_legacy: bool = True) -> None:
        self.db_path = db_path
        self.report_path = report_path
        self.size = size
        self.momentum_lookback = momentum_lookback
        self.include_legacy = include_legacy
        self.logger = self._build_logger()
        DASHBOARD_DATA_DIR.mkdir(parents=True, exist_ok=True)
        ensure_schema(self.db_path)

    def run(self, force: bool = False) -> UniverseReport:
        now_iso = datetime.now(UTC).isoformat()
        should, reason = should_rebalance(self.db_path, force=force)
        self.logger.info("Universe rebalance check: should=%s reason=%s", should, reason)

        if not should:
            # Cache hit: leemos el universo existente y devolvemos sin rescanear
            existing = load_active_universe(self.db_path)
            entries = [UniverseEntry(
                ticker=r["ticker"], score=float(r["score"] or 0.0),
                rank=int(r["rank"] or 0), asset_class=str(r["asset_class"] or "equity"),
                reason=str(r["reason"] or "top_momentum"),
            ) for r in existing]
            report = UniverseReport(
                generated_at=now_iso,
                rebalanced=False,
                universe_size=len(entries),
                sp500_size=0,
                rebalance_reason=reason,
                selected_at=existing[0]["selected_at"] if existing else now_iso,
                entries=entries,
                changes_vs_previous=[],
            )
            self._export(report)
            return report

        # Rescanear: S&P 500 + momentum + top-N
        self.logger.info("Descargando lista S&P 500...")
        sp500 = fetch_sp500_components()
        self.logger.info("Tickers S&P 500: %d", len(sp500))

        self.logger.info("Calculando momentum %dd para %d tickers...",
                         self.momentum_lookback, len(sp500))
        scores = fetch_momentum_batch(sp500, self.momentum_lookback, self.logger)
        self.logger.info("Momentum calculado para %d/%d tickers", len(scores), len(sp500))

        top = select_top_n(scores, self.size)
        open_pos = get_open_positions_tickers(self.db_path)
        self.logger.info("Posiciones abiertas: %d (%s)", len(open_pos), open_pos)

        previous_set = {r["ticker"] for r in load_active_universe(self.db_path)}
        entries = merge_universe(top, open_pos, self.include_legacy, now_iso)
        changes = compute_changes(previous_set, entries)
        persist_universe(entries, self.db_path)

        report = UniverseReport(
            generated_at=now_iso,
            rebalanced=True,
            universe_size=len(entries),
            sp500_size=len(sp500),
            rebalance_reason=reason,
            selected_at=now_iso,
            entries=entries,
            changes_vs_previous=changes,
        )
        self._export(report)
        self.logger.info(
            "Universe rebalanced: tickers=%d | entries=%d | exits=%d",
            len(entries),
            sum(1 for c in changes if c.change == "entry"),
            sum(1 for c in changes if c.change == "exit"),
        )
        return report

    def _export(self, report: UniverseReport) -> None:
        payload = {
            **{k: v for k, v in asdict(report).items()
               if k not in ("entries", "changes_vs_previous")},
            "entries": [asdict(e) for e in report.entries],
            "changes_vs_previous": [asdict(c) for c in report.changes_vs_previous],
        }
        self.report_path.write_text(
            json.dumps(payload, indent=2, ensure_ascii=False),
            encoding="utf-8",
        )
        self.logger.info("Informe exportado → %s", self.report_path)

    def _build_logger(self) -> logging.Logger:
        LOGS_DIR.mkdir(parents=True, exist_ok=True)
        logger = logging.getLogger("agente_universe")
        logger.setLevel(logging.INFO)
        logger.handlers.clear()
        fmt = logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s")
        fh = logging.FileHandler(LOGS_DIR / "agente_universe.log", encoding="utf-8")
        fh.setFormatter(fmt)
        sh = logging.StreamHandler()
        sh.setFormatter(fmt)
        logger.addHandler(fh)
        logger.addHandler(sh)
        return logger


def main() -> int:
    parser = argparse.ArgumentParser(description="Agente Universe de Leonex")
    parser.add_argument("--size", type=int, default=DEFAULT_SIZE,
                        help="Tamano del top por momentum (default 30)")
    parser.add_argument("--lookback", type=int, default=DEFAULT_MOMENTUM_LOOKBACK,
                        help="Dias para calcular momentum (default 60)")
    parser.add_argument("--force", action="store_true",
                        help="Forzar rebalance aunque no toque por antigüedad")
    parser.add_argument("--no-legacy", action="store_true",
                        help="No incluir activos legacy (BTC/ETH/SOL/forex)")
    args = parser.parse_args()

    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")

    agente = AgenteUniverse(
        size=args.size,
        momentum_lookback=args.lookback,
        include_legacy=not args.no_legacy,
    )
    report = agente.run(force=args.force)

    sep = "=" * 64
    print(f"\n{sep}")
    print("Leonex -- Universe")
    print(sep)
    print(f"Rebalanceado     : {report.rebalanced} ({report.rebalance_reason})")
    print(f"Tamano universo  : {report.universe_size}")
    print(f"S&P 500 size     : {report.sp500_size}")
    print(f"Generado         : {report.generated_at}")
    if report.entries:
        print()
        print("Top tickers del universo activo:")
        print(f"  {'rank':>4} {'ticker':<10} {'score':>10}  {'reason':<14} {'class':<8}")
        for e in sorted(report.entries, key=lambda x: x.rank or 999):
            score_txt = f"{e.score*100:+.2f}%" if e.score else "—"
            print(f"  {e.rank:>4} {e.ticker:<10} {score_txt:>10}  {e.reason:<14} {e.asset_class:<8}")
    if report.changes_vs_previous:
        print()
        print("Cambios vs universo anterior:")
        for c in report.changes_vs_previous:
            tag = "[+]" if c.change == "entry" else "[-]"
            extra = f" score={c.score_now*100:+.2f}% rank={c.rank_now}" if c.score_now else ""
            print(f"  {tag} {c.ticker:<10}{extra}")
    print(sep)
    return 0


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