diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..43bc3ef
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,27 @@
+# ============================================================
+# InvestSkill — Configuración de variables de entorno
+# Copia este archivo como ".env" y rellena tus valores
+# NUNCA subas el archivo .env a git
+# ============================================================
+
+# --- IA (Claude API) --- requerido para narrativas en español
+ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxx
+
+# --- Bot de Telegram --- obtén tu token con @BotFather en Telegram
+TELEGRAM_BOT_TOKEN=xxxxxxxxxxxx:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+
+# --- Bot de WhatsApp (Twilio) --- https://console.twilio.com
+TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886 # Número sandbox de Twilio
+
+# --- Configuración de la app ---
+APP_ENV=development # development | production
+APP_PORT=8501 # Puerto de Streamlit
+WEBHOOK_PORT=5000 # Puerto del webhook de WhatsApp
+DEBUG=true
+
+# --- Caché de datos (segundos) ---
+CACHE_TTL=300 # 5 minutos (datos de mercado)
+CACHE_TTL_FINANCIALS=3600 # 1 hora (estados financieros)
+CACHE_TTL_NEWS=900 # 15 minutos (noticias)
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..893d985
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,460 @@
+"""
+InvestSkill — Dashboard Principal
+Plataforma de análisis de inversiones en tiempo real
+"""
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import streamlit as st
+import plotly.graph_objects as go
+import plotly.express as px
+import pandas as pd
+from datetime import datetime
+
+from core.data.fetcher import FetcherMercado, formatear_numero, color_cambio
+from core.analysis.fundamental import calcular_piotroski, calcular_roic_wacc, calidad_ganancias
+from core.analysis.valuation import calcular_dcf, valoracion_multiples, score_valoracion
+from core.analysis.technical import analizar as analizar_tecnico, retornos_historicos
+from core.ai.analyst import narrativa_evaluacion_accion, señal_inversion_final
+from config import MERCADOS, INDICES_REFERENCIA, tiene_api_claude
+
+# ── Configuración de página ───────────────────────────────────────────────────
+st.set_page_config(
+ page_title="InvestSkill — Análisis de Inversiones",
+ page_icon="📈",
+ layout="wide",
+ initial_sidebar_state="expanded",
+)
+
+# ── CSS personalizado ─────────────────────────────────────────────────────────
+st.markdown("""
+
+""", unsafe_allow_html=True)
+
+
+# ── Sidebar ───────────────────────────────────────────────────────────────────
+with st.sidebar:
+ st.image("https://img.icons8.com/fluency/96/stock-share.png", width=60)
+ st.title("InvestSkill")
+ st.caption("Análisis de inversiones en tiempo real")
+ st.divider()
+
+ # Buscador de ticker
+ ticker_input = st.text_input(
+ "🔍 Ticker del activo",
+ value=st.session_state.get("ticker_actual", "AAPL"),
+ placeholder="Ej: AAPL, BTC-USD, FEMSA.MX",
+ help="Ingresa el símbolo del activo. Para LatAm agrega el sufijo (.MX, .SA, .BA...)"
+ ).strip().upper()
+
+ mercado = st.selectbox(
+ "🌎 Mercado",
+ options=list(MERCADOS.keys()),
+ format_func=lambda k: f"{MERCADOS[k]['emoji']} {MERCADOS[k]['nombre']}",
+ index=0,
+ )
+
+ analizar_btn = st.button("📊 Analizar", type="primary", use_container_width=True)
+
+ st.divider()
+ st.caption("**Módulos disponibles**")
+ st.page_link("pages/1_📊_Fundamental.py", label="📊 Análisis Fundamental")
+ st.page_link("pages/2_💰_Valoracion.py", label="💰 Valoración DCF")
+ st.page_link("pages/3_📈_Tecnico.py", label="📈 Análisis Técnico")
+ st.page_link("pages/4_🌐_Macro.py", label="🌐 Economía Macro")
+ st.page_link("pages/5_💼_Portafolio.py", label="💼 Mi Portafolio")
+ st.page_link("pages/6_📋_Reporte.py", label="📋 Reporte Completo")
+ st.divider()
+
+ # Estado de API
+ if tiene_api_claude():
+ st.success("🤖 Claude API activa", icon="✅")
+ else:
+ st.warning("🤖 Claude API no configurada\nAgrega ANTHROPIC_API_KEY en .env", icon="⚠️")
+
+ st.caption(f"Datos: Yahoo Finance (retraso ~15 min)\n{datetime.now().strftime('%d/%m/%Y %H:%M')}")
+
+
+# ── Lógica principal ──────────────────────────────────────────────────────────
+if analizar_btn or "datos_cargados" not in st.session_state:
+ st.session_state["ticker_actual"] = ticker_input
+ st.session_state["mercado_actual"] = mercado
+ st.session_state["datos_cargados"] = False
+
+ticker_sel = st.session_state.get("ticker_actual", "AAPL")
+mercado_sel = st.session_state.get("mercado_actual", "us")
+
+
+@st.cache_data(ttl=300, show_spinner=False)
+def cargar_datos(ticker: str, mercado: str) -> dict:
+ f = FetcherMercado(ticker, mercado)
+ return f.resumen_completo()
+
+
+@st.cache_data(ttl=300, show_spinner=False)
+def cargar_historial(ticker: str, mercado: str, periodo: str = "1y"):
+ f = FetcherMercado(ticker, mercado)
+ return f.precio_historico(periodo)
+
+
+@st.cache_data(ttl=300, show_spinner=False)
+def cargar_indices() -> dict:
+ resultados = {}
+ for nombre, sym in list(INDICES_REFERENCIA.items())[:6]:
+ try:
+ f = FetcherMercado(sym)
+ p = f.precio_actual()
+ resultados[nombre] = p
+ except Exception:
+ pass
+ return resultados
+
+
+# ── Header ────────────────────────────────────────────────────────────────────
+st.markdown("# 📈 InvestSkill — Dashboard de Inversiones")
+st.caption("Datos en tiempo real • Análisis fundamental, técnico y macroeconómico • IA en español")
+
+# Barra de índices en tiempo real
+with st.spinner("Cargando índices..."):
+ indices = cargar_indices()
+
+if indices:
+ cols_idx = st.columns(len(indices))
+ for i, (nombre, data) in enumerate(indices.items()):
+ precio = data.get("precio")
+ cambio = data.get("cambio_pct", 0)
+ with cols_idx[i]:
+ color = "#00c851" if cambio >= 0 else "#ff4444"
+ signo = "▲" if cambio >= 0 else "▼"
+ st.markdown(
+ f"
"
+ f"
{nombre}
"
+ f"
{precio:,.2f}
"
+ f"
{signo} {abs(cambio):.2f}%
"
+ f"
",
+ unsafe_allow_html=True,
+ )
+
+st.divider()
+
+# ── Cargar activo seleccionado ────────────────────────────────────────────────
+with st.spinner(f"Cargando datos de {ticker_sel}..."):
+ try:
+ datos = cargar_datos(ticker_sel, mercado_sel)
+ hist_1y = cargar_historial(ticker_sel, mercado_sel, "1y")
+ datos_cargados = True
+ except Exception as e:
+ st.error(f"Error al cargar datos: {e}")
+ st.stop()
+
+precio_d = datos["precio"]
+fund = datos["fundamentales"]
+consenso = datos["consenso"]
+noticias = datos["noticias"]
+
+nombre_activo = precio_d.get("nombre", ticker_sel)
+precio_val = precio_d.get("precio")
+cambio_pct = precio_d.get("cambio_pct", 0)
+moneda = precio_d.get("moneda", "USD")
+
+# ── Encabezado del activo ─────────────────────────────────────────────────────
+col_titulo, col_señal = st.columns([3, 1])
+
+with col_titulo:
+ color_p = "#00c851" if cambio_pct >= 0 else "#ff4444"
+ signo_p = "▲" if cambio_pct >= 0 else "▼"
+ st.markdown(
+ f"## {nombre_activo} `{ticker_sel}`\n"
+ f"### {moneda} {precio_val:,.4f} {signo_p} {abs(cambio_pct):.2f}%",
+ unsafe_allow_html=True,
+ )
+ st.caption(f"{precio_d.get('sector', '')} • {precio_d.get('industria', '')} • {precio_d.get('pais', '')} • {precio_d.get('intercambio', '')}")
+
+# Calcular señal final
+with col_señal:
+ piot_raw = datos.get("piotroski_raw", {})
+ piotroski = calcular_piotroski(piot_raw)
+ roic_wacc = calcular_roic_wacc(fund, piot_raw)
+ dcf_res = calcular_dcf(fund, piot_raw)
+ tech_res = analizar_tecnico(hist_1y)
+
+ señal_final = señal_inversion_final(piotroski, roic_wacc, dcf_res, tech_res, consenso)
+ score_f = señal_final["score"]
+ accion = señal_final["accion"]
+ conf = señal_final["confianza"]
+ c_f = señal_final["color"]
+
+ st.markdown(
+ f""
+ f"
{accion}
"
+ f"
{score_f}/10
"
+ f"
Confianza: {conf}
"
+ f"
",
+ unsafe_allow_html=True,
+ )
+
+st.divider()
+
+# ── Métricas clave ────────────────────────────────────────────────────────────
+col1, col2, col3, col4, col5, col6 = st.columns(6)
+
+metricas = [
+ ("Cap. Mercado", formatear_numero(fund.get("cap_mercado"), prefijo="$"), None),
+ ("P/E TTM", f"{fund.get('pe_ttm', 'N/D')}x" if fund.get("pe_ttm") else "N/D", None),
+ ("EV/EBITDA", f"{fund.get('ev_ebitda', 'N/D')}x" if fund.get("ev_ebitda") else "N/D", None),
+ ("Margen Neto", f"{fund.get('margen_neto', 'N/D')}%" if fund.get("margen_neto") else "N/D", fund.get("margen_neto")),
+ ("FCF Yield", f"{fund.get('fcf_yield', 'N/D')}%" if fund.get("fcf_yield") else "N/D", fund.get("fcf_yield")),
+ ("Beta", f"{fund.get('beta', 'N/D')}" if fund.get("beta") else "N/D", None),
+]
+
+for col, (label, valor, delta) in zip([col1, col2, col3, col4, col5, col6], metricas):
+ with col:
+ st.metric(label=label, value=valor)
+
+# ── Gráfico de precio ─────────────────────────────────────────────────────────
+st.subheader("Precio histórico")
+
+periodo_sel = st.radio("Período", ["1m", "3m", "6m", "1y", "2y", "5y"],
+ index=3, horizontal=True, key="periodo_precio")
+
+@st.cache_data(ttl=300, show_spinner=False)
+def _hist(t, m, p): return cargar_historial(t, m, p)
+
+hist = _hist(ticker_sel, mercado_sel, periodo_sel)
+
+if hist is not None and not hist.empty:
+ fig = go.Figure()
+
+ # Área de precio
+ fig.add_trace(go.Scatter(
+ x=hist.index, y=hist["Close"],
+ fill="tozeroy",
+ fillcolor="rgba(26,115,232,0.1)",
+ line=dict(color="#1a73e8", width=2),
+ name="Precio cierre",
+ hovertemplate=f"{moneda} %{{y:,.4f}}",
+ ))
+
+ # SMA 50 y 200
+ if len(hist) >= 50:
+ sma50 = hist["Close"].rolling(50).mean()
+ fig.add_trace(go.Scatter(x=hist.index, y=sma50, line=dict(color="#ffc107", width=1.5, dash="dot"),
+ name="SMA 50", hovertemplate="SMA50: %{y:,.2f}"))
+ if len(hist) >= 200:
+ sma200 = hist["Close"].rolling(200).mean()
+ fig.add_trace(go.Scatter(x=hist.index, y=sma200, line=dict(color="#ff7043", width=1.5, dash="dot"),
+ name="SMA 200", hovertemplate="SMA200: %{y:,.2f}"))
+
+ fig.update_layout(
+ template="plotly_dark",
+ height=380,
+ margin=dict(l=0, r=0, t=10, b=0),
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
+ xaxis=dict(showgrid=False),
+ yaxis=dict(showgrid=True, gridcolor="#1e2530"),
+ hovermode="x unified",
+ )
+ st.plotly_chart(fig, use_container_width=True)
+
+ # Volumen
+ if "Volume" in hist.columns:
+ vol_colors = ["#00c851" if c >= o else "#ff4444"
+ for c, o in zip(hist["Close"], hist["Open"])]
+ fig_vol = go.Figure(go.Bar(
+ x=hist.index, y=hist["Volume"],
+ marker_color=vol_colors, opacity=0.7, name="Volumen",
+ ))
+ fig_vol.update_layout(template="plotly_dark", height=120,
+ margin=dict(l=0, r=0, t=0, b=0),
+ xaxis=dict(showgrid=False), yaxis=dict(showgrid=False),
+ showlegend=False)
+ st.plotly_chart(fig_vol, use_container_width=True)
+else:
+ st.warning("No hay datos históricos disponibles para este período.")
+
+# ── Tabs de análisis ──────────────────────────────────────────────────────────
+tab_resumen, tab_fund, tab_tesis, tab_noticias = st.tabs([
+ "📊 Resumen",
+ "📋 Fundamentales",
+ "🤖 Tesis IA",
+ "📰 Noticias",
+])
+
+# ── Tab Resumen ───────────────────────────────────────────────────────────────
+with tab_resumen:
+ c1, c2, c3 = st.columns(3)
+
+ with c1:
+ st.markdown("**Valoración**")
+ data_val = {
+ "Métrica": ["P/E TTM", "P/E Forward", "PEG", "P/Ventas", "P/Libro", "EV/EBITDA"],
+ "Valor": [
+ f"{fund.get('pe_ttm', 'N/D')}x",
+ f"{fund.get('pe_forward', 'N/D')}x",
+ f"{fund.get('peg', 'N/D')}x",
+ f"{fund.get('precio_ventas', 'N/D')}x",
+ f"{fund.get('precio_valor_libro', 'N/D')}x",
+ f"{fund.get('ev_ebitda', 'N/D')}x",
+ ]
+ }
+ st.dataframe(pd.DataFrame(data_val), hide_index=True, use_container_width=True)
+
+ with c2:
+ st.markdown("**Rentabilidad**")
+ data_rent = {
+ "Métrica": ["Margen Bruto", "Margen Op.", "Margen Neto", "EBITDA Margin", "ROE", "ROA"],
+ "Valor": [
+ f"{fund.get('margen_bruto', 'N/D')}%",
+ f"{fund.get('margen_operativo', 'N/D')}%",
+ f"{fund.get('margen_neto', 'N/D')}%",
+ f"{fund.get('margen_ebitda', 'N/D')}%",
+ f"{fund.get('roe', 'N/D')}%",
+ f"{fund.get('roa', 'N/D')}%",
+ ]
+ }
+ st.dataframe(pd.DataFrame(data_rent), hide_index=True, use_container_width=True)
+
+ with c3:
+ st.markdown("**Balance y Caja**")
+ data_bal = {
+ "Métrica": ["Deuda/Equity", "Ratio Corriente", "Caja Total", "Deuda Total", "Deuda Neta", "FCF"],
+ "Valor": [
+ f"{fund.get('deuda_equity', 'N/D')}x",
+ f"{fund.get('ratio_corriente', 'N/D')}x",
+ formatear_numero(fund.get("caja_total"), prefijo="$"),
+ formatear_numero(fund.get("deuda_total"), prefijo="$"),
+ formatear_numero(fund.get("deuda_neta"), prefijo="$"),
+ formatear_numero(fund.get("fcf"), prefijo="$"),
+ ]
+ }
+ st.dataframe(pd.DataFrame(data_bal), hide_index=True, use_container_width=True)
+
+ # Consenso analistas
+ st.divider()
+ st.markdown("**Consenso de Analistas**")
+ ca1, ca2, ca3, ca4 = st.columns(4)
+ ca1.metric("Recomendación", consenso.get("recomendacion", "N/D"))
+ ca2.metric("Precio objetivo",
+ f"${consenso.get('precio_objetivo_medio', 'N/D')}" if consenso.get("precio_objetivo_medio") else "N/D")
+ ca3.metric("Upside potencial",
+ f"{consenso.get('upside_potencial_pct', 'N/D')}%" if consenso.get("upside_potencial_pct") else "N/D",
+ delta=None)
+ ca4.metric("Nº analistas", consenso.get("num_analistas", "N/D"))
+
+ # Piotroski y score técnico
+ st.divider()
+ ps1, ps2 = st.columns(2)
+ with ps1:
+ piot_score = piotroski.get("score")
+ st.markdown("**Piotroski F-Score**")
+ if piot_score is not None:
+ st.markdown(
+ f"{piot_score}/9
"
+ f"{piotroski.get('interpretacion')}
",
+ unsafe_allow_html=True,
+ )
+ else:
+ st.info("Sin datos para calcular Piotroski")
+
+ with ps2:
+ st.markdown("**Score Técnico**")
+ tech_sc = tech_res.get("score", {})
+ if tech_sc and not tech_res.get("error"):
+ st.markdown(
+ f"{tech_sc.get('score')}/10
"
+ f"{tech_sc.get('señal')}
",
+ unsafe_allow_html=True,
+ )
+ else:
+ st.info("Sin datos técnicos")
+
+# ── Tab Fundamentales ─────────────────────────────────────────────────────────
+with tab_fund:
+ estados = datos.get("estados", {})
+ ingresos = estados.get("ingresos", {})
+ años = ingresos.get("años", [])
+
+ if años:
+ st.markdown("**Evolución financiera (últimos 4 años)**")
+
+ métricas_grafico = {
+ "Ingresos (MM USD)": ingresos.get("ingresos_totales", {}),
+ "Beneficio Bruto (MM)": ingresos.get("beneficio_bruto", {}),
+ "Beneficio Neto (MM)": ingresos.get("beneficio_neto", {}),
+ }
+
+ for titulo, serie_dict in métricas_grafico.items():
+ if any(v is not None for v in serie_dict.values()):
+ df_plot = pd.DataFrame({"Año": list(serie_dict.keys()), "Valor": list(serie_dict.values())})
+ df_plot = df_plot.dropna(subset=["Valor"])
+ if not df_plot.empty:
+ fig_bar = px.bar(df_plot, x="Año", y="Valor", title=titulo,
+ color_discrete_sequence=["#1a73e8"], template="plotly_dark")
+ fig_bar.update_layout(height=250, margin=dict(l=0, r=0, t=30, b=0),
+ showlegend=False)
+ st.plotly_chart(fig_bar, use_container_width=True)
+ else:
+ st.info("Estados financieros detallados disponibles en la página de Análisis Fundamental.")
+
+# ── Tab Tesis IA ──────────────────────────────────────────────────────────────
+with tab_tesis:
+ if st.button("🤖 Generar análisis con IA", type="primary"):
+ with st.spinner("Analizando con Claude..."):
+ tesis = narrativa_evaluacion_accion(datos)
+ st.markdown(tesis)
+ else:
+ st.info("Haz clic en el botón para generar el análisis narrativo completo con IA en español.")
+ if not tiene_api_claude():
+ st.warning("Para activar IA: agrega `ANTHROPIC_API_KEY=sk-ant-...` en el archivo `.env` y reinicia la app.")
+
+# ── Tab Noticias ──────────────────────────────────────────────────────────────
+with tab_noticias:
+ if noticias:
+ for n in noticias:
+ with st.container():
+ st.markdown(
+ f"**[{n['titulo']}]({n['url']})** \n"
+ f"{n['fuente']} • {n['fecha']}",
+ unsafe_allow_html=True,
+ )
+ if n.get("resumen"):
+ st.caption(n["resumen"][:200] + "..." if len(n.get("resumen","")) > 200 else n["resumen"])
+ st.divider()
+ else:
+ st.info("No hay noticias recientes disponibles para este activo.")
+
+# ── Footer ────────────────────────────────────────────────────────────────────
+st.divider()
+st.caption(
+ "⚠️ **Aviso:** Este análisis es solo educativo. No constituye asesoramiento financiero. "
+ "Los datos tienen retraso de ~15 minutos. Verifica siempre la información antes de invertir. "
+ f"| InvestSkill v2.0 | {datetime.now().strftime('%d/%m/%Y %H:%M')}"
+)
diff --git "a/app/pages/1_\360\237\223\212_Fundamental.py" "b/app/pages/1_\360\237\223\212_Fundamental.py"
new file mode 100644
index 0000000..2820301
--- /dev/null
+++ "b/app/pages/1_\360\237\223\212_Fundamental.py"
@@ -0,0 +1,203 @@
+"""InvestSkill — Página: Análisis Fundamental completo"""
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+import streamlit as st
+import plotly.graph_objects as go
+import plotly.express as px
+import pandas as pd
+
+from core.data.fetcher import FetcherMercado, formatear_numero
+from core.analysis.fundamental import calcular_piotroski, calcular_roic_wacc, calidad_ganancias
+
+st.set_page_config(page_title="Análisis Fundamental — InvestSkill", page_icon="📊", layout="wide")
+st.title("📊 Análisis Fundamental")
+
+# ── Input ─────────────────────────────────────────────────────────────────────
+col_t, col_b = st.columns([4, 1])
+with col_t:
+ ticker = st.text_input("Ticker", value=st.session_state.get("ticker_actual", "AAPL"),
+ placeholder="AAPL, MSFT, VALE3.SA...").strip().upper()
+with col_b:
+ st.write("")
+ analizar = st.button("Analizar", type="primary", use_container_width=True)
+
+
+@st.cache_data(ttl=3600, show_spinner=False)
+def cargar(ticker):
+ f = FetcherMercado(ticker)
+ return {
+ "precio": f.precio_actual(),
+ "fund": f.fundamentales(),
+ "estados": f.estados_financieros(),
+ "piot_raw": f.datos_piotroski(),
+ }
+
+
+with st.spinner(f"Cargando datos fundamentales de {ticker}..."):
+ datos = cargar(ticker)
+
+fund = datos["fund"]
+estados = datos["estados"]
+piot_raw = datos["piot_raw"]
+precio_d = datos["precio"]
+
+# ── Piotroski F-Score ─────────────────────────────────────────────────────────
+st.subheader("Piotroski F-Score")
+piot = calcular_piotroski(piot_raw)
+
+if piot.get("error"):
+ st.warning(f"No se pudo calcular Piotroski: {piot['error']}")
+else:
+ col_score, col_detalle = st.columns([1, 3])
+ with col_score:
+ score = piot["score"]
+ color = piot["color"]
+ st.markdown(
+ f""
+ f"
{score}
"
+ f"
de 9 puntos
"
+ f"
{piot['interpretacion']}
"
+ f"
",
+ unsafe_allow_html=True,
+ )
+
+ with col_detalle:
+ criterios = piot["criterios"]
+ filas = []
+ for clave, c in criterios.items():
+ filas.append({
+ "Criterio": c["descripcion"],
+ "Valor": f"{c['valor']} {c.get('unidad','')}" if c["valor"] is not None else "N/D",
+ "Resultado": "✅ PASA" if c["pasa"] else "❌ FALLA",
+ "Puntos": c["puntos"],
+ })
+ df_piot = pd.DataFrame(filas)
+ st.dataframe(df_piot, hide_index=True, use_container_width=True,
+ column_config={"Puntos": st.column_config.NumberColumn(format="%d")})
+
+# ── ROIC / WACC / EVA ─────────────────────────────────────────────────────────
+st.divider()
+st.subheader("ROIC / WACC / EVA")
+rw = calcular_roic_wacc(fund, piot_raw)
+
+r1, r2, r3, r4 = st.columns(4)
+r1.metric("ROIC", f"{rw.get('roic_pct', 'N/D')}%" if rw.get("roic_pct") else "N/D")
+r2.metric("WACC", f"{rw.get('wacc_pct', 'N/D')}%")
+r3.metric("Spread ROIC-WACC",
+ f"{rw.get('spread_roic_wacc', 'N/D')}pp" if rw.get("spread_roic_wacc") is not None else "N/D",
+ delta=rw.get("spread_roic_wacc"))
+r4.metric("EVA", formatear_numero(rw.get("eva_mm"), prefijo="$") + "M" if rw.get("eva_mm") else "N/D")
+
+st.markdown(
+ f"{rw.get('señal','')}
",
+ unsafe_allow_html=True,
+)
+
+# WACC desglose
+with st.expander("Desglose WACC"):
+ wd1, wd2, wd3 = st.columns(3)
+ wd1.metric("Costo Equity (Ke)", f"{rw.get('ke_pct')}%")
+ wd2.metric("Costo Deuda (Kd)", f"{rw.get('kd_pct')}%")
+ wd3.metric("Peso Equity / Deuda", f"{rw.get('peso_equity')}% / {rw.get('peso_deuda')}%")
+ st.caption(f"Beta: {rw.get('beta')} | Rf: {rw.get('tasa_libre_riesgo_pct')}% | Prima riesgo: {rw.get('prima_riesgo_pct')}%")
+
+# ── Estados Financieros ───────────────────────────────────────────────────────
+st.divider()
+st.subheader("Estados Financieros (últimos 4 años)")
+
+tabs_est = st.tabs(["Cuenta de Resultados", "Balance General", "Flujo de Caja"])
+
+with tabs_est[0]:
+ ingresos = estados.get("ingresos", {})
+ años = ingresos.get("años", [])
+ if años:
+ metricas_p = {
+ "Ingresos Totales": ingresos.get("ingresos_totales", {}),
+ "Beneficio Bruto": ingresos.get("beneficio_bruto", {}),
+ "EBITDA": ingresos.get("ebitda", {}),
+ "EBIT": ingresos.get("ebit", {}),
+ "Beneficio Neto": ingresos.get("beneficio_neto", {}),
+ "EPS Básico": ingresos.get("eps_basico", {}),
+ "EPS Diluido": ingresos.get("eps_diluido", {}),
+ }
+ filas_p = []
+ for nombre, serie in metricas_p.items():
+ fila = {"Métrica": nombre}
+ fila.update(serie)
+ filas_p.append(fila)
+ df_p = pd.DataFrame(filas_p).set_index("Métrica")
+ st.dataframe(df_p, use_container_width=True)
+
+ # Gráfico de evolución
+ ig = ingresos.get("ingresos_totales", {})
+ ng = ingresos.get("beneficio_neto", {})
+ if any(v is not None for v in ig.values()):
+ fig = go.Figure()
+ fig.add_trace(go.Bar(x=list(ig.keys()), y=[v or 0 for v in ig.values()],
+ name="Ingresos", marker_color="#1a73e8"))
+ fig.add_trace(go.Bar(x=list(ng.keys()), y=[v or 0 for v in ng.values()],
+ name="Beneficio Neto", marker_color="#00c851"))
+ fig.update_layout(template="plotly_dark", height=300, barmode="group",
+ margin=dict(l=0, r=0, t=10, b=0), yaxis_title="MM USD")
+ st.plotly_chart(fig, use_container_width=True)
+ else:
+ st.info("Estados de resultados no disponibles para este activo.")
+
+with tabs_est[1]:
+ balance = estados.get("balance", {})
+ años_b = balance.get("años", [])
+ if años_b:
+ metricas_b = {
+ "Activos Totales": balance.get("activos_totales", {}),
+ "Activos Corrientes": balance.get("activos_corrientes", {}),
+ "Caja y Equivalentes": balance.get("caja", {}),
+ "Inventario": balance.get("inventario", {}),
+ "Pasivos Totales": balance.get("pasivos_totales", {}),
+ "Pasivos Corrientes": balance.get("pasivos_corrientes", {}),
+ "Deuda Largo Plazo": balance.get("deuda_largo_plazo", {}),
+ "Patrimonio": balance.get("patrimonio", {}),
+ }
+ filas_b = [{"Métrica": n, **s} for n, s in metricas_b.items()]
+ st.dataframe(pd.DataFrame(filas_b).set_index("Métrica"), use_container_width=True)
+ else:
+ st.info("Balance general no disponible.")
+
+with tabs_est[2]:
+ cf = estados.get("flujo_caja", {})
+ años_c = cf.get("años", [])
+ if años_c:
+ metricas_c = {
+ "Flujo Operativo": cf.get("flujo_operativo", {}),
+ "CapEx": cf.get("capex", {}),
+ "Flujo Libre (FCF)": cf.get("flujo_libre", {}),
+ "Dividendos Pagados": cf.get("dividendos_pagados", {}),
+ "Recompra Acciones": cf.get("recompra_acciones", {}),
+ }
+ filas_c = [{"Métrica": n, **s} for n, s in metricas_c.items()]
+ st.dataframe(pd.DataFrame(filas_c).set_index("Métrica"), use_container_width=True)
+
+ # Gráfico FCF
+ fcf_serie = cf.get("flujo_libre", {})
+ if any(v is not None for v in fcf_serie.values()):
+ fig_cf = px.bar(x=list(fcf_serie.keys()), y=[v or 0 for v in fcf_serie.values()],
+ title="Flujo Libre de Caja (FCF) — MM USD",
+ color_discrete_sequence=["#00c851"], template="plotly_dark")
+ fig_cf.update_layout(height=250, margin=dict(l=0,r=0,t=30,b=0), showlegend=False)
+ st.plotly_chart(fig_cf, use_container_width=True)
+ else:
+ st.info("Flujo de caja no disponible.")
+
+# ── Calidad de ganancias ───────────────────────────────────────────────────────
+st.divider()
+st.subheader("Calidad de Ganancias")
+cg = calidad_ganancias(fund, piot_raw)
+cg1, cg2 = st.columns(2)
+with cg1:
+ st.metric("Tasa de Conversión a Caja (CCR)", f"{cg.get('tasa_conversion_caja', 'N/D')}x")
+ st.caption(cg.get("ccr_señal", ""))
+with cg2:
+ st.metric("Ratio de Accruals", f"{cg.get('ratio_accruals_pct', 'N/D')}%")
+ st.caption(cg.get("accruals_señal", ""))
diff --git "a/app/pages/2_\360\237\222\260_Valoracion.py" "b/app/pages/2_\360\237\222\260_Valoracion.py"
new file mode 100644
index 0000000..67d0654
--- /dev/null
+++ "b/app/pages/2_\360\237\222\260_Valoracion.py"
@@ -0,0 +1,183 @@
+"""InvestSkill — Página: Valoración DCF y múltiplos"""
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+import streamlit as st
+import plotly.graph_objects as go
+import pandas as pd
+import numpy as np
+
+from core.data.fetcher import FetcherMercado, formatear_numero
+from core.analysis.valuation import calcular_dcf, valoracion_multiples, tabla_sensibilidad, score_valoracion
+from core.analysis.fundamental import calcular_piotroski
+from core.ai.analyst import narrativa_dcf
+
+st.set_page_config(page_title="Valoración — InvestSkill", page_icon="💰", layout="wide")
+st.title("💰 Valoración: DCF y Múltiplos")
+
+# ── Input ─────────────────────────────────────────────────────────────────────
+col_t, col_b = st.columns([4, 1])
+with col_t:
+ ticker = st.text_input("Ticker", value=st.session_state.get("ticker_actual", "AAPL")).strip().upper()
+with col_b:
+ st.write("")
+ st.button("Analizar", type="primary", use_container_width=True)
+
+# Parámetros del DCF
+with st.expander("⚙️ Ajustar parámetros del DCF"):
+ p1, p2, p3, p4 = st.columns(4)
+ crec_f1 = p1.slider("Crecimiento Fase 1 (años 1-5) %", 0, 50, 10) / 100
+ crec_f2 = p2.slider("Crecimiento Fase 2 (años 6-10) %", 0, 20, 4) / 100
+ g_term = p3.slider("Crecimiento terminal %", 0, 5, 2) / 100
+ wacc_ov = p4.slider("WACC personalizado % (0=auto)", 0, 20, 0) / 100
+ wacc_override = wacc_ov if wacc_ov > 0 else None
+
+
+@st.cache_data(ttl=3600, show_spinner=False)
+def cargar(ticker):
+ f = FetcherMercado(ticker)
+ fund = f.fundamentales()
+ piot_raw = f.datos_piotroski()
+ return fund, piot_raw
+
+
+with st.spinner(f"Cargando datos de {ticker}..."):
+ fund, piot_raw = cargar(ticker)
+
+# ── DCF ───────────────────────────────────────────────────────────────────────
+st.subheader("Modelo DCF — Flujo de Caja Descontado")
+dcf = calcular_dcf(fund, piot_raw, crec_f1, crec_f2, wacc_override=wacc_override, g_terminal=g_term)
+
+if dcf.get("error"):
+ st.error(f"DCF no disponible: {dcf['error']}")
+else:
+ # Escenarios
+ esc = dcf["escenarios"]
+ cols_e = st.columns(3)
+ for i, (nombre, col) in enumerate(zip(["bajista", "base", "alcista"], cols_e)):
+ e = esc[nombre]
+ vi = e["valor_intrinseco"]
+ pm = e["precio_mercado"] or 0
+ mg = e["margen_seguridad_pct"]
+ color = "#00c851" if (mg and mg > 0) else "#ff4444"
+ etiqueta = {"bajista": "🐻 Caso Bajista", "base": "📊 Caso Base", "alcista": "🐂 Caso Alcista"}
+ with col:
+ st.markdown(
+ f""
+ f"
{etiqueta[nombre]}
"
+ f"
${vi:,.2f}
"
+ f"
"
+ f"{'▲' if (mg and mg>0) else '▼'} Margen: {abs(mg or 0):.1f}%
"
+ f"
"
+ f"WACC: {e['wacc_pct']}% | g: {e['crec_fase1_pct']}%/{e['crec_fase2_pct']}%
"
+ f"
Prob: {int(e['probabilidad']*100)}%
"
+ f"
",
+ unsafe_allow_html=True,
+ )
+
+ st.markdown(f"**Precio mercado:** ${dcf.get('precio_mercado', 0):,.2f} | "
+ f"**Precio ponderado (prob.):** ${dcf.get('precio_ponderado', 0):,.2f}")
+ st.markdown(
+ f"{dcf['señal']}
",
+ unsafe_allow_html=True,
+ )
+
+ # Gráfico de FCFs proyectados
+ base_fcfs = esc["base"]["fcfs_proyectados"]
+ if base_fcfs:
+ df_fcf = pd.DataFrame(base_fcfs)
+ fig_fcf = go.Figure()
+ fig_fcf.add_trace(go.Bar(x=df_fcf["año"], y=df_fcf["fcf"],
+ name="FCF Proyectado (MM)", marker_color="#1a73e8"))
+ fig_fcf.add_trace(go.Scatter(x=df_fcf["año"], y=df_fcf["vp"],
+ name="VP Descontado (MM)", mode="lines+markers",
+ line=dict(color="#ffc107", width=2)))
+ fig_fcf.update_layout(template="plotly_dark", height=300, barmode="group",
+ margin=dict(l=0, r=0, t=10, b=0),
+ xaxis_title="Año", yaxis_title="MM USD")
+ st.plotly_chart(fig_fcf, use_container_width=True)
+
+ # Tabla de sensibilidad
+ st.divider()
+ st.subheader("Tabla de Sensibilidad — Valor Intrínseco por Acción")
+ st.caption("Filas: WACC | Columnas: Tasa de crecimiento terminal")
+
+ with st.spinner("Calculando tabla de sensibilidad..."):
+ tabla = tabla_sensibilidad(fund, piot_raw)
+
+ df_sens = pd.DataFrame(tabla).T
+ precio_actual = fund.get("precio") or 0
+
+ def colorear(val):
+ if val is None: return ""
+ try:
+ v = float(val)
+ if v > precio_actual * 1.3: return "background-color: #004d1a; color: #00c851"
+ elif v > precio_actual * 1.1: return "background-color: #003311; color: #80d9a0"
+ elif v > precio_actual * 0.9: return "background-color: #332a00; color: #ffc107"
+ else: return "background-color: #330000; color: #ff4444"
+ except Exception:
+ return ""
+
+ styled = df_sens.style.applymap(colorear).format("{:.2f}", na_rep="N/D")
+ st.dataframe(styled, use_container_width=True)
+
+ # Narrativa IA
+ st.divider()
+ if st.button("🤖 Interpretar DCF con IA"):
+ with st.spinner("Analizando..."):
+ texto = narrativa_dcf(dcf, ticker)
+ st.markdown(texto)
+
+# ── Valoración por múltiplos ──────────────────────────────────────────────────
+st.divider()
+st.subheader("Valoración por Múltiplos de Mercado")
+mult = valoracion_multiples(fund)
+
+if mult["metodos"]:
+ cols_m = st.columns(len(mult["metodos"]))
+ for i, (clave, m) in enumerate(mult["metodos"].items()):
+ vi = m["valor"]
+ pm = mult["precio_mercado"] or 0
+ diff_pct = round((vi - pm) / pm * 100, 1) if pm else None
+ color = "#00c851" if (diff_pct and diff_pct > 0) else "#ff4444"
+ with cols_m[i]:
+ st.markdown(
+ f""
+ f"
{m['metodo']}
"
+ f"
${vi:,.2f}
"
+ f"
{'▲' if (diff_pct and diff_pct>0) else '▼'} {abs(diff_pct or 0):.1f}%
"
+ f"
{m['unidad_base']}: {m['base']}
"
+ f"
",
+ unsafe_allow_html=True,
+ )
+
+ precio_prom = mult.get("precio_promedio")
+ if precio_prom:
+ st.markdown(
+ f"**Precio promedio (todos los métodos):** ${precio_prom:,.2f} | "
+ f"**Precio mercado:** ${mult.get('precio_mercado', 0):,.2f} | "
+ f"**Upside estimado:** {mult.get('upside_pct', 'N/D')}%"
+ )
+
+ # Football field chart
+ pm = mult.get("precio_mercado") or 0
+ fig_ff = go.Figure()
+ colores_ff = ["#1a73e8", "#00c851", "#ffc107", "#ff7043"]
+ for i, (clave, m) in enumerate(mult["metodos"].items()):
+ fig_ff.add_trace(go.Bar(
+ y=[m["metodo"]], x=[m["valor"]],
+ orientation="h", name=m["metodo"],
+ marker_color=colores_ff[i % len(colores_ff)],
+ ))
+ if pm:
+ fig_ff.add_vline(x=pm, line_dash="dash", line_color="#ffffff",
+ annotation_text=f"Precio actual ${pm:.2f}", annotation_position="top right")
+ fig_ff.update_layout(template="plotly_dark", height=200,
+ margin=dict(l=0, r=0, t=10, b=0), showlegend=False,
+ xaxis_title="Valor por acción (USD)")
+ st.plotly_chart(fig_ff, use_container_width=True)
+else:
+ st.info("No hay suficientes datos para calcular valoración por múltiplos.")
diff --git "a/app/pages/3_\360\237\223\210_Tecnico.py" "b/app/pages/3_\360\237\223\210_Tecnico.py"
new file mode 100644
index 0000000..464513d
--- /dev/null
+++ "b/app/pages/3_\360\237\223\210_Tecnico.py"
@@ -0,0 +1,177 @@
+"""InvestSkill — Página: Análisis Técnico"""
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+import streamlit as st
+import plotly.graph_objects as go
+import pandas as pd
+
+from core.data.fetcher import FetcherMercado
+from core.analysis.technical import analizar, retornos_historicos
+from core.ai.analyst import narrativa_tecnica
+
+st.set_page_config(page_title="Análisis Técnico — InvestSkill", page_icon="📈", layout="wide")
+st.title("📈 Análisis Técnico")
+
+col_t, col_b = st.columns([4, 1])
+with col_t:
+ ticker = st.text_input("Ticker", value=st.session_state.get("ticker_actual", "AAPL")).strip().upper()
+with col_b:
+ st.write("")
+ st.button("Analizar", type="primary", use_container_width=True)
+
+periodo = st.radio("Período histórico", ["3m", "6m", "1y", "2y"], index=2, horizontal=True)
+
+
+@st.cache_data(ttl=300, show_spinner=False)
+def cargar(ticker, periodo):
+ f = FetcherMercado(ticker)
+ return f.precio_historico(periodo)
+
+
+with st.spinner(f"Cargando datos técnicos de {ticker}..."):
+ hist = cargar(ticker, periodo)
+
+if hist is None or hist.empty:
+ st.error("No hay datos históricos disponibles.")
+ st.stop()
+
+# Analizar
+tech = analizar(hist)
+rets = retornos_historicos(hist)
+
+if tech.get("error"):
+ st.error(tech["error"])
+ st.stop()
+
+# ── Score técnico ─────────────────────────────────────────────────────────────
+sc = tech.get("score", {})
+tend = tech.get("tendencia", {})
+
+s1, s2, s3 = st.columns(3)
+s1.markdown(
+ f""
+ f"
SCORE TÉCNICO
"
+ f"
{sc.get('score')}/10
"
+ f"
{sc.get('señal')}
"
+ f"
",
+ unsafe_allow_html=True,
+)
+s2.markdown(
+ f""
+ f"
TENDENCIA
"
+ f"
{tend.get('tendencia')}
"
+ f"
{' '.join(tend.get('señales', []))}
"
+ f"
",
+ unsafe_allow_html=True,
+)
+
+rsi_d = tech.get("rsi", {})
+s3.markdown(
+ f""
+ f"
RSI (14)
"
+ f"
{rsi_d.get('valor', 'N/D')}
"
+ f"
{rsi_d.get('señal')}
"
+ f"
",
+ unsafe_allow_html=True,
+)
+
+# ── Gráfico de velas + indicadores ───────────────────────────────────────────
+st.divider()
+fig = go.Figure()
+
+# Velas
+fig.add_trace(go.Candlestick(
+ x=hist.index,
+ open=hist["Open"], high=hist["High"],
+ low=hist["Low"], close=hist["Close"],
+ name="OHLC",
+ increasing_line_color="#00c851",
+ decreasing_line_color="#ff4444",
+))
+
+mm = tech.get("medias_moviles", {})
+if mm.get("sma50"):
+ sma50 = hist["Close"].rolling(50).mean()
+ fig.add_trace(go.Scatter(x=hist.index, y=sma50, name="SMA 50",
+ line=dict(color="#ffc107", width=1.5, dash="dot")))
+if mm.get("sma200"):
+ sma200 = hist["Close"].rolling(200).mean()
+ fig.add_trace(go.Scatter(x=hist.index, y=sma200, name="SMA 200",
+ line=dict(color="#ff7043", width=1.5, dash="dot")))
+
+# Bandas Bollinger
+bb = tech.get("bollinger", {})
+if bb.get("superior"):
+ bb_up = hist["Close"].rolling(20).mean() + 2 * hist["Close"].rolling(20).std()
+ bb_dn = hist["Close"].rolling(20).mean() - 2 * hist["Close"].rolling(20).std()
+ fig.add_trace(go.Scatter(x=hist.index, y=bb_up, name="BB Superior",
+ line=dict(color="#7b68ee", width=1, dash="dash"), opacity=0.7))
+ fig.add_trace(go.Scatter(x=hist.index, y=bb_dn, name="BB Inferior",
+ line=dict(color="#7b68ee", width=1, dash="dash"), opacity=0.7,
+ fill="tonexty", fillcolor="rgba(123,104,238,0.05)"))
+
+fig.update_layout(
+ template="plotly_dark", height=450,
+ margin=dict(l=0, r=0, t=10, b=0),
+ xaxis_rangeslider_visible=False,
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
+)
+st.plotly_chart(fig, use_container_width=True)
+
+# ── Indicadores en detalle ────────────────────────────────────────────────────
+st.subheader("Indicadores Técnicos")
+
+ind_cols = st.columns(4)
+indicadores = [
+ ("RSI (14)", rsi_d.get("valor"), rsi_d.get("señal"), rsi_d.get("color")),
+ ("MACD", tech.get("macd",{}).get("macd"), tech.get("macd",{}).get("señal"), tech.get("macd",{}).get("color")),
+ ("Estocástico %K", tech.get("estocastico",{}).get("k"), tech.get("estocastico",{}).get("señal"), "#ffc107"),
+ ("ATR", tech.get("atr",{}).get("pct"), tech.get("atr",{}).get("señal"), "#8892a4"),
+]
+for col, (label, valor, señal, color) in zip(ind_cols, indicadores):
+ with col:
+ st.metric(label=label, value=f"{valor:.2f}" if valor else "N/D")
+ if señal:
+ st.caption(f"{señal}", unsafe_allow_html=True)
+
+# ── Retornos históricos ───────────────────────────────────────────────────────
+st.divider()
+st.subheader("Retornos Históricos")
+
+ret_cols = st.columns(7)
+ret_labels = [("1D", "1d"), ("5D", "5d"), ("1M", "1m"), ("3M", "3m"), ("6M", "6m"), ("1Y", "1y"), ("YTD", "ytd")]
+for col, (label, key) in zip(ret_cols, ret_labels):
+ val = rets.get(key)
+ with col:
+ if val is not None:
+ delta_color = "normal" if val >= 0 else "inverse"
+ st.metric(label=label, value=f"{val:.2f}%", delta=f"{val:.2f}%", delta_color=delta_color)
+ else:
+ st.metric(label=label, value="N/D")
+
+r1, r2 = st.columns(2)
+r1.metric("Volatilidad Anualizada", f"{rets.get('vol_anual_pct', 'N/D')}%" if rets.get("vol_anual_pct") else "N/D")
+r2.metric("Máx. Drawdown (1Y)", f"{rets.get('max_drawdown_pct', 'N/D')}%" if rets.get("max_drawdown_pct") else "N/D")
+
+# ── Soporte / Resistencia ─────────────────────────────────────────────────────
+sr = tech.get("soporte_resistencia", {})
+if sr:
+ st.divider()
+ st.subheader("Soporte y Resistencia")
+ sc1, sc2, sc3 = st.columns(3)
+ sc1.metric("Soporte", f"${sr.get('soporte', 0):,.4f}",
+ delta=f"{-sr.get('distancia_soporte_pct', 0):.1f}% desde precio")
+ sc2.metric("Precio actual", f"${sr.get('precio', 0):,.4f}")
+ sc3.metric("Resistencia", f"${sr.get('resistencia', 0):,.4f}",
+ delta=f"+{sr.get('distancia_resistencia_pct', 0):.1f}% al objetivo")
+
+# ── Narrativa IA ──────────────────────────────────────────────────────────────
+st.divider()
+if st.button("🤖 Interpretar análisis técnico con IA"):
+ with st.spinner("Analizando..."):
+ texto = narrativa_tecnica(tech, ticker)
+ st.markdown(texto)
diff --git "a/app/pages/4_\360\237\214\220_Macro.py" "b/app/pages/4_\360\237\214\220_Macro.py"
new file mode 100644
index 0000000..629222f
--- /dev/null
+++ "b/app/pages/4_\360\237\214\220_Macro.py"
@@ -0,0 +1,144 @@
+"""InvestSkill — Página: Economía Macro (FRED)"""
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+import streamlit as st
+import plotly.graph_objects as go
+import plotly.express as px
+import pandas as pd
+from datetime import datetime
+
+from core.data.macro import dashboard_macro, curva_rendimientos, condiciones_mercado
+from core.ai.analyst import narrativa_macro
+
+st.set_page_config(page_title="Economía Macro — InvestSkill", page_icon="🌐", layout="wide")
+st.title("🌐 Economía Macroeconómica — EE.UU.")
+st.caption("Fuente: FRED (Federal Reserve Bank of St. Louis) — datos gratuitos y en tiempo real")
+
+
+@st.cache_data(ttl=3600, show_spinner=False)
+def cargar_macro():
+ return condiciones_mercado()
+
+
+@st.cache_data(ttl=3600, show_spinner=False)
+def cargar_curva():
+ return curva_rendimientos()
+
+
+@st.cache_data(ttl=3600, show_spinner=False)
+def cargar_dashboard():
+ return dashboard_macro()
+
+
+with st.spinner("Cargando indicadores macroeconómicos desde FRED..."):
+ macro = cargar_macro()
+ curva = cargar_curva()
+ dash = cargar_dashboard()
+
+# ── Semáforo macro ────────────────────────────────────────────────────────────
+regimen = macro.get("regimen_macro", "No disponible")
+curva_inv = macro.get("curva_invertida", False)
+color_reg = "#00c851" if "Acomodaticio" in regimen else "#ff4444" if "Restrictivo" in regimen else "#ffc107"
+
+st.markdown(
+ f""
+ f"Régimen Macro Actual: "
+ f"{regimen}"
+ f"{'
⚠️ CURVA INVERTIDA — señal histórica de recesión' if curva_inv else ''}"
+ f"
",
+ unsafe_allow_html=True,
+)
+
+# ── KPIs principales ──────────────────────────────────────────────────────────
+m1, m2, m3, m4, m5 = st.columns(5)
+m1.metric("Desempleo", f"{macro.get('desempleo_pct', 'N/D')}%")
+m2.metric("Inflación YoY", f"{macro.get('inflacion_yoy_pct', 'N/D')}%")
+m3.metric("Fed Funds Rate", f"{macro.get('fed_funds_pct', 'N/D')}%")
+m4.metric("Rendimiento T10Y", f"{macro.get('t10y_pct', 'N/D')}%")
+m5.metric("Spread 10Y-2Y",
+ f"{macro.get('spread_10y_2y', 'N/D')} bps" if macro.get("spread_10y_2y") is not None else "N/D",
+ delta=macro.get("spread_10y_2y"), delta_color="inverse")
+
+# ── Curva de rendimientos ─────────────────────────────────────────────────────
+st.divider()
+st.subheader("Curva de Rendimientos del Tesoro")
+puntos_curva = curva.get("puntos", {})
+
+if puntos_curva:
+ orden = ["3M", "6M", "1Y", "2Y", "3Y", "5Y", "7Y", "10Y", "20Y", "30Y"]
+ x_vals = [p for p in orden if p in puntos_curva]
+ y_vals = [puntos_curva[p] for p in x_vals]
+
+ color_curva = "#ff4444" if curva.get("invertida") else "#00c851"
+ fig_curva = go.Figure()
+ fig_curva.add_trace(go.Scatter(
+ x=x_vals, y=y_vals,
+ mode="lines+markers+text",
+ text=[f"{v:.2f}%" for v in y_vals],
+ textposition="top center",
+ line=dict(color=color_curva, width=3),
+ marker=dict(size=10, color=color_curva),
+ fill="tozeroy", fillcolor=f"rgba({'255,68,68' if curva.get('invertida') else '0,200,81'},0.1)",
+ name="Rendimiento",
+ ))
+ fig_curva.update_layout(
+ template="plotly_dark", height=300,
+ margin=dict(l=0, r=0, t=10, b=0),
+ yaxis_title="Rendimiento (%)",
+ xaxis_title="Vencimiento",
+ annotations=[dict(
+ text=f"Spread 10Y-2Y: {curva.get('spread_10y_2y')} bps — {curva.get('señal')}",
+ xref="paper", yref="paper", x=0, y=1.05,
+ showarrow=False, font=dict(color=color_curva, size=13),
+ )],
+ )
+ st.plotly_chart(fig_curva, use_container_width=True)
+else:
+ st.info("Curva de rendimientos no disponible (requiere pandas-datareader).")
+
+# ── Series temporales de indicadores ─────────────────────────────────────────
+st.divider()
+st.subheader("Indicadores en el Tiempo")
+
+indicadores_disp = {k: v for k, v in dash.items() if v.get("valor") is not None}
+if indicadores_disp:
+ indicador_sel = st.selectbox(
+ "Selecciona indicador",
+ options=list(indicadores_disp.keys()),
+ format_func=lambda k: indicadores_disp[k]["descripcion"],
+ )
+ ind_data = indicadores_disp.get(indicador_sel, {})
+ serie = ind_data.get("serie", {})
+
+ if serie:
+ df_serie = pd.DataFrame(
+ [(pd.to_datetime(str(k)), v) for k, v in serie.items()],
+ columns=["Fecha", "Valor"]
+ ).sort_values("Fecha")
+
+ fig_ts = px.line(df_serie, x="Fecha", y="Valor",
+ title=ind_data["descripcion"],
+ template="plotly_dark",
+ color_discrete_sequence=["#1a73e8"])
+ fig_ts.update_layout(height=300, margin=dict(l=0, r=0, t=30, b=0))
+ st.plotly_chart(fig_ts, use_container_width=True)
+
+ c_val, c_mom, c_anual = st.columns(3)
+ c_val.metric("Último valor", ind_data.get("valor"), help=f"Fecha: {ind_data.get('fecha')}")
+ c_mom.metric("Cambio mensual", ind_data.get("cambio_mom"),
+ delta=ind_data.get("cambio_mom") if ind_data.get("cambio_mom") else None)
+ c_anual.metric("Cambio anual", ind_data.get("cambio_anual"),
+ delta=ind_data.get("cambio_anual") if ind_data.get("cambio_anual") else None)
+ else:
+ st.info("Serie temporal no disponible para este indicador.")
+else:
+ st.warning("No se pudieron cargar indicadores FRED. Verifica que pandas-datareader esté instalado.")
+
+# ── Narrativa IA ──────────────────────────────────────────────────────────────
+st.divider()
+if st.button("🤖 Análisis macro con IA"):
+ with st.spinner("Analizando condiciones macro..."):
+ texto = narrativa_macro(macro)
+ st.markdown(texto)
diff --git "a/app/pages/5_\360\237\222\274_Portafolio.py" "b/app/pages/5_\360\237\222\274_Portafolio.py"
new file mode 100644
index 0000000..776999d
--- /dev/null
+++ "b/app/pages/5_\360\237\222\274_Portafolio.py"
@@ -0,0 +1,213 @@
+"""InvestSkill — Página: Portafolio personal"""
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+import streamlit as st
+import plotly.graph_objects as go
+import plotly.express as px
+import pandas as pd
+import numpy as np
+
+from core.data.fetcher import FetcherMercado, formatear_numero
+
+st.set_page_config(page_title="Mi Portafolio — InvestSkill", page_icon="💼", layout="wide")
+st.title("💼 Mi Portafolio")
+st.caption("Agrega tus posiciones para ver el rendimiento en tiempo real")
+
+# ── Gestión del portafolio ─────────────────────────────────────────────────────
+if "portafolio" not in st.session_state:
+ st.session_state["portafolio"] = [
+ {"ticker": "AAPL", "acciones": 10, "precio_compra": 150.00, "sector": "Tecnología"},
+ {"ticker": "MSFT", "acciones": 5, "precio_compra": 310.00, "sector": "Tecnología"},
+ {"ticker": "JNJ", "acciones": 8, "precio_compra": 155.00, "sector": "Salud"},
+ {"ticker": "BRK-B", "acciones": 15, "precio_compra": 310.00, "sector": "Finanzas"},
+ {"ticker": "BTC-USD", "acciones": 0.05, "precio_compra": 42000.00, "sector": "Crypto"},
+ ]
+
+# Editor de portafolio
+with st.expander("✏️ Editar portafolio", expanded=False):
+ df_port = pd.DataFrame(st.session_state["portafolio"])
+ edited = st.data_editor(
+ df_port,
+ num_rows="dynamic",
+ use_container_width=True,
+ column_config={
+ "ticker": st.column_config.TextColumn("Ticker"),
+ "acciones": st.column_config.NumberColumn("Acciones", format="%.4f"),
+ "precio_compra": st.column_config.NumberColumn("Precio compra", format="$%.2f"),
+ "sector": st.column_config.TextColumn("Sector"),
+ },
+ )
+ if st.button("💾 Guardar cambios", type="primary"):
+ st.session_state["portafolio"] = edited.to_dict("records")
+ st.cache_data.clear()
+ st.rerun()
+
+
+@st.cache_data(ttl=300, show_spinner=False)
+def obtener_precios_portafolio(tickers: tuple) -> dict:
+ precios = {}
+ for t in tickers:
+ try:
+ f = FetcherMercado(t)
+ p = f.precio_actual()
+ precios[t] = p
+ except Exception:
+ precios[t] = {}
+ return precios
+
+
+portfolio = st.session_state["portafolio"]
+tickers_port = tuple([p["ticker"] for p in portfolio])
+
+with st.spinner("Actualizando precios en tiempo real..."):
+ precios = obtener_precios_portafolio(tickers_port)
+
+# ── Calcular P&L ──────────────────────────────────────────────────────────────
+rows = []
+for pos in portfolio:
+ tick = pos["ticker"]
+ nshares = pos["acciones"]
+ pc = pos["precio_compra"]
+ sector = pos.get("sector", "N/D")
+ pd_act = precios.get(tick, {})
+ precio_act = pd_act.get("precio") or pc
+
+ valor_actual = precio_act * nshares
+ costo_base = pc * nshares
+ ganancia_abs = valor_actual - costo_base
+ ganancia_pct = (ganancia_abs / costo_base * 100) if costo_base else 0
+ cambio_dia = pd_act.get("cambio_pct", 0)
+
+ rows.append({
+ "Ticker": tick,
+ "Sector": sector,
+ "Acciones": nshares,
+ "P. Compra": pc,
+ "P. Actual": round(precio_act, 4),
+ "Valor ($)": round(valor_actual, 2),
+ "Costo Base ($)": round(costo_base, 2),
+ "G/P ($)": round(ganancia_abs, 2),
+ "G/P (%)": round(ganancia_pct, 2),
+ "Cambio Hoy (%)": round(cambio_dia, 2),
+ })
+
+df_result = pd.DataFrame(rows)
+valor_total = df_result["Valor ($)"].sum()
+costo_total = df_result["Costo Base ($)"].sum()
+ganancia_total = valor_total - costo_total
+ret_total_pct = (ganancia_total / costo_total * 100) if costo_total else 0
+
+# ── KPIs del portafolio ───────────────────────────────────────────────────────
+k1, k2, k3, k4 = st.columns(4)
+k1.metric("Valor Total", formatear_numero(valor_total, prefijo="$"))
+k2.metric("Costo Base", formatear_numero(costo_total, prefijo="$"))
+k3.metric("Ganancia/Pérdida",
+ formatear_numero(ganancia_total, prefijo="$"),
+ delta=f"{ret_total_pct:.2f}%",
+ delta_color="normal" if ganancia_total >= 0 else "inverse")
+k4.metric("Rendimiento Total", f"{ret_total_pct:.2f}%")
+
+# ── Tabla de posiciones ───────────────────────────────────────────────────────
+st.subheader("Posiciones")
+
+def highlight_ganancia(val):
+ try:
+ v = float(val)
+ if v > 0: return "color: #00c851; font-weight: 700"
+ elif v < 0: return "color: #ff4444; font-weight: 700"
+ except Exception:
+ pass
+ return ""
+
+styled_df = df_result.style \
+ .applymap(highlight_ganancia, subset=["G/P ($)", "G/P (%)", "Cambio Hoy (%)"]) \
+ .format({
+ "P. Compra": "${:.4f}", "P. Actual": "${:.4f}",
+ "Valor ($)": "${:,.2f}", "Costo Base ($)": "${:,.2f}",
+ "G/P ($)": "${:,.2f}", "G/P (%)": "{:.2f}%",
+ "Cambio Hoy (%)": "{:.2f}%", "Acciones": "{:.4f}",
+ })
+
+st.dataframe(styled_df, hide_index=True, use_container_width=True)
+
+# ── Gráficos ──────────────────────────────────────────────────────────────────
+st.divider()
+gc1, gc2 = st.columns(2)
+
+with gc1:
+ st.subheader("Distribución por activo")
+ fig_pie = px.pie(df_result, values="Valor ($)", names="Ticker",
+ template="plotly_dark", hole=0.4,
+ color_discrete_sequence=px.colors.qualitative.Set3)
+ fig_pie.update_traces(textposition="inside", textinfo="percent+label")
+ fig_pie.update_layout(height=320, margin=dict(l=0, r=0, t=10, b=0), showlegend=True)
+ st.plotly_chart(fig_pie, use_container_width=True)
+
+with gc2:
+ st.subheader("Distribución por sector")
+ df_sector = df_result.groupby("Sector")["Valor ($)"].sum().reset_index()
+ fig_sec = px.bar(df_sector, x="Sector", y="Valor ($)",
+ template="plotly_dark", color="Sector",
+ color_discrete_sequence=px.colors.qualitative.Pastel)
+ fig_sec.update_layout(height=320, margin=dict(l=0, r=0, t=10, b=0), showlegend=False)
+ st.plotly_chart(fig_sec, use_container_width=True)
+
+# ── Waterfall de G/P por posición ────────────────────────────────────────────
+st.subheader("Ganancia/Pérdida por posición")
+colores_wf = ["#00c851" if v >= 0 else "#ff4444" for v in df_result["G/P ($)"]]
+fig_wf = go.Figure(go.Bar(
+ x=df_result["Ticker"], y=df_result["G/P ($)"],
+ marker_color=colores_wf, text=[f"${v:,.0f}" for v in df_result["G/P ($)"]],
+ textposition="outside",
+))
+fig_wf.update_layout(template="plotly_dark", height=300,
+ margin=dict(l=0, r=0, t=10, b=0),
+ yaxis_title="USD")
+st.plotly_chart(fig_wf, use_container_width=True)
+
+# ── Rendimiento histórico del portafolio ──────────────────────────────────────
+st.divider()
+st.subheader("Rendimiento histórico del portafolio (últimos 12 meses)")
+
+@st.cache_data(ttl=300, show_spinner=False)
+def hist_portafolio(tickers: tuple, pesos: tuple):
+ dfs = {}
+ for t in tickers:
+ try:
+ f = FetcherMercado(t)
+ h = f.precio_historico("1y")
+ if not h.empty:
+ dfs[t] = h["Close"]
+ except Exception:
+ pass
+ if not dfs:
+ return pd.DataFrame()
+ df_precios = pd.DataFrame(dfs).dropna()
+ ret = df_precios.pct_change().dropna()
+ # Retorno ponderado
+ pesos_arr = np.array(pesos)
+ pesos_arr = pesos_arr / pesos_arr.sum()
+ ret_port = (ret * pesos_arr).sum(axis=1)
+ return (1 + ret_port).cumprod() * 100 - 100
+
+
+tickers_val = [p["ticker"] for p in portfolio]
+pesos_val = [p["acciones"] * p["precio_compra"] for p in portfolio]
+hist_p = hist_portafolio(tuple(tickers_val), tuple(pesos_val))
+
+if not hist_p.empty:
+ fig_hist = go.Figure()
+ color_line = "#00c851" if float(hist_p.iloc[-1]) >= 0 else "#ff4444"
+ fig_hist.add_trace(go.Scatter(
+ x=hist_p.index, y=hist_p.values,
+ fill="tozeroy", fillcolor=f"rgba({'0,200,81' if color_line=='#00c851' else '255,68,68'},0.1)",
+ line=dict(color=color_line, width=2), name="Retorno Portafolio (%)",
+ ))
+ fig_hist.add_hline(y=0, line_dash="dot", line_color="#8892a4")
+ fig_hist.update_layout(template="plotly_dark", height=300,
+ margin=dict(l=0, r=0, t=10, b=0),
+ yaxis_title="Retorno acumulado (%)")
+ st.plotly_chart(fig_hist, use_container_width=True)
+else:
+ st.info("No hay suficientes datos históricos para mostrar el rendimiento del portafolio.")
diff --git "a/app/pages/6_\360\237\223\213_Reporte.py" "b/app/pages/6_\360\237\223\213_Reporte.py"
new file mode 100644
index 0000000..68c6317
--- /dev/null
+++ "b/app/pages/6_\360\237\223\213_Reporte.py"
@@ -0,0 +1,244 @@
+"""InvestSkill — Página: Reporte completo descargable"""
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+import streamlit as st
+import pandas as pd
+from datetime import datetime
+import json
+
+from core.data.fetcher import FetcherMercado, formatear_numero
+from core.analysis.fundamental import calcular_piotroski, calcular_roic_wacc, calidad_ganancias
+from core.analysis.valuation import calcular_dcf, valoracion_multiples
+from core.analysis.technical import analizar as analizar_tecnico, retornos_historicos
+from core.ai.analyst import (narrativa_evaluacion_accion, narrativa_dcf,
+ narrativa_tecnica, señal_inversion_final)
+from config import tiene_api_claude
+
+st.set_page_config(page_title="Reporte Completo — InvestSkill", page_icon="📋", layout="wide")
+st.title("📋 Reporte de Análisis Completo")
+st.caption("Genera un informe profesional descargable con todos los datos en tiempo real")
+
+# ── Input ─────────────────────────────────────────────────────────────────────
+col_t, col_g = st.columns([4, 1])
+with col_t:
+ ticker = st.text_input("Ticker del activo a reportar",
+ value=st.session_state.get("ticker_actual", "AAPL"),
+ placeholder="Ej: AAPL, MSFT, BTC-USD, FEMSA.MX").strip().upper()
+with col_g:
+ st.write("")
+ generar = st.button("📊 Generar Reporte", type="primary", use_container_width=True)
+
+if not generar:
+ st.info("Ingresa un ticker y haz clic en **Generar Reporte** para crear el análisis completo.")
+ st.stop()
+
+# ── Carga de todos los datos ──────────────────────────────────────────────────
+progress = st.progress(0, text="Iniciando análisis...")
+
+with st.spinner(""):
+ fetcher = FetcherMercado(ticker)
+ progress.progress(10, "Obteniendo precio en tiempo real...")
+ datos = fetcher.resumen_completo()
+
+ progress.progress(25, "Descargando estados financieros...")
+ hist_1y = fetcher.precio_historico("1y")
+
+ progress.progress(40, "Calculando Piotroski F-Score...")
+ piot_raw = datos.get("piotroski_raw", {})
+ fund = datos.get("fundamentales", {})
+ piotroski = calcular_piotroski(piot_raw)
+
+ progress.progress(50, "Calculando ROIC / WACC / EVA...")
+ roic_wacc = calcular_roic_wacc(fund, piot_raw)
+
+ progress.progress(60, "Ejecutando modelo DCF...")
+ dcf_res = calcular_dcf(fund, piot_raw)
+
+ progress.progress(70, "Analizando múltiplos de valoración...")
+ mult_res = valoracion_multiples(fund)
+
+ progress.progress(80, "Calculando indicadores técnicos...")
+ tech_res = analizar_tecnico(hist_1y)
+ rets = retornos_historicos(hist_1y)
+
+ progress.progress(90, "Calculando señal final de inversión...")
+ señal = señal_inversion_final(piotroski, roic_wacc, dcf_res, tech_res, datos.get("consenso", {}))
+
+ narrativa_ia = ""
+ dcf_narrativa = ""
+ tech_narrativa = ""
+ if tiene_api_claude():
+ progress.progress(95, "Generando análisis narrativo con IA...")
+ narrativa_ia = narrativa_evaluacion_accion(datos)
+ dcf_narrativa = narrativa_dcf(dcf_res, ticker) if not dcf_res.get("error") else ""
+ tech_narrativa = narrativa_tecnica(tech_res, ticker) if not tech_res.get("error") else ""
+
+ progress.progress(100, "¡Reporte listo!")
+
+progress.empty()
+
+# ── Encabezado del reporte ────────────────────────────────────────────────────
+precio_d = datos["precio"]
+nombre = precio_d.get("nombre", ticker)
+precio_act = precio_d.get("precio", 0)
+cambio_pct = precio_d.get("cambio_pct", 0)
+moneda = precio_d.get("moneda", "USD")
+
+st.divider()
+col_h, col_s = st.columns([3, 1])
+with col_h:
+ st.markdown(f"# {nombre}")
+ st.markdown(f"**{ticker}** • {precio_d.get('sector','')} • {precio_d.get('intercambio','')} • {datetime.now().strftime('%d/%m/%Y %H:%M')}")
+ color = "#00c851" if cambio_pct >= 0 else "#ff4444"
+ st.markdown(f"{moneda} {precio_act:,.4f} {'▲' if cambio_pct>=0 else '▼'} {abs(cambio_pct):.2f}%
", unsafe_allow_html=True)
+
+with col_s:
+ sc = señal["score"]
+ cl = señal["color"]
+ st.markdown(
+ f""
+ f"
{señal['accion']}
"
+ f"
{sc}/10
"
+ f"
{señal['señal']}
Confianza: {señal['confianza']}
"
+ f"
", unsafe_allow_html=True,
+ )
+
+# ── Sección 1: Tesis de inversión (IA) ───────────────────────────────────────
+st.divider()
+st.subheader("1. Tesis de Inversión")
+if narrativa_ia:
+ st.markdown(narrativa_ia)
+else:
+ st.markdown(f"**{nombre}** opera en el sector {precio_d.get('sector','N/D')}.")
+ desc = datos.get("descripcion", "")
+ if desc:
+ st.write(desc[:800] + "..." if len(desc) > 800 else desc)
+ if not tiene_api_claude():
+ st.info("Para narrativa IA: configura ANTHROPIC_API_KEY en .env")
+
+# ── Sección 2: Métricas clave ─────────────────────────────────────────────────
+st.divider()
+st.subheader("2. Métricas de Valoración y Rentabilidad")
+
+mc1, mc2, mc3 = st.columns(3)
+with mc1:
+ st.markdown("**Valoración**")
+ st.dataframe(pd.DataFrame({
+ "Métrica": ["P/E TTM", "P/E Fwd", "PEG", "P/Ventas", "P/Libro", "EV/EBITDA", "FCF Yield"],
+ "Valor": [
+ f"{fund.get('pe_ttm','N/D')}x", f"{fund.get('pe_forward','N/D')}x",
+ f"{fund.get('peg','N/D')}x", f"{fund.get('precio_ventas','N/D')}x",
+ f"{fund.get('precio_valor_libro','N/D')}x", f"{fund.get('ev_ebitda','N/D')}x",
+ f"{fund.get('fcf_yield','N/D')}%",
+ ]
+ }), hide_index=True, use_container_width=True)
+
+with mc2:
+ st.markdown("**Rentabilidad**")
+ st.dataframe(pd.DataFrame({
+ "Métrica": ["M. Bruto", "M. Operativo", "M. Neto", "EBITDA M.", "ROE", "ROA"],
+ "Valor": [
+ f"{fund.get('margen_bruto','N/D')}%", f"{fund.get('margen_operativo','N/D')}%",
+ f"{fund.get('margen_neto','N/D')}%", f"{fund.get('margen_ebitda','N/D')}%",
+ f"{fund.get('roe','N/D')}%", f"{fund.get('roa','N/D')}%",
+ ]
+ }), hide_index=True, use_container_width=True)
+
+with mc3:
+ st.markdown("**Calidad y Riesgo**")
+ st.dataframe(pd.DataFrame({
+ "Métrica": ["Piotroski", "ROIC", "WACC", "Beta", "D/E", "Ratio Corriente"],
+ "Valor": [
+ f"{piotroski.get('score','N/D')}/9",
+ f"{roic_wacc.get('roic_pct','N/D')}%",
+ f"{roic_wacc.get('wacc_pct','N/D')}%",
+ f"{fund.get('beta','N/D')}",
+ f"{fund.get('deuda_equity','N/D')}x",
+ f"{fund.get('ratio_corriente','N/D')}x",
+ ]
+ }), hide_index=True, use_container_width=True)
+
+# ── Sección 3: DCF ────────────────────────────────────────────────────────────
+st.divider()
+st.subheader("3. Valoración DCF")
+if dcf_res.get("error"):
+ st.warning(f"DCF no disponible: {dcf_res['error']}")
+else:
+ esc = dcf_res["escenarios"]
+ df_dcf = pd.DataFrame({
+ "Escenario": ["🐻 Bajista", "📊 Base", "🐂 Alcista"],
+ "Valor intrínseco": [f"${e['valor_intrinseco']:,.2f}" for e in [esc["bajista"],esc["base"],esc["alcista"]]],
+ "Margen de seguridad": [f"{e.get('margen_seguridad_pct','N/D')}%" for e in [esc["bajista"],esc["base"],esc["alcista"]]],
+ "WACC": [f"{e['wacc_pct']}%" for e in [esc["bajista"],esc["base"],esc["alcista"]]],
+ "Crec. F1/F2": [f"{e['crec_fase1_pct']}%/{e['crec_fase2_pct']}%" for e in [esc["bajista"],esc["base"],esc["alcista"]]],
+ "Probabilidad": ["20%", "55%", "25%"],
+ })
+ st.dataframe(df_dcf, hide_index=True, use_container_width=True)
+ st.markdown(f"**Precio ponderado:** ${dcf_res.get('precio_ponderado',0):,.2f} | **Señal:** {dcf_res.get('señal','')}")
+ if dcf_narrativa:
+ st.markdown(dcf_narrativa)
+
+# ── Sección 4: Técnico ────────────────────────────────────────────────────────
+st.divider()
+st.subheader("4. Análisis Técnico")
+if not tech_res.get("error"):
+ tc1, tc2, tc3, tc4 = st.columns(4)
+ tc1.metric("Tendencia", tech_res.get("tendencia",{}).get("tendencia","N/D"))
+ tc2.metric("RSI (14)", f"{tech_res.get('rsi',{}).get('valor','N/D')}")
+ tc3.metric("Score Técnico", f"{tech_res.get('score',{}).get('score','N/D')}/10")
+ tc4.metric("MACD", tech_res.get("macd",{}).get("señal","N/D"))
+ if tech_narrativa:
+ st.markdown(tech_narrativa)
+
+# ── Sección 5: Retornos ───────────────────────────────────────────────────────
+st.divider()
+st.subheader("5. Retornos Históricos")
+df_rets = pd.DataFrame({
+ "Período": ["1 Día","5 Días","1 Mes","3 Meses","6 Meses","1 Año","YTD"],
+ "Retorno": [
+ f"{rets.get('1d','N/D')}%" if rets.get('1d') else "N/D",
+ f"{rets.get('5d','N/D')}%" if rets.get('5d') else "N/D",
+ f"{rets.get('1m','N/D')}%" if rets.get('1m') else "N/D",
+ f"{rets.get('3m','N/D')}%" if rets.get('3m') else "N/D",
+ f"{rets.get('6m','N/D')}%" if rets.get('6m') else "N/D",
+ f"{rets.get('1y','N/D')}%" if rets.get('1y') else "N/D",
+ f"{rets.get('ytd','N/D')}%" if rets.get('ytd') else "N/D",
+ ],
+ "Volatilidad Anual": [f"{rets.get('vol_anual_pct','N/D')}%"]+[""]*6,
+ "Máx. Drawdown 1Y": [f"{rets.get('max_drawdown_pct','N/D')}%"]+[""]*6,
+})
+st.dataframe(df_rets, hide_index=True, use_container_width=True)
+
+# ── Sección 6: Noticias ───────────────────────────────────────────────────────
+st.divider()
+st.subheader("6. Noticias Recientes")
+for n in datos.get("noticias", [])[:5]:
+ st.markdown(f"- **[{n['titulo']}]({n['url']})** — *{n['fuente']}* {n['fecha']}")
+
+# ── Señal final ───────────────────────────────────────────────────────────────
+st.divider()
+st.subheader("7. Señal de Inversión Final")
+st.code(señal["bloque"], language=None)
+
+# ── Descargar JSON ────────────────────────────────────────────────────────────
+st.divider()
+reporte_json = {
+ "ticker": ticker, "nombre": nombre, "fecha": datetime.now().isoformat(),
+ "precio": datos["precio"], "fundamentales": fund,
+ "piotroski": {"score": piotroski.get("score"), "interpretacion": piotroski.get("interpretacion")},
+ "roic_wacc": {"roic": roic_wacc.get("roic_pct"), "wacc": roic_wacc.get("wacc_pct"), "eva_mm": roic_wacc.get("eva_mm")},
+ "dcf": {"precio_ponderado": dcf_res.get("precio_ponderado"), "señal": dcf_res.get("señal")} if not dcf_res.get("error") else {"error": dcf_res["error"]},
+ "tecnico": {"score": tech_res.get("score",{}).get("score"), "tendencia": tech_res.get("tendencia",{}).get("tendencia")},
+ "señal_final": {"score": señal["score"], "accion": señal["accion"], "señal": señal["señal"]},
+ "consenso": datos.get("consenso", {}),
+}
+st.download_button(
+ "⬇️ Descargar reporte JSON",
+ data=json.dumps(reporte_json, ensure_ascii=False, indent=2),
+ file_name=f"investskill_{ticker}_{datetime.now().strftime('%Y%m%d_%H%M')}.json",
+ mime="application/json",
+ type="primary",
+)
+
+st.caption("⚠️ Análisis educativo. No es asesoramiento financiero. Verifica los datos antes de invertir.")
diff --git a/bots/telegram_bot.py b/bots/telegram_bot.py
new file mode 100644
index 0000000..b647f28
--- /dev/null
+++ b/bots/telegram_bot.py
@@ -0,0 +1,404 @@
+"""
+InvestSkill — Bot de Telegram
+Comandos disponibles:
+ /start — Bienvenida
+ /analizar TICKER — Análisis completo de un activo
+ /precio TICKER — Precio en tiempo real
+ /fundamental TICKER — Métricas fundamentales
+ /dcf TICKER — Valoración DCF rápida
+ /tecnico TICKER — Score técnico
+ /macro — Resumen macroeconómico
+ /portafolio — Ver portafolio guardado
+ /ayuda — Lista de comandos
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import logging
+from datetime import datetime
+
+from telegram import Update
+from telegram.ext import (
+ Application, CommandHandler, MessageHandler,
+ filters, ContextTypes,
+)
+from telegram.constants import ParseMode
+
+from core.data.fetcher import FetcherMercado, formatear_numero
+from core.data.macro import condiciones_mercado
+from core.analysis.fundamental import calcular_piotroski, calcular_roic_wacc
+from core.analysis.valuation import calcular_dcf, valoracion_multiples
+from core.analysis.technical import analizar as analizar_tecnico, retornos_historicos
+from core.ai.analyst import narrativa_evaluacion_accion, señal_inversion_final
+from config import TELEGRAM_BOT_TOKEN, tiene_token_telegram
+
+logging.basicConfig(
+ format="%(asctime)s — %(name)s — %(levelname)s — %(message)s",
+ level=logging.INFO,
+)
+logger = logging.getLogger(__name__)
+
+
+# ── Helpers de formato ────────────────────────────────────────────────────────
+
+def emoji_cambio(v: float) -> str:
+ return "🟢" if v > 0 else "🔴" if v < 0 else "⚪"
+
+def fmt_pct(v) -> str:
+ return f"{v:.2f}%" if v is not None else "N/D"
+
+def fmt_num(v, prefijo="$") -> str:
+ return formatear_numero(v, prefijo=prefijo) if v else "N/D"
+
+
+def bloque_señal(señal: dict) -> str:
+ s = señal["score"]
+ ac = señal["accion"]
+ sn = señal["señal"]
+ cn = señal["confianza"]
+ em = "🟢" if ac == "COMPRAR" else "🔴" if ac == "VENDER" else "🟡"
+ return (
+ f"\n╔══════════════════════════╗\n"
+ f"║ SEÑAL DE INVERSIÓN ║\n"
+ f"╠══════════════════════════╣\n"
+ f"║ {em} Acción: {ac:<16}║\n"
+ f"║ 📊 Score: {s:.1f}/10{' ':10}║\n"
+ f"║ 🎯 Señal: {sn[:16]:<16}║\n"
+ f"║ 💪 Confianza: {cn:<13}║\n"
+ f"╚══════════════════════════╝"
+ )
+
+
+# ── Handlers de comandos ──────────────────────────────────────────────────────
+
+async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ await update.message.reply_text(
+ "👋 *¡Bienvenido a InvestSkill!*\n\n"
+ "Soy tu asistente de análisis de inversiones en tiempo real.\n\n"
+ "📊 *Comandos disponibles:*\n"
+ "• `/precio AAPL` — Precio en tiempo real\n"
+ "• `/analizar AAPL` — Análisis completo\n"
+ "• `/fundamental AAPL` — Métricas fundamentales\n"
+ "• `/dcf AAPL` — Valoración DCF\n"
+ "• `/tecnico AAPL` — Score técnico\n"
+ "• `/macro` — Economía EE.UU.\n"
+ "• `/ayuda` — Ver todos los comandos\n\n"
+ "_También puedes escribir directamente el ticker y te respondo._",
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+
+async def cmd_ayuda(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ await update.message.reply_text(
+ "📚 *Comandos de InvestSkill*\n\n"
+ "`/precio TICKER` — Precio en tiempo real con cambio %\n"
+ "`/fundamental TICKER` — Valoración, márgenes, balance\n"
+ "`/dcf TICKER` — Modelo DCF en 3 escenarios\n"
+ "`/tecnico TICKER` — RSI, MACD, tendencia, score\n"
+ "`/analizar TICKER` — Análisis completo + señal IA\n"
+ "`/macro` — PIB, inflación, tasas, curva de rendimientos\n"
+ "`/portafolio` — Ver portafolio guardado\n\n"
+ "💡 *Ejemplos:*\n"
+ "• `/analizar AAPL` — Apple\n"
+ "• `/analizar BTC-USD` — Bitcoin\n"
+ "• `/analizar FEMSA.MX` — FEMSA (México)\n"
+ "• `/analizar VALE3.SA` — Vale (Brasil)\n\n"
+ "⚠️ _Uso educativo. No es asesoramiento financiero._",
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+
+async def cmd_precio(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ if not context.args:
+ await update.message.reply_text("Uso: `/precio TICKER`\nEjemplo: `/precio AAPL`",
+ parse_mode=ParseMode.MARKDOWN)
+ return
+
+ ticker = context.args[0].upper()
+ msg = await update.message.reply_text(f"⏳ Obteniendo precio de *{ticker}*...", parse_mode=ParseMode.MARKDOWN)
+
+ try:
+ f = FetcherMercado(ticker)
+ p = f.precio_actual()
+ em = emoji_cambio(p.get("cambio_pct", 0))
+ texto = (
+ f"*{p.get('nombre', ticker)}* (`{ticker}`)\n\n"
+ f"💰 *Precio:* {p.get('moneda','USD')} `{p.get('precio', 'N/D'):,.4f}`\n"
+ f"{em} *Cambio:* `{p.get('cambio_pct', 0):+.2f}%` ({p.get('cambio', 0):+.4f})\n\n"
+ f"📊 *Rango del día:*\n"
+ f" Min: `{p.get('minimo_dia', 'N/D')}` | Max: `{p.get('maximo_dia', 'N/D')}`\n\n"
+ f"📈 *Rango 52 semanas:*\n"
+ f" Min: `{p.get('min_52s', 'N/D')}` | Max: `{p.get('max_52s', 'N/D')}`\n"
+ f" Posición: `{p.get('rango_52s_pct', 'N/D')}%` del rango\n\n"
+ f"🏢 Sector: {p.get('sector', 'N/D')}\n"
+ f"💹 Cap. Mercado: {fmt_num(p.get('cap_mercado'))}\n"
+ f"🕐 _{p.get('timestamp', '')} (Yahoo Finance, ~15 min retraso)_"
+ )
+ await msg.edit_text(texto, parse_mode=ParseMode.MARKDOWN)
+ except Exception as e:
+ await msg.edit_text(f"❌ Error al obtener precio de `{ticker}`: {e}", parse_mode=ParseMode.MARKDOWN)
+
+
+async def cmd_fundamental(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ if not context.args:
+ await update.message.reply_text("Uso: `/fundamental TICKER`", parse_mode=ParseMode.MARKDOWN)
+ return
+
+ ticker = context.args[0].upper()
+ msg = await update.message.reply_text(f"⏳ Cargando fundamentales de *{ticker}*...", parse_mode=ParseMode.MARKDOWN)
+
+ try:
+ f = FetcherMercado(ticker)
+ fund = f.fundamentales()
+ piot_raw = f.datos_piotroski()
+ piot = calcular_piotroski(piot_raw)
+ rw = calcular_roic_wacc(fund, piot_raw)
+
+ texto = (
+ f"📊 *Análisis Fundamental — {ticker}*\n\n"
+ f"*📐 Valoración*\n"
+ f" P/E TTM: `{fund.get('pe_ttm','N/D')}x` | P/E Fwd: `{fund.get('pe_forward','N/D')}x`\n"
+ f" PEG: `{fund.get('peg','N/D')}x` | EV/EBITDA: `{fund.get('ev_ebitda','N/D')}x`\n"
+ f" P/Ventas: `{fund.get('precio_ventas','N/D')}x` | P/Libro: `{fund.get('precio_valor_libro','N/D')}x`\n"
+ f" FCF Yield: `{fmt_pct(fund.get('fcf_yield'))}`\n\n"
+ f"*💰 Rentabilidad*\n"
+ f" Margen Bruto: `{fmt_pct(fund.get('margen_bruto'))}`\n"
+ f" Margen Operativo: `{fmt_pct(fund.get('margen_operativo'))}`\n"
+ f" Margen Neto: `{fmt_pct(fund.get('margen_neto'))}`\n"
+ f" ROE: `{fmt_pct(fund.get('roe'))}` | ROA: `{fmt_pct(fund.get('roa'))}`\n\n"
+ f"*🏦 Balance*\n"
+ f" Caja: `{fmt_num(fund.get('caja_total'))}`\n"
+ f" Deuda Total: `{fmt_num(fund.get('deuda_total'))}`\n"
+ f" Deuda Neta: `{fmt_num(fund.get('deuda_neta'))}`\n"
+ f" D/E: `{fund.get('deuda_equity','N/D')}x` | Corriente: `{fund.get('ratio_corriente','N/D')}x`\n\n"
+ f"*🎯 Piotroski F-Score:* `{piot.get('score','N/D')}/9`\n"
+ f" _{piot.get('interpretacion','')}_\n\n"
+ f"*⚡ ROIC/WACC*\n"
+ f" ROIC: `{rw.get('roic_pct','N/D')}%` | WACC: `{rw.get('wacc_pct','N/D')}%`\n"
+ f" Spread: `{rw.get('spread_roic_wacc','N/D')}pp` | EVA: `{fmt_num(rw.get('eva_mm'))}M`\n"
+ f" _{rw.get('señal','')}_"
+ )
+ await msg.edit_text(texto, parse_mode=ParseMode.MARKDOWN)
+ except Exception as e:
+ await msg.edit_text(f"❌ Error: {e}", parse_mode=ParseMode.MARKDOWN)
+
+
+async def cmd_dcf(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ if not context.args:
+ await update.message.reply_text("Uso: `/dcf TICKER`", parse_mode=ParseMode.MARKDOWN)
+ return
+
+ ticker = context.args[0].upper()
+ msg = await update.message.reply_text(f"⏳ Calculando DCF de *{ticker}*...", parse_mode=ParseMode.MARKDOWN)
+
+ try:
+ f = FetcherMercado(ticker)
+ fund = f.fundamentales()
+ piot_raw = f.datos_piotroski()
+ dcf = calcular_dcf(fund, piot_raw)
+
+ if dcf.get("error"):
+ await msg.edit_text(f"⚠️ DCF no disponible para `{ticker}`:\n_{dcf['error']}_",
+ parse_mode=ParseMode.MARKDOWN)
+ return
+
+ esc = dcf["escenarios"]
+ b = esc["base"]; al = esc["alcista"]; ba = esc["bajista"]
+ señal_e = dcf["señal"]
+ em_s = "🟢" if "INFRA" in señal_e else "🔴" if "SOBRE" in señal_e else "🟡"
+
+ texto = (
+ f"💰 *DCF — {ticker}*\n\n"
+ f"*FCF base:* `${dcf.get('fcf_base_mm','N/D')}M`\n"
+ f"*WACC:* `{dcf.get('wacc_base_pct','N/D')}%`\n"
+ f"*Precio mercado:* `${dcf.get('precio_mercado',0):,.2f}`\n\n"
+ f"📊 *Escenarios:*\n"
+ f" 🐻 Bajista: `${ba['valor_intrinseco']:,.2f}` (MoS: `{ba.get('margen_seguridad_pct','N/D')}%`)\n"
+ f" 📊 Base: `${b['valor_intrinseco']:,.2f}` (MoS: `{b.get('margen_seguridad_pct','N/D')}%`)\n"
+ f" 🐂 Alcista: `${al['valor_intrinseco']:,.2f}` (MoS: `{al.get('margen_seguridad_pct','N/D')}%`)\n\n"
+ f"*Precio ponderado:* `${dcf.get('precio_ponderado',0):,.2f}`\n\n"
+ f"{em_s} _{señal_e}_"
+ )
+ await msg.edit_text(texto, parse_mode=ParseMode.MARKDOWN)
+ except Exception as e:
+ await msg.edit_text(f"❌ Error: {e}", parse_mode=ParseMode.MARKDOWN)
+
+
+async def cmd_tecnico(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ if not context.args:
+ await update.message.reply_text("Uso: `/tecnico TICKER`", parse_mode=ParseMode.MARKDOWN)
+ return
+
+ ticker = context.args[0].upper()
+ msg = await update.message.reply_text(f"⏳ Analizando gráficos de *{ticker}*...", parse_mode=ParseMode.MARKDOWN)
+
+ try:
+ f = FetcherMercado(ticker)
+ hist = f.precio_historico("1y")
+ tech = analizar_tecnico(hist)
+ rets = retornos_historicos(hist)
+
+ if tech.get("error"):
+ await msg.edit_text(f"⚠️ {tech['error']}", parse_mode=ParseMode.MARKDOWN)
+ return
+
+ sc = tech.get("score",{})
+ tend = tech.get("tendencia",{})
+ rsi = tech.get("rsi",{})
+ macd = tech.get("macd",{})
+ mm = tech.get("medias_moviles",{})
+ bb = tech.get("bollinger",{})
+ sr = tech.get("soporte_resistencia",{})
+
+ texto = (
+ f"📈 *Análisis Técnico — {ticker}*\n\n"
+ f"*Score técnico:* `{sc.get('score','N/D')}/10` — _{sc.get('señal','')}_\n"
+ f"*Tendencia:* _{tend.get('tendencia','N/D')}_\n\n"
+ f"*📐 Medias Móviles:*\n"
+ f" Precio: `${mm.get('precio','N/D'):,.4f}`\n"
+ f" SMA50: `{mm.get('sma50','N/D')}` | SMA200: `{mm.get('sma200','N/D')}`\n"
+ f" Golden Cross: `{'✅ Sí' if mm.get('golden_cross') else '❌ No'}`\n\n"
+ f"*📊 Indicadores:*\n"
+ f" RSI(14): `{rsi.get('valor','N/D')}` — _{rsi.get('señal','')}_\n"
+ f" MACD: `{macd.get('macd','N/D')}` — _{macd.get('señal','')}_\n"
+ f" Bollinger: _{bb.get('señal','N/D')}_\n\n"
+ f"*📉 Soporte/Resistencia:*\n"
+ f" Soporte: `${sr.get('soporte','N/D')}` ({sr.get('distancia_soporte_pct','N/D')}% abajo)\n"
+ f" Resistencia: `${sr.get('resistencia','N/D')}` (+{sr.get('distancia_resistencia_pct','N/D')}%)\n\n"
+ f"*📅 Retornos:*\n"
+ f" 1D: `{fmt_pct(rets.get('1d'))}` | 1M: `{fmt_pct(rets.get('1m'))}` | "
+ f"1Y: `{fmt_pct(rets.get('1y'))}` | YTD: `{fmt_pct(rets.get('ytd'))}`\n"
+ f" Volatilidad anual: `{fmt_pct(rets.get('vol_anual_pct'))}`"
+ )
+ await msg.edit_text(texto, parse_mode=ParseMode.MARKDOWN)
+ except Exception as e:
+ await msg.edit_text(f"❌ Error: {e}", parse_mode=ParseMode.MARKDOWN)
+
+
+async def cmd_analizar(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ if not context.args:
+ await update.message.reply_text("Uso: `/analizar TICKER`\nEjemplo: `/analizar AAPL`",
+ parse_mode=ParseMode.MARKDOWN)
+ return
+
+ ticker = context.args[0].upper()
+ msg = await update.message.reply_text(
+ f"⏳ Analizando *{ticker}* — esto puede tomar unos segundos...",
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+ try:
+ f = FetcherMercado(ticker)
+ datos = f.resumen_completo()
+ hist_1y = f.precio_historico("1y")
+ fund = datos["fundamentales"]
+ piot_raw = datos["piotroski_raw"]
+ consenso = datos["consenso"]
+ precio_d = datos["precio"]
+
+ piot = calcular_piotroski(piot_raw)
+ rw = calcular_roic_wacc(fund, piot_raw)
+ dcf = calcular_dcf(fund, piot_raw)
+ tech = analizar_tecnico(hist_1y)
+ señal = señal_inversion_final(piot, rw, dcf, tech, consenso)
+
+ nombre = precio_d.get("nombre", ticker)
+ precio = precio_d.get("precio", 0)
+ cambio = precio_d.get("cambio_pct", 0)
+ em_p = emoji_cambio(cambio)
+
+ vi_base = dcf["escenarios"]["base"]["valor_intrinseco"] if not dcf.get("error") else None
+ mg_base = dcf["escenarios"]["base"].get("margen_seguridad_pct") if not dcf.get("error") else None
+
+ texto = (
+ f"📊 *Análisis Completo — {nombre}* (`{ticker}`)\n\n"
+ f"💰 *Precio:* `{precio_d.get('moneda','USD')} {precio:,.4f}` {em_p} `{cambio:+.2f}%`\n"
+ f"🏢 Cap. Mercado: `{fmt_num(fund.get('cap_mercado'))}`\n\n"
+ f"*📐 Valoración:*\n"
+ f" P/E TTM: `{fund.get('pe_ttm','N/D')}x` | EV/EBITDA: `{fund.get('ev_ebitda','N/D')}x`\n"
+ f" FCF Yield: `{fmt_pct(fund.get('fcf_yield'))}`\n\n"
+ f"*💡 Rentabilidad:*\n"
+ f" Margen Neto: `{fmt_pct(fund.get('margen_neto'))}` | ROE: `{fmt_pct(fund.get('roe'))}`\n\n"
+ f"*🎯 Calidad:*\n"
+ f" Piotroski: `{piot.get('score','N/D')}/9` — _{piot.get('interpretacion','')}_\n"
+ f" ROIC: `{rw.get('roic_pct','N/D')}%` vs WACC: `{rw.get('wacc_pct','N/D')}%`\n\n"
+ f"*💰 DCF Caso Base:*\n"
+ f" Valor intrínseco: `${vi_base:,.2f}`\n"
+ f" Margen de seguridad: `{mg_base:.1f}%`\n" if vi_base else " _DCF no disponible_\n"
+ f"\n*📈 Técnico:*\n"
+ f" Score: `{tech.get('score',{}).get('score','N/D')}/10` | "
+ f"Tendencia: _{tech.get('tendencia',{}).get('tendencia','N/D')}_\n"
+ f" RSI: `{tech.get('rsi',{}).get('valor','N/D')}` — _{tech.get('rsi',{}).get('señal','')}_\n\n"
+ f"*🗣️ Consenso:*\n"
+ f" {consenso.get('recomendacion','N/D')} | "
+ f"Obj: `${consenso.get('precio_objetivo_medio','N/D')}` | "
+ f"Upside: `{fmt_pct(consenso.get('upside_potencial_pct'))}`"
+ )
+ texto += bloque_señal(señal)
+
+ await msg.edit_text(texto, parse_mode=ParseMode.MARKDOWN)
+ except Exception as e:
+ await msg.edit_text(f"❌ Error al analizar `{ticker}`: {e}", parse_mode=ParseMode.MARKDOWN)
+
+
+async def cmd_macro(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ msg = await update.message.reply_text("⏳ Cargando indicadores macro desde FRED...",
+ parse_mode=ParseMode.MARKDOWN)
+ try:
+ macro = condiciones_mercado()
+ texto = (
+ f"🌐 *Economía EE.UU. — {macro.get('fecha','')}*\n\n"
+ f"*Régimen:* _{macro.get('regimen_macro','N/D')}_\n\n"
+ f"*📊 Indicadores clave:*\n"
+ f" Desempleo: `{macro.get('desempleo_pct','N/D')}%`\n"
+ f" Inflación YoY: `{macro.get('inflacion_yoy_pct','N/D')}%`\n"
+ f" Fed Funds Rate: `{macro.get('fed_funds_pct','N/D')}%`\n"
+ f" T10Y: `{macro.get('t10y_pct','N/D')}%`\n"
+ f" Spread 10Y-2Y: `{macro.get('spread_10y_2y','N/D')} bps`\n\n"
+ f"{'⚠️ *CURVA INVERTIDA* — señal histórica de recesión' if macro.get('curva_invertida') else '✅ Curva de rendimientos normal'}"
+ )
+ await msg.edit_text(texto, parse_mode=ParseMode.MARKDOWN)
+ except Exception as e:
+ await msg.edit_text(f"❌ Error: {e}", parse_mode=ParseMode.MARKDOWN)
+
+
+async def manejar_mensaje(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Si el usuario escribe un ticker directamente, ejecutar análisis rápido."""
+ texto = update.message.text.strip().upper()
+ if 1 <= len(texto) <= 10 and texto.replace("-", "").replace(".", "").isalnum():
+ context.args = [texto]
+ await cmd_precio(update, context)
+ else:
+ await update.message.reply_text(
+ "No entendí tu mensaje. Escribe `/ayuda` para ver los comandos disponibles.",
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+
+def main():
+ if not tiene_token_telegram():
+ print("ERROR: TELEGRAM_BOT_TOKEN no configurado en .env")
+ print("Obtén tu token con @BotFather en Telegram y agrégalo al .env")
+ return
+
+ app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
+
+ app.add_handler(CommandHandler("start", cmd_start))
+ app.add_handler(CommandHandler("ayuda", cmd_ayuda))
+ app.add_handler(CommandHandler("help", cmd_ayuda))
+ app.add_handler(CommandHandler("precio", cmd_precio))
+ app.add_handler(CommandHandler("fundamental", cmd_fundamental))
+ app.add_handler(CommandHandler("dcf", cmd_dcf))
+ app.add_handler(CommandHandler("tecnico", cmd_tecnico))
+ app.add_handler(CommandHandler("analizar", cmd_analizar))
+ app.add_handler(CommandHandler("macro", cmd_macro))
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, manejar_mensaje))
+
+ print("🤖 InvestSkill Bot de Telegram iniciado. Presiona Ctrl+C para detener.")
+ app.run_polling(allowed_updates=Update.ALL_TYPES)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bots/whatsapp_bot.py b/bots/whatsapp_bot.py
new file mode 100644
index 0000000..cd866fe
--- /dev/null
+++ b/bots/whatsapp_bot.py
@@ -0,0 +1,300 @@
+"""
+InvestSkill — Bot de WhatsApp via Twilio
+Ejecuta un webhook Flask que Twilio llama cuando llega un mensaje.
+
+SETUP:
+1. Cuenta Twilio gratuita: https://console.twilio.com
+2. Activa WhatsApp Sandbox en Twilio → Messaging → Try it out → Send a WhatsApp message
+3. Configura en Twilio el webhook: http://TU_IP:5000/whatsapp
+4. Para exponer localmente: usa ngrok → ngrok http 5000
+5. Agrega credenciales en .env
+
+COMANDOS (escríbelos en WhatsApp al número sandbox de Twilio):
+ precio AAPL — Precio en tiempo real
+ analizar AAPL — Análisis completo
+ fundamental AAPL — Métricas fundamentales
+ dcf AAPL — Valoración DCF
+ tecnico AAPL — Score técnico
+ macro — Indicadores macroeconómicos
+ ayuda — Ver todos los comandos
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import logging
+from flask import Flask, request
+from twilio.twiml.messaging_response import MessagingResponse
+from twilio.rest import Client as TwilioClient
+
+from core.data.fetcher import FetcherMercado, formatear_numero
+from core.data.macro import condiciones_mercado
+from core.analysis.fundamental import calcular_piotroski, calcular_roic_wacc
+from core.analysis.valuation import calcular_dcf
+from core.analysis.technical import analizar as analizar_tecnico, retornos_historicos
+from core.ai.analyst import señal_inversion_final
+from config import (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN,
+ TWILIO_WHATSAPP_NUMBER, WEBHOOK_PORT, tiene_credenciales_twilio)
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+app = Flask(__name__)
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def fmt_pct(v) -> str:
+ return f"{v:.2f}%" if v is not None else "N/D"
+
+def fmt_num(v, prefijo="$") -> str:
+ return formatear_numero(v, prefijo=prefijo) if v else "N/D"
+
+def responder(texto: str) -> str:
+ """Genera respuesta TwiML."""
+ r = MessagingResponse()
+ r.message(texto)
+ return str(r)
+
+
+# ── Lógica de análisis ────────────────────────────────────────────────────────
+
+def procesar_precio(ticker: str) -> str:
+ try:
+ f = FetcherMercado(ticker)
+ p = f.precio_actual()
+ em = "📈" if (p.get("cambio_pct", 0) or 0) >= 0 else "📉"
+ return (
+ f"*{p.get('nombre', ticker)}* ({ticker})\n\n"
+ f"💰 Precio: {p.get('moneda','USD')} {p.get('precio', 'N/D'):,.4f}\n"
+ f"{em} Cambio: {p.get('cambio_pct', 0):+.2f}%\n\n"
+ f"📊 Rango día: {p.get('minimo_dia','N/D')} - {p.get('maximo_dia','N/D')}\n"
+ f"📅 52 sem: {p.get('min_52s','N/D')} - {p.get('max_52s','N/D')}\n"
+ f"🏢 Cap: {fmt_num(p.get('cap_mercado'))}\n"
+ f"⏰ {p.get('timestamp','')} (retraso ~15 min)"
+ )
+ except Exception as e:
+ return f"❌ Error al obtener precio de {ticker}: {e}"
+
+
+def procesar_fundamental(ticker: str) -> str:
+ try:
+ f = FetcherMercado(ticker)
+ fund = f.fundamentales()
+ piot_raw = f.datos_piotroski()
+ piot = calcular_piotroski(piot_raw)
+ rw = calcular_roic_wacc(fund, piot_raw)
+ return (
+ f"📊 *Fundamentales — {ticker}*\n\n"
+ f"Valoración:\n"
+ f" P/E TTM: {fund.get('pe_ttm','N/D')}x\n"
+ f" EV/EBITDA: {fund.get('ev_ebitda','N/D')}x\n"
+ f" FCF Yield: {fmt_pct(fund.get('fcf_yield'))}\n\n"
+ f"Rentabilidad:\n"
+ f" Margen Neto: {fmt_pct(fund.get('margen_neto'))}\n"
+ f" ROE: {fmt_pct(fund.get('roe'))}\n\n"
+ f"Balance:\n"
+ f" Caja: {fmt_num(fund.get('caja_total'))}\n"
+ f" Deuda: {fmt_num(fund.get('deuda_total'))}\n"
+ f" D/E: {fund.get('deuda_equity','N/D')}x\n\n"
+ f"Piotroski: {piot.get('score','N/D')}/9 - {piot.get('interpretacion','')}\n"
+ f"ROIC: {rw.get('roic_pct','N/D')}% vs WACC: {rw.get('wacc_pct','N/D')}%"
+ )
+ except Exception as e:
+ return f"❌ Error: {e}"
+
+
+def procesar_dcf(ticker: str) -> str:
+ try:
+ f = FetcherMercado(ticker)
+ fund = f.fundamentales()
+ piot = f.datos_piotroski()
+ dcf = calcular_dcf(fund, piot)
+ if dcf.get("error"):
+ return f"⚠️ DCF no disponible: {dcf['error']}"
+ esc = dcf["escenarios"]
+ b, al, ba = esc["base"], esc["alcista"], esc["bajista"]
+ return (
+ f"💰 *DCF — {ticker}*\n\n"
+ f"WACC: {dcf.get('wacc_base_pct')}% | FCF Base: ${dcf.get('fcf_base_mm')}M\n"
+ f"Precio mercado: ${dcf.get('precio_mercado',0):,.2f}\n\n"
+ f"Escenarios:\n"
+ f" 🐻 Bajista: ${ba['valor_intrinseco']:,.2f} (MoS: {ba.get('margen_seguridad_pct','N/D')}%)\n"
+ f" 📊 Base: ${b['valor_intrinseco']:,.2f} (MoS: {b.get('margen_seguridad_pct','N/D')}%)\n"
+ f" 🐂 Alcista: ${al['valor_intrinseco']:,.2f} (MoS: {al.get('margen_seguridad_pct','N/D')}%)\n\n"
+ f"Precio ponderado: ${dcf.get('precio_ponderado',0):,.2f}\n"
+ f"{dcf.get('señal','')}"
+ )
+ except Exception as e:
+ return f"❌ Error: {e}"
+
+
+def procesar_tecnico(ticker: str) -> str:
+ try:
+ f = FetcherMercado(ticker)
+ hist = f.precio_historico("1y")
+ tech = analizar_tecnico(hist)
+ rets = retornos_historicos(hist)
+ if tech.get("error"):
+ return f"⚠️ {tech['error']}"
+ sc = tech.get("score",{})
+ tend = tech.get("tendencia",{})
+ rsi = tech.get("rsi",{})
+ macd = tech.get("macd",{})
+ return (
+ f"📈 *Técnico — {ticker}*\n\n"
+ f"Score: {sc.get('score','N/D')}/10 — {sc.get('señal','')}\n"
+ f"Tendencia: {tend.get('tendencia','N/D')}\n\n"
+ f"RSI(14): {rsi.get('valor','N/D')} — {rsi.get('señal','')}\n"
+ f"MACD: {macd.get('señal','')}\n\n"
+ f"Retornos:\n"
+ f" 1D: {fmt_pct(rets.get('1d'))} | 1M: {fmt_pct(rets.get('1m'))}\n"
+ f" 1Y: {fmt_pct(rets.get('1y'))} | YTD: {fmt_pct(rets.get('ytd'))}\n"
+ f"Volatilidad anual: {fmt_pct(rets.get('vol_anual_pct'))}"
+ )
+ except Exception as e:
+ return f"❌ Error: {e}"
+
+
+def procesar_analizar(ticker: str) -> str:
+ try:
+ f = FetcherMercado(ticker)
+ datos = f.resumen_completo()
+ hist_1y = f.precio_historico("1y")
+ fund = datos["fundamentales"]
+ piot_raw= datos["piotroski_raw"]
+ consenso= datos["consenso"]
+ precio_d= datos["precio"]
+
+ piot = calcular_piotroski(piot_raw)
+ rw = calcular_roic_wacc(fund, piot_raw)
+ dcf = calcular_dcf(fund, piot_raw)
+ tech = analizar_tecnico(hist_1y)
+ señal = señal_inversion_final(piot, rw, dcf, tech, consenso)
+
+ sc = señal["score"]
+ ac = señal["accion"]
+ em_a = "✅" if ac == "COMPRAR" else "❌" if ac == "VENDER" else "⚠️"
+
+ vi = dcf["escenarios"]["base"]["valor_intrinseco"] if not dcf.get("error") else None
+ mg = dcf["escenarios"]["base"].get("margen_seguridad_pct") if not dcf.get("error") else None
+
+ return (
+ f"🔍 *Análisis — {precio_d.get('nombre',ticker)}* ({ticker})\n\n"
+ f"💰 Precio: {precio_d.get('moneda','USD')} {precio_d.get('precio',0):,.4f} "
+ f"({precio_d.get('cambio_pct',0):+.2f}%)\n\n"
+ f"Valoración: P/E {fund.get('pe_ttm','N/D')}x | EV/EBITDA {fund.get('ev_ebitda','N/D')}x\n"
+ f"Calidad: Piotroski {piot.get('score','N/D')}/9\n"
+ f"ROIC {rw.get('roic_pct','N/D')}% vs WACC {rw.get('wacc_pct','N/D')}%\n"
+ + (f"DCF Base: ${vi:,.2f} | MoS: {mg:.1f}%\n" if vi else "DCF: No disponible\n") +
+ f"Técnico: {tech.get('score',{}).get('score','N/D')}/10 — {tech.get('tendencia',{}).get('tendencia','N/D')}\n\n"
+ f"{em_a} *SEÑAL: {ac}* — Score {sc:.1f}/10\n"
+ f"{señal.get('señal','')} | Confianza: {señal.get('confianza','')}\n\n"
+ f"⚠️ Solo educativo. No es asesoramiento financiero."
+ )
+ except Exception as e:
+ return f"❌ Error al analizar {ticker}: {e}"
+
+
+def procesar_macro() -> str:
+ try:
+ macro = condiciones_mercado()
+ em_c = "⚠️ CURVA INVERTIDA" if macro.get("curva_invertida") else "✅ Curva normal"
+ return (
+ f"🌐 *Macro EE.UU. — {macro.get('fecha','')}*\n\n"
+ f"Régimen: {macro.get('regimen_macro','N/D')}\n\n"
+ f"Desempleo: {macro.get('desempleo_pct','N/D')}%\n"
+ f"Inflación YoY: {macro.get('inflacion_yoy_pct','N/D')}%\n"
+ f"Fed Funds: {macro.get('fed_funds_pct','N/D')}%\n"
+ f"T10Y: {macro.get('t10y_pct','N/D')}%\n"
+ f"Spread 10Y-2Y: {macro.get('spread_10y_2y','N/D')} bps\n\n"
+ f"{em_c}"
+ )
+ except Exception as e:
+ return f"❌ Error macro: {e}"
+
+
+AYUDA = (
+ "📚 *InvestSkill WhatsApp Bot*\n\n"
+ "Escribe los siguientes comandos:\n\n"
+ "precio AAPL — Precio en tiempo real\n"
+ "analizar AAPL — Análisis completo + señal\n"
+ "fundamental AAPL — P/E, ROE, balance...\n"
+ "dcf AAPL — Modelo DCF en 3 escenarios\n"
+ "tecnico AAPL — RSI, MACD, tendencia\n"
+ "macro — Indicadores Fed, inflación...\n\n"
+ "Ejemplos:\n"
+ " analizar BTC-USD\n"
+ " precio FEMSA.MX\n"
+ " analizar VALE3.SA\n\n"
+ "⚠️ Solo educativo. No es asesoramiento financiero."
+)
+
+
+# ── Webhook Flask ─────────────────────────────────────────────────────────────
+
+@app.route("/whatsapp", methods=["POST"])
+def webhook_whatsapp():
+ texto_entrada = request.form.get("Body", "").strip().lower()
+ partes = texto_entrada.split()
+ cmd = partes[0] if partes else ""
+ args = partes[1:] if len(partes) > 1 else []
+
+ if cmd in ("ayuda", "help", "hola", "start"):
+ respuesta = AYUDA
+ elif cmd == "macro":
+ respuesta = procesar_macro()
+ elif cmd == "precio" and args:
+ respuesta = procesar_precio(args[0].upper())
+ elif cmd == "fundamental" and args:
+ respuesta = procesar_fundamental(args[0].upper())
+ elif cmd == "dcf" and args:
+ respuesta = procesar_dcf(args[0].upper())
+ elif cmd == "tecnico" and args:
+ respuesta = procesar_tecnico(args[0].upper())
+ elif cmd == "analizar" and args:
+ respuesta = procesar_analizar(args[0].upper())
+ elif len(partes) == 1 and 1 <= len(partes[0]) <= 10 and partes[0].upper().replace("-","").replace(".","").isalnum():
+ respuesta = procesar_precio(partes[0].upper())
+ else:
+ respuesta = "No entendí. Escribe *ayuda* para ver los comandos disponibles."
+
+ return responder(respuesta)
+
+
+@app.route("/health", methods=["GET"])
+def health():
+ return {"status": "ok", "bot": "InvestSkill WhatsApp", "timestamp": __import__("datetime").datetime.now().isoformat()}
+
+
+# ── Envío proactivo (opcional) ────────────────────────────────────────────────
+
+def enviar_alerta(destinatario: str, mensaje: str):
+ """
+ Envía un mensaje proactivo via Twilio WhatsApp.
+ destinatario: 'whatsapp:+573001234567'
+ """
+ if not tiene_credenciales_twilio():
+ logger.warning("Credenciales Twilio no configuradas")
+ return
+ try:
+ client = TwilioClient(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
+ msg = client.messages.create(
+ body=mensaje,
+ from_=TWILIO_WHATSAPP_NUMBER,
+ to=destinatario,
+ )
+ logger.info(f"Alerta enviada: {msg.sid}")
+ except Exception as e:
+ logger.error(f"Error enviando alerta: {e}")
+
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+
+if __name__ == "__main__":
+ if not tiene_credenciales_twilio():
+ print("⚠️ TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN no configurados en .env")
+ print(" El webhook igual funcionará para pruebas locales con ngrok.")
+ print(f"🟢 InvestSkill WhatsApp Webhook corriendo en puerto {WEBHOOK_PORT}")
+ print(f" Endpoint: http://localhost:{WEBHOOK_PORT}/whatsapp")
+ print(f" Para exponer a internet: ngrok http {WEBHOOK_PORT}")
+ app.run(host="0.0.0.0", port=WEBHOOK_PORT, debug=False)
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..7f89523
--- /dev/null
+++ b/config.py
@@ -0,0 +1,126 @@
+"""
+InvestSkill — Configuración central del sistema
+"""
+import os
+from dotenv import load_dotenv
+
+load_dotenv()
+
+# ── IA ─────────────────────────────────────────────────────
+ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
+CLAUDE_MODEL = "claude-sonnet-4-6"
+
+# ── Bots ────────────────────────────────────────────────────
+TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
+TWILIO_ACCOUNT_SID = os.getenv("TWILIO_ACCOUNT_SID", "")
+TWILIO_AUTH_TOKEN = os.getenv("TWILIO_AUTH_TOKEN", "")
+TWILIO_WHATSAPP_NUMBER = os.getenv("TWILIO_WHATSAPP_NUMBER", "")
+
+# ── App ─────────────────────────────────────────────────────
+APP_ENV = os.getenv("APP_ENV", "development")
+APP_PORT = int(os.getenv("APP_PORT", 8501))
+DEBUG = os.getenv("DEBUG", "true").lower() == "true"
+
+# ── Caché (segundos) ────────────────────────────────────────
+CACHE_TTL = int(os.getenv("CACHE_TTL", 300))
+CACHE_TTL_FINANCIALS = int(os.getenv("CACHE_TTL_FINANCIALS", 3600))
+CACHE_TTL_NEWS = int(os.getenv("CACHE_TTL_NEWS", 900))
+
+# ── Parámetros financieros por defecto ─────────────────────
+TASA_LIBRE_RIESGO = 0.043 # 10Y Treasury actual
+PRIMA_RIESGO_MERCADO = 0.055 # ERP histórica
+TASA_IMPUESTO = 0.21 # Federal USA
+TASA_CRECIMIENTO_TERMINAL = 0.025
+WACC_DEFECTO = 0.10
+
+# ── Mercados soportados ─────────────────────────────────────
+MERCADOS = {
+ "us": {
+ "nombre": "EE.UU. (NYSE/NASDAQ)",
+ "sufijo": "",
+ "moneda": "USD",
+ "emoji": "🇺🇸",
+ },
+ "mx": {
+ "nombre": "México (BMV)",
+ "sufijo": ".MX",
+ "moneda": "MXN",
+ "emoji": "🇲🇽",
+ },
+ "br": {
+ "nombre": "Brasil (B3)",
+ "sufijo": ".SA",
+ "moneda": "BRL",
+ "emoji": "🇧🇷",
+ },
+ "ar": {
+ "nombre": "Argentina (BYMA)",
+ "sufijo": ".BA",
+ "moneda": "ARS",
+ "emoji": "🇦🇷",
+ },
+ "co": {
+ "nombre": "Colombia (BVC)",
+ "sufijo": ".CL",
+ "moneda": "COP",
+ "emoji": "🇨🇴",
+ },
+ "cl": {
+ "nombre": "Chile (BCS)",
+ "sufijo": ".SN",
+ "moneda": "CLP",
+ "emoji": "🇨🇱",
+ },
+ "crypto": {
+ "nombre": "Criptomonedas",
+ "sufijo": "-USD",
+ "moneda": "USD",
+ "emoji": "₿",
+ },
+}
+
+# ── Tickers de referencia (índices y benchmarks) ────────────
+INDICES_REFERENCIA = {
+ "S&P 500": "^GSPC",
+ "NASDAQ 100": "^NDX",
+ "Dow Jones": "^DJI",
+ "Russell 2000": "^RUT",
+ "VIX": "^VIX",
+ "Oro": "GC=F",
+ "Petróleo WTI": "CL=F",
+ "Bitcoin": "BTC-USD",
+ "T-Note 10Y": "^TNX",
+}
+
+# ── Sectores S&P 500 (ETFs representativos) ─────────────────
+SECTORES_ETF = {
+ "Tecnología": "XLK",
+ "Salud": "XLV",
+ "Finanzas": "XLF",
+ "Consumo Discrecional": "XLY",
+ "Consumo Básico": "XLP",
+ "Energía": "XLE",
+ "Industria": "XLI",
+ "Materiales": "XLB",
+ "Servicios Públicos": "XLU",
+ "Inmobiliario": "XLRE",
+ "Comunicaciones": "XLC",
+}
+
+# ── RSS feeds de noticias financieras (gratuito) ────────────
+NEWS_FEEDS = [
+ "https://feeds.finance.yahoo.com/rss/2.0/headline",
+ "https://www.investing.com/rss/news.rss",
+ "https://feeds.marketwatch.com/marketwatch/realtimeheadlines/",
+ "https://seekingalpha.com/feed.xml",
+]
+
+# ── Validaciones ─────────────────────────────────────────────
+def tiene_api_claude() -> bool:
+ return bool(ANTHROPIC_API_KEY and ANTHROPIC_API_KEY.startswith("sk-ant"))
+
+def tiene_token_telegram() -> bool:
+ return bool(TELEGRAM_BOT_TOKEN and ":" in TELEGRAM_BOT_TOKEN)
+
+def tiene_credenciales_twilio() -> bool:
+ return bool(TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN)
diff --git a/core/__init__.py b/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/ai/__init__.py b/core/ai/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/ai/analyst.py b/core/ai/analyst.py
new file mode 100644
index 0000000..21d88f1
--- /dev/null
+++ b/core/ai/analyst.py
@@ -0,0 +1,363 @@
+"""
+InvestSkill — Capa de Inteligencia Artificial
+Genera narrativas de análisis en español usando Claude API (Anthropic).
+Si no hay API key configurada, retorna un resumen estructurado sin IA.
+"""
+import json
+from typing import Optional
+from config import ANTHROPIC_API_KEY, CLAUDE_MODEL, tiene_api_claude
+from core.data.fetcher import formatear_numero
+
+try:
+ import anthropic
+ ANTHROPIC_OK = True
+except ImportError:
+ ANTHROPIC_OK = False
+
+
+# ── Cliente Claude ────────────────────────────────────────────────────────────
+
+def _cliente() -> Optional[object]:
+ if not ANTHROPIC_OK or not tiene_api_claude():
+ return None
+ try:
+ return anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
+ except Exception:
+ return None
+
+
+def _llamar_claude(prompt: str, max_tokens: int = 1500) -> Optional[str]:
+ """Llama a Claude y retorna el texto, o None si no está disponible."""
+ cliente = _cliente()
+ if not cliente:
+ return None
+ try:
+ msg = cliente.messages.create(
+ model=CLAUDE_MODEL,
+ max_tokens=max_tokens,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ return msg.content[0].text.strip()
+ except Exception as e:
+ return f"[Error al conectar con Claude API: {e}]"
+
+
+# ── Prompts especializados ────────────────────────────────────────────────────
+
+def narrativa_evaluacion_accion(datos: dict) -> str:
+ """
+ Genera la tesis de inversión completa en español para un activo.
+ Usa datos reales del FetcherMercado.resumen_completo().
+ """
+ ticker = datos.get("ticker", "")
+ nombre = datos.get("precio", {}).get("nombre", ticker)
+ precio_d = datos.get("precio", {})
+ fund = datos.get("fundamentales", {})
+ consenso = datos.get("consenso", {})
+ desc = datos.get("descripcion", "")[:500]
+
+ prompt = f"""Eres un analista financiero senior. Analiza el siguiente activo y genera un informe de inversión COMPLETO en español, con datos reales y sin inventar cifras.
+
+ACTIVO: {nombre} ({ticker})
+DESCRIPCIÓN: {desc}
+
+DATOS DE PRECIO:
+- Precio actual: {precio_d.get('precio')} {precio_d.get('moneda','USD')}
+- Cambio hoy: {precio_d.get('cambio_pct')}%
+- Máximo 52 semanas: {precio_d.get('max_52s')}
+- Mínimo 52 semanas: {precio_d.get('min_52s')}
+- Posición en rango 52s: {precio_d.get('rango_52s_pct')}%
+- Cap. de mercado: {formatear_numero(fund.get('cap_mercado'), prefijo='$')}
+- Sector: {precio_d.get('sector')} | Industria: {precio_d.get('industria')}
+
+VALORACIÓN:
+- P/E TTM: {fund.get('pe_ttm')} | P/E Forward: {fund.get('pe_forward')}
+- PEG: {fund.get('peg')} | P/Ventas: {fund.get('precio_ventas')} | EV/EBITDA: {fund.get('ev_ebitda')}
+- Price/Book: {fund.get('precio_valor_libro')}
+- FCF Yield: {fund.get('fcf_yield')}%
+
+RENTABILIDAD:
+- ROE: {fund.get('roe')}% | ROA: {fund.get('roa')}%
+- Margen bruto: {fund.get('margen_bruto')}% | Margen operativo: {fund.get('margen_operativo')}%
+- Margen neto: {fund.get('margen_neto')}% | Margen EBITDA: {fund.get('margen_ebitda')}%
+
+CRECIMIENTO:
+- Crec. ingresos YoY: {fund.get('crec_ingresos_anual')}%
+- Crec. ganancias: {fund.get('crec_ganancias')}%
+
+BALANCE Y CAJA:
+- Deuda/Equity: {fund.get('deuda_equity')} | Ratio corriente: {fund.get('ratio_corriente')}
+- Caja total: {formatear_numero(fund.get('caja_total'), prefijo='$')}
+- Deuda total: {formatear_numero(fund.get('deuda_total'), prefijo='$')}
+- Deuda neta: {formatear_numero(fund.get('deuda_neta'), prefijo='$')}
+
+FLUJOS DE CAJA:
+- FCF: {formatear_numero(fund.get('fcf'), prefijo='$')} | OCF: {formatear_numero(fund.get('ocf'), prefijo='$')}
+
+DIVIDENDO:
+- Yield: {fund.get('div_yield')}% | Payout ratio: {fund.get('payout_ratio')}%
+
+RIESGO:
+- Beta: {fund.get('beta')}
+
+CONSENSO ANALISTAS:
+- Recomendación: {consenso.get('recomendación')}
+- Precio objetivo medio: ${consenso.get('precio_objetivo_medio')}
+- Upside potencial: {consenso.get('upside_potencial_pct')}%
+- Num. analistas: {consenso.get('num_analistas')}
+
+Genera el informe con estas secciones exactas:
+
+## 1. Tesis de Inversión
+(2-3 párrafos sobre el caso bull/bear principal)
+
+## 2. Fortalezas
+(3-5 puntos con bullet points •)
+
+## 3. Riesgos Principales
+(3-5 puntos con bullet points •)
+
+## 4. Valoración
+(Análisis de si está caro/barato basado en los múltiplos reales arriba)
+
+## 5. Veredicto Final
+(1 párrafo claro: COMPRAR / MANTENER / VENDER con justificación)
+
+IMPORTANTE: Solo usa los datos reales proporcionados. Si un dato es None o N/D, dilo explícitamente. No inventes cifras."""
+
+ respuesta = _llamar_claude(prompt, max_tokens=1800)
+ if respuesta:
+ return respuesta
+ return _narrativa_sin_ia(datos)
+
+
+def narrativa_dcf(dcf_result: dict, ticker: str) -> str:
+ """Explica los resultados del DCF en lenguaje claro."""
+ if dcf_result.get("error"):
+ return f"DCF no disponible: {dcf_result['error']}"
+
+ base = dcf_result["escenarios"]["base"]
+ alcista = dcf_result["escenarios"]["alcista"]
+ bajista = dcf_result["escenarios"]["bajista"]
+
+ prompt = f"""Eres un analista financiero. Explica los siguientes resultados del modelo DCF de {ticker} en español claro y profesional (máximo 300 palabras):
+
+RESULTADOS DCF:
+- FCF Base: ${dcf_result.get('fcf_base_mm')}M
+- WACC utilizado: {dcf_result.get('wacc_base_pct')}%
+
+Escenario BASE (probabilidad 55%):
+ - Crecimiento fase 1 (años 1-5): {base['crec_fase1_pct']}%
+ - Crecimiento fase 2 (años 6-10): {base['crec_fase2_pct']}%
+ - Valor intrínseco: ${base['valor_intrinseco']}
+ - Precio de mercado: ${base['precio_mercado']}
+ - Margen de seguridad: {base['margen_seguridad_pct']}%
+
+Escenario ALCISTA (probabilidad 25%):
+ - Valor intrínseco: ${alcista['valor_intrinseco']}
+ - Margen de seguridad: {alcista['margen_seguridad_pct']}%
+
+Escenario BAJISTA (probabilidad 20%):
+ - Valor intrínseco: ${bajista['valor_intrinseco']}
+ - Margen de seguridad: {bajista['margen_seguridad_pct']}%
+
+Precio ponderado (promedio ponderado): ${dcf_result.get('precio_ponderado')}
+
+Señal: {dcf_result.get('señal')}
+
+Explica: 1) Qué significan estos resultados, 2) Si el precio de mercado es atractivo, 3) Qué supuestos son los más sensibles."""
+
+ respuesta = _llamar_claude(prompt, max_tokens=500)
+ if respuesta:
+ return respuesta
+ return (f"El modelo DCF estima un valor intrínseco de ${base['valor_intrinseco']} en el caso base, "
+ f"comparado con el precio de mercado de ${base['precio_mercado']}. "
+ f"Margen de seguridad: {base['margen_seguridad_pct']}%.")
+
+
+def narrativa_tecnica(tech_result: dict, ticker: str) -> str:
+ """Interpreta los indicadores técnicos en lenguaje natural."""
+ if tech_result.get("error"):
+ return f"Análisis técnico no disponible: {tech_result['error']}"
+
+ rsi = tech_result.get("rsi", {})
+ macd = tech_result.get("macd", {})
+ tend = tech_result.get("tendencia", {})
+ score = tech_result.get("score", {})
+ mm = tech_result.get("medias_moviles", {})
+
+ prompt = f"""Eres un analista técnico. Resume el análisis técnico de {ticker} en español (máximo 250 palabras):
+
+INDICADORES ACTUALES:
+- Precio: ${mm.get('precio')} | SMA20: {mm.get('sma20')} | SMA50: {mm.get('sma50')} | SMA200: {mm.get('sma200')}
+- Golden Cross: {mm.get('golden_cross')}
+- RSI(14): {rsi.get('valor')} — {rsi.get('señal')}
+- MACD: {macd.get('macd')} | Señal: {macd.get('señal_line')} | Histograma: {macd.get('histograma')}
+- Señal MACD: {macd.get('señal')}
+- Tendencia: {tend.get('tendencia')} | Señales: {', '.join(tend.get('señales', []))}
+- Score técnico: {score.get('score')}/10 — {score.get('señal')}
+
+Describe: 1) La tendencia actual, 2) Los niveles de entrada/salida sugeridos, 3) La confirmación o divergencia entre indicadores."""
+
+ respuesta = _llamar_claude(prompt, max_tokens=400)
+ if respuesta:
+ return respuesta
+ return (f"Tendencia {tend.get('tendencia', 'no disponible')}. "
+ f"RSI en {rsi.get('valor', 'N/D')} ({rsi.get('señal', '')}). "
+ f"MACD: {macd.get('señal', '')}. "
+ f"Score técnico: {score.get('score', 'N/D')}/10.")
+
+
+def narrativa_macro(macro_data: dict) -> str:
+ """Interpreta las condiciones macroeconómicas y su impacto en mercados."""
+ prompt = f"""Eres un economista. Analiza las siguientes condiciones macroeconómicas de EE.UU. y su impacto en los mercados financieros (máximo 350 palabras en español):
+
+INDICADORES MACRO ACTUALES:
+- Régimen: {macro_data.get('regimen_macro')}
+- Desempleo: {macro_data.get('desempleo_pct')}%
+- Inflación YoY: {macro_data.get('inflacion_yoy_pct')}%
+- Tasa Fed Funds: {macro_data.get('fed_funds_pct')}%
+- Rendimiento T10Y: {macro_data.get('t10y_pct')}%
+- Spread 10Y-2Y: {macro_data.get('spread_10y_2y')} bps
+- Curva invertida: {macro_data.get('curva_invertida')}
+
+Describe: 1) El régimen macroeconómico actual, 2) Implicaciones para renta variable, 3) Sectores favorecidos y desfavorecidos, 4) Riesgos principales en el horizonte próximo."""
+
+ respuesta = _llamar_claude(prompt, max_tokens=600)
+ if respuesta:
+ return respuesta
+ return (f"Régimen macroeconómico: {macro_data.get('regimen_macro', 'No disponible')}. "
+ f"Inflación: {macro_data.get('inflacion_yoy_pct')}%. "
+ f"Fed Funds: {macro_data.get('fed_funds_pct')}%.")
+
+
+def señal_inversion_final(piotroski: dict, roic_wacc: dict,
+ dcf: dict, tech: dict, consenso: dict) -> dict:
+ """
+ Genera la señal de inversión final consolidada (0-10) combinando todas las capas.
+ Funciona con o sin Claude API.
+ """
+ score_total = 0
+ componentes = {}
+
+ # Piotroski (peso 20%)
+ piot_score = piotroski.get("score")
+ if piot_score is not None:
+ pts = piot_score / 9 * 10
+ componentes["piotroski"] = {"score": piot_score, "max": 9, "puntos": round(pts, 1)}
+ score_total += pts * 0.20
+
+ # ROIC vs WACC (peso 20%)
+ spread = roic_wacc.get("spread_roic_wacc")
+ if spread is not None:
+ if spread > 5: pts = 9
+ elif spread > 2: pts = 7
+ elif spread > 0: pts = 6
+ elif spread > -3: pts = 4
+ else: pts = 2
+ componentes["roic_wacc"] = {"spread": spread, "puntos": pts}
+ score_total += pts * 0.20
+
+ # DCF margen seguridad (peso 30%)
+ if not dcf.get("error"):
+ mg = dcf["escenarios"]["base"].get("margen_seguridad_pct")
+ if mg is not None:
+ if mg > 40: pts = 10
+ elif mg > 20: pts = 8
+ elif mg > 5: pts = 6
+ elif mg > -10: pts = 4
+ else: pts = 2
+ componentes["dcf"] = {"margen_seguridad": mg, "puntos": pts}
+ score_total += pts * 0.30
+
+ # Score técnico (peso 15%)
+ tech_score = tech.get("score", {}).get("score") if tech else None
+ if tech_score is not None:
+ componentes["tecnico"] = {"score": tech_score, "puntos": tech_score}
+ score_total += tech_score * 0.15
+
+ # Consenso analistas (peso 15%)
+ upside = consenso.get("upside_potencial_pct")
+ if upside is not None:
+ if upside > 30: pts = 9
+ elif upside > 15: pts = 7
+ elif upside > 5: pts = 6
+ elif upside > -5: pts = 5
+ else: pts = 3
+ componentes["consenso"] = {"upside": upside, "puntos": pts}
+ score_total += pts * 0.15
+
+ # Normalizar
+ pesos_usados = (
+ (0.20 if "piotroski" in componentes else 0) +
+ (0.20 if "roic_wacc" in componentes else 0) +
+ (0.30 if "dcf" in componentes else 0) +
+ (0.15 if "tecnico" in componentes else 0) +
+ (0.15 if "consenso" in componentes else 0)
+ )
+
+ score_final = round(score_total / pesos_usados if pesos_usados > 0 else 5.0, 1)
+ score_final = min(max(score_final, 0), 10)
+
+ if score_final >= 8.0:
+ señal = "ALCISTA"; accion = "COMPRAR"; conf = "ALTA"; color = "#00c851"
+ elif score_final >= 6.5:
+ señal = "MODERADAMENTE ALCISTA"; accion = "COMPRAR"; conf = "MEDIA"; color = "#80d9a0"
+ elif score_final >= 5.0:
+ señal = "NEUTRAL"; accion = "MANTENER"; conf = "MEDIA"; color = "#ffc107"
+ elif score_final >= 3.5:
+ señal = "MODERADAMENTE BAJISTA"; accion = "VENDER"; conf = "MEDIA"; color = "#ff8888"
+ else:
+ señal = "BAJISTA"; accion = "VENDER"; conf = "ALTA"; color = "#ff4444"
+
+ return {
+ "score": score_final,
+ "señal": señal,
+ "accion": accion,
+ "confianza": conf,
+ "color": color,
+ "componentes": componentes,
+ "bloque": f"""
+╔══════════════════════════════════════════════╗
+║ SEÑAL DE INVERSIÓN ║
+╠══════════════════════════════════════════════╣
+║ Señal: {señal:<32}║
+║ Confianza: {conf:<32}║
+║ Score: {score_final:<5.1f} / 10 ║
+╠══════════════════════════════════════════════╣
+║ Acción: {accion:<32}║
+╚══════════════════════════════════════════════╝""",
+ }
+
+
+# ── Fallback sin API Claude ───────────────────────────────────────────────────
+
+def _narrativa_sin_ia(datos: dict) -> str:
+ """Genera un resumen estructurado sin IA cuando no hay API key."""
+ fund = datos.get("fundamentales", {})
+ precio_d = datos.get("precio", {})
+ ticker = datos.get("ticker", "")
+ nombre = precio_d.get("nombre", ticker)
+
+ lineas = [
+ f"## {nombre} ({ticker}) — Resumen de Análisis",
+ "",
+ f"**Precio actual:** {precio_d.get('precio')} {precio_d.get('moneda','USD')} "
+ f"({'↑' if (precio_d.get('cambio_pct') or 0) > 0 else '↓'} {abs(precio_d.get('cambio_pct') or 0):.2f}%)",
+ "",
+ "### Valoración",
+ f"- P/E TTM: {fund.get('pe_ttm') or 'N/D'} | P/E Forward: {fund.get('pe_forward') or 'N/D'}",
+ f"- EV/EBITDA: {fund.get('ev_ebitda') or 'N/D'} | FCF Yield: {fund.get('fcf_yield') or 'N/D'}%",
+ "",
+ "### Rentabilidad",
+ f"- Margen neto: {fund.get('margen_neto') or 'N/D'}% | ROE: {fund.get('roe') or 'N/D'}%",
+ f"- Margen operativo: {fund.get('margen_operativo') or 'N/D'}%",
+ "",
+ "### Balance",
+ f"- Deuda/Equity: {fund.get('deuda_equity') or 'N/D'} | Ratio corriente: {fund.get('ratio_corriente') or 'N/D'}",
+ f"- Caja: {formatear_numero(fund.get('caja_total'), prefijo='$')} | "
+ f"Deuda neta: {formatear_numero(fund.get('deuda_neta'), prefijo='$')}",
+ "",
+ "*Para análisis narrativo con IA, configura ANTHROPIC_API_KEY en el archivo .env*",
+ ]
+ return "\n".join(lineas)
diff --git a/core/analysis/__init__.py b/core/analysis/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/analysis/fundamental.py b/core/analysis/fundamental.py
new file mode 100644
index 0000000..bdd9bd6
--- /dev/null
+++ b/core/analysis/fundamental.py
@@ -0,0 +1,305 @@
+"""
+InvestSkill — Motor de Análisis Fundamental
+Calcula Piotroski F-Score, ROIC, WACC, EVA y métricas de calidad
+con datos reales provenientes del FetcherMercado.
+"""
+import numpy as np
+from typing import Optional
+from config import TASA_LIBRE_RIESGO, PRIMA_RIESGO_MERCADO, TASA_IMPUESTO
+
+
+# ── Piotroski F-Score ─────────────────────────────────────────────────────────
+
+def calcular_piotroski(datos: dict) -> dict:
+ """
+ Calcula el Piotroski F-Score (0–9) a partir de datos financieros reales.
+ Retorna puntuación, criterios individuales e interpretación.
+ """
+ if datos.get("error"):
+ return {"error": datos["error"], "score": None}
+
+ criterios = {}
+ puntos = {}
+
+ ni0 = datos.get("net_income_0")
+ ni1 = datos.get("net_income_1")
+ ta0 = datos.get("total_assets_0")
+ ta1 = datos.get("total_assets_1")
+ ocf0 = datos.get("ocf_0")
+ ld0 = datos.get("long_debt_0", 0)
+ ld1 = datos.get("long_debt_1", 0)
+ ca0 = datos.get("curr_assets_0")
+ cl0 = datos.get("curr_liab_0")
+ ca1 = datos.get("curr_assets_1")
+ cl1 = datos.get("curr_liab_1")
+ sh0 = datos.get("shares_0", 0)
+ sh1 = datos.get("shares_1", 0)
+ gp0 = datos.get("gross_profit_0")
+ rev0 = datos.get("revenue_0")
+ gp1 = datos.get("gross_profit_1")
+ rev1 = datos.get("revenue_1")
+
+ ta_prom = (ta0 + ta1) / 2 if (ta0 and ta1) else ta0
+
+ # ── Rentabilidad ──────────────────────────────────────────────────────────
+
+ # F1: ROA > 0
+ roa0 = (ni0 / ta_prom) if (ni0 is not None and ta_prom) else None
+ p1 = 1 if (roa0 is not None and roa0 > 0) else 0
+ criterios["F1_ROA_positivo"] = {
+ "descripcion": "ROA positivo (ganancia neta > 0)",
+ "valor": round(roa0 * 100, 2) if roa0 is not None else None,
+ "unidad": "%",
+ "pasa": bool(p1),
+ "puntos": p1,
+ }
+
+ # F2: OCF > 0
+ p2 = 1 if (ocf0 is not None and ocf0 > 0) else 0
+ criterios["F2_OCF_positivo"] = {
+ "descripcion": "Flujo de caja operativo positivo",
+ "valor": ocf0,
+ "unidad": "USD",
+ "pasa": bool(p2),
+ "puntos": p2,
+ }
+
+ # F3: Mejora del ROA YoY
+ roa1 = (ni1 / ta1) if (ni1 is not None and ta1) else None
+ p3 = 1 if (roa0 is not None and roa1 is not None and roa0 > roa1) else 0
+ criterios["F3_ROA_mejora"] = {
+ "descripcion": "ROA mejoró respecto al año anterior",
+ "valor": round((roa0 - roa1) * 100, 3) if (roa0 and roa1) else None,
+ "unidad": "pp",
+ "pasa": bool(p3),
+ "puntos": p3,
+ }
+
+ # F4: Calidad de ganancias (OCF/TA > ROA)
+ ocf_ta = (ocf0 / ta_prom) if (ocf0 and ta_prom) else None
+ p4 = 1 if (ocf_ta is not None and roa0 is not None and ocf_ta > roa0) else 0
+ criterios["F4_calidad_ganancias"] = {
+ "descripcion": "Flujo de caja > ROA (ganancias respaldadas por caja)",
+ "valor": round(ocf_ta * 100, 2) if ocf_ta else None,
+ "unidad": "%",
+ "pasa": bool(p4),
+ "puntos": p4,
+ }
+
+ # ── Apalancamiento / Liquidez ─────────────────────────────────────────────
+
+ # F5: Apalancamiento redujo (deuda LP / activos promedio)
+ lev0 = (ld0 / ta0) if (ld0 is not None and ta0) else None
+ lev1 = (ld1 / ta1) if (ld1 is not None and ta1) else None
+ p5 = 1 if (lev0 is not None and lev1 is not None and lev0 < lev1) else 0
+ criterios["F5_apalancamiento_baja"] = {
+ "descripcion": "Apalancamiento disminuyó año a año",
+ "valor": round((lev0 - lev1) * 100, 3) if (lev0 and lev1) else None,
+ "unidad": "pp",
+ "pasa": bool(p5),
+ "puntos": p5,
+ }
+
+ # F6: Mejora del ratio corriente
+ rc0 = (ca0 / cl0) if (ca0 and cl0) else None
+ rc1 = (ca1 / cl1) if (ca1 and cl1) else None
+ p6 = 1 if (rc0 is not None and rc1 is not None and rc0 > rc1) else 0
+ criterios["F6_liquidez_mejora"] = {
+ "descripcion": "Ratio corriente mejoró año a año",
+ "valor": round(rc0, 3) if rc0 else None,
+ "unidad": "x",
+ "pasa": bool(p6),
+ "puntos": p6,
+ }
+
+ # F7: Sin nueva emisión de acciones
+ p7 = 1 if (sh0 is not None and sh1 is not None and sh0 <= sh1 * 1.01) else 0
+ criterios["F7_sin_dilusion"] = {
+ "descripcion": "No emitió nuevas acciones (sin dilución)",
+ "valor": round((sh0 - sh1) / sh1 * 100, 2) if sh1 else None,
+ "unidad": "% cambio",
+ "pasa": bool(p7),
+ "puntos": p7,
+ }
+
+ # ── Eficiencia operativa ──────────────────────────────────────────────────
+
+ # F8: Mejora del margen bruto
+ mg0 = (gp0 / rev0) if (gp0 and rev0) else None
+ mg1 = (gp1 / rev1) if (gp1 and rev1) else None
+ p8 = 1 if (mg0 is not None and mg1 is not None and mg0 > mg1) else 0
+ criterios["F8_margen_bruto_mejora"] = {
+ "descripcion": "Margen bruto mejoró año a año",
+ "valor": round((mg0 - mg1) * 100, 2) if (mg0 and mg1) else None,
+ "unidad": "pp",
+ "pasa": bool(p8),
+ "puntos": p8,
+ }
+
+ # F9: Mejora de rotación de activos
+ at0 = (rev0 / ta_prom) if (rev0 and ta_prom) else None
+ at1 = (rev1 / ta1) if (rev1 and ta1) else None
+ p9 = 1 if (at0 is not None and at1 is not None and at0 > at1) else 0
+ criterios["F9_rotacion_activos_mejora"] = {
+ "descripcion": "Rotación de activos mejoró año a año",
+ "valor": round(at0, 3) if at0 else None,
+ "unidad": "x",
+ "pasa": bool(p9),
+ "puntos": p9,
+ }
+
+ # ── Score final ───────────────────────────────────────────────────────────
+ score = p1 + p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9
+
+ if score >= 8:
+ interpretacion = "Posición financiera FUERTE — candidato de alta calidad"
+ color = "#00c851"
+ elif score >= 5:
+ interpretacion = "Calidad MEDIA — señales mixtas, análisis adicional requerido"
+ color = "#ffc107"
+ else:
+ interpretacion = "Posición financiera DÉBIL — alto riesgo de deterioro"
+ color = "#ff4444"
+
+ return {
+ "score": score,
+ "max_score": 9,
+ "criterios": criterios,
+ "interpretacion": interpretacion,
+ "color": color,
+ "error": None,
+ }
+
+
+# ── ROIC / WACC / EVA ────────────────────────────────────────────────────────
+
+def calcular_roic_wacc(fundamentales: dict, piotroski_raw: dict) -> dict:
+ """
+ Calcula ROIC, WACC y EVA (Economic Value Added) con datos reales.
+ """
+ resultado = {"error": None}
+
+ # Datos de entrada
+ ocf = fundamentales.get("ocf") or 0
+ capex = fundamentales.get("capex") or 0
+ deuda_total = fundamentales.get("deuda_total") or 0
+ caja = fundamentales.get("caja_total") or 0
+ cap_mercado = fundamentales.get("cap_mercado") or 0
+ beta = fundamentales.get("beta") or 1.2
+ roe = fundamentales.get("roe")
+ roa = fundamentales.get("roa")
+ margen_op = fundamentales.get("margen_operativo")
+
+ ni = piotroski_raw.get("net_income_0") or 0
+ ta = piotroski_raw.get("total_assets_0") or 0
+ ta1= piotroski_raw.get("total_assets_1") or 0
+
+ # ── NOPAT ──
+ ebit = ni / (1 - TASA_IMPUESTO) if ni else 0
+ nopat = ebit * (1 - TASA_IMPUESTO)
+
+ # ── Capital invertido ──
+ deuda_neta = deuda_total - caja
+ cap_invertido = (cap_mercado + deuda_neta) if cap_mercado else (ta - caja)
+ if cap_invertido <= 0:
+ cap_invertido = ta or 1
+
+ # ── ROIC ──
+ roic = (nopat / cap_invertido * 100) if cap_invertido else None
+
+ # ── WACC (CAPM) ──
+ ke = TASA_LIBRE_RIESGO + beta * PRIMA_RIESGO_MERCADO # costo equity
+ valor_total = cap_mercado + deuda_total if cap_mercado else 1
+ peso_e = cap_mercado / valor_total if valor_total else 0.8
+ peso_d = deuda_total / valor_total if valor_total else 0.2
+
+ # Costo de deuda aproximado
+ kd_bruto = 0.06 # proxy si no hay deuda financiera
+ kd_neto = kd_bruto * (1 - TASA_IMPUESTO)
+
+ wacc = (peso_e * ke) + (peso_d * kd_neto)
+
+ # ── EVA ──
+ eva = None
+ if roic is not None:
+ spread = (roic / 100) - wacc
+ eva = spread * cap_invertido
+
+ # ── Señal ──
+ if roic is not None:
+ if roic / 100 > wacc + 0.03:
+ señal_roic = "CREANDO VALOR — ROIC supera ampliamente el WACC"
+ color_roic = "#00c851"
+ elif roic / 100 > wacc:
+ señal_roic = "Creando valor — ROIC supera WACC por margen estrecho"
+ color_roic = "#80d9a0"
+ else:
+ señal_roic = "DESTRUYENDO VALOR — ROIC por debajo del WACC"
+ color_roic = "#ff4444"
+ else:
+ señal_roic = "Sin datos suficientes"
+ color_roic = "#aaaaaa"
+
+ resultado.update({
+ "nopat": round(nopat / 1e6, 2) if nopat else None,
+ "cap_invertido_mm": round(cap_invertido / 1e6, 2) if cap_invertido else None,
+ "roic_pct": round(roic, 2) if roic else None,
+ "wacc_pct": round(wacc * 100, 2),
+ "ke_pct": round(ke * 100, 2),
+ "kd_pct": round(kd_neto * 100, 2),
+ "peso_equity": round(peso_e * 100, 1),
+ "peso_deuda": round(peso_d * 100, 1),
+ "beta": round(beta, 3),
+ "spread_roic_wacc": round((roic / 100 - wacc) * 100, 2) if roic else None,
+ "eva_mm": round(eva / 1e6, 2) if eva else None,
+ "señal": señal_roic,
+ "color": color_roic,
+ "tasa_libre_riesgo_pct": round(TASA_LIBRE_RIESGO * 100, 2),
+ "prima_riesgo_pct": round(PRIMA_RIESGO_MERCADO * 100, 2),
+ })
+
+ return resultado
+
+
+# ── Calidad de ganancias ──────────────────────────────────────────────────────
+
+def calidad_ganancias(fundamentales: dict, piotroski_raw: dict) -> dict:
+ """Evalúa la calidad y sostenibilidad de las ganancias reportadas."""
+ ocf = fundamentales.get("ocf") or 0
+ ni = piotroski_raw.get("net_income_0") or 0
+ ta = piotroski_raw.get("total_assets_0") or 1
+ ta1 = piotroski_raw.get("total_assets_1") or 1
+ ta_prom = (ta + ta1) / 2
+
+ # Ratio de conversión a caja
+ ccr = round(ocf / ni, 3) if ni else None
+
+ # Ratio de accruals
+ accruals = round((ni - ocf) / ta_prom * 100, 2) if ta_prom else None
+
+ # Interpretación
+ if ccr is not None:
+ if ccr >= 1.0:
+ ccr_señal = "ALTA — Las ganancias están totalmente respaldadas por caja"
+ ccr_color = "#00c851"
+ elif ccr >= 0.7:
+ ccr_señal = "MEDIA — Ganancias mayormente respaldadas por caja"
+ ccr_color = "#ffc107"
+ else:
+ ccr_señal = "BAJA — Las ganancias podrían estar infladas"
+ ccr_color = "#ff4444"
+ else:
+ ccr_señal = "Sin datos"
+ ccr_color = "#aaaaaa"
+
+ return {
+ "tasa_conversion_caja": ccr,
+ "ccr_señal": ccr_señal,
+ "ccr_color": ccr_color,
+ "ratio_accruals_pct": accruals,
+ "accruals_señal": (
+ "Bajo — ganancias de alta calidad" if (accruals is not None and abs(accruals) < 5)
+ else "Alto — revisar accruals contables" if (accruals is not None and abs(accruals) >= 5)
+ else "Sin datos"
+ ),
+ }
diff --git a/core/analysis/technical.py b/core/analysis/technical.py
new file mode 100644
index 0000000..7501b93
--- /dev/null
+++ b/core/analysis/technical.py
@@ -0,0 +1,350 @@
+"""
+InvestSkill — Motor de Análisis Técnico
+Calcula indicadores sobre datos OHLCV reales de yfinance.
+Usa la librería `ta` para máxima precisión.
+"""
+import pandas as pd
+import numpy as np
+from typing import Optional
+import warnings
+warnings.filterwarnings("ignore")
+
+try:
+ import ta
+ TA_OK = True
+except ImportError:
+ TA_OK = False
+
+
+# ── Análisis técnico completo ─────────────────────────────────────────────────
+
+def analizar(df: pd.DataFrame) -> dict:
+ """
+ Recibe un DataFrame OHLCV (de yfinance) y calcula todos los indicadores.
+ Retorna dict con indicadores, señales y score técnico 0-10.
+ """
+ if df is None or df.empty or len(df) < 20:
+ return {"error": "Datos históricos insuficientes (mínimo 20 velas)"}
+
+ df = df.copy().dropna(subset=["Close"])
+ close = df["Close"]
+ high = df["High"]
+ low = df["Low"]
+ vol = df["Volume"] if "Volume" in df.columns else pd.Series(dtype=float)
+
+ resultado = {"error": None}
+
+ # ── Medias Móviles ────────────────────────────────────────────────────────
+ sma20 = close.rolling(20).mean().iloc[-1] if len(close) >= 20 else None
+ sma50 = close.rolling(50).mean().iloc[-1] if len(close) >= 50 else None
+ sma200 = close.rolling(200).mean().iloc[-1] if len(close) >= 200 else None
+ ema12 = close.ewm(span=12).mean().iloc[-1]
+ ema26 = close.ewm(span=26).mean().iloc[-1]
+ ema50 = close.ewm(span=50).mean().iloc[-1] if len(close) >= 50 else None
+
+ precio_actual = float(close.iloc[-1])
+
+ resultado["medias_moviles"] = {
+ "precio": round(precio_actual, 4),
+ "sma20": round(sma20, 4) if sma20 else None,
+ "sma50": round(sma50, 4) if sma50 else None,
+ "sma200": round(sma200, 4) if sma200 else None,
+ "ema12": round(ema12, 4),
+ "ema26": round(ema26, 4),
+ "ema50": round(ema50, 4) if ema50 else None,
+ # Posición relativa
+ "sobre_sma20": bool(precio_actual > sma20) if sma20 else None,
+ "sobre_sma50": bool(precio_actual > sma50) if sma50 else None,
+ "sobre_sma200": bool(precio_actual > sma200) if sma200 else None,
+ # Golden / Death Cross
+ "golden_cross": bool(sma50 > sma200) if (sma50 and sma200) else None,
+ }
+
+ # ── RSI ───────────────────────────────────────────────────────────────────
+ if TA_OK and len(close) >= 14:
+ rsi_series = ta.momentum.RSIIndicator(close, window=14).rsi()
+ rsi = float(rsi_series.iloc[-1]) if not rsi_series.empty else None
+ else:
+ # RSI manual
+ delta = close.diff()
+ gain = delta.where(delta > 0, 0).rolling(14).mean()
+ loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
+ rs = gain / loss.replace(0, np.nan)
+ rsi = float(100 - 100 / (1 + rs.iloc[-1])) if len(close) >= 14 else None
+
+ if rsi is not None:
+ if rsi >= 70: rsi_señal = "SOBRECOMPRADO"; rsi_color = "#ff4444"
+ elif rsi <= 30: rsi_señal = "SOBREVENDIDO"; rsi_color = "#00c851"
+ else: rsi_señal = "NEUTRO"; rsi_color = "#ffc107"
+ else:
+ rsi_señal = "Sin datos"; rsi_color = "#aaaaaa"
+
+ resultado["rsi"] = {
+ "valor": round(rsi, 2) if rsi else None,
+ "señal": rsi_señal,
+ "color": rsi_color,
+ }
+
+ # ── MACD ──────────────────────────────────────────────────────────────────
+ macd_line = ema12 - ema26
+ signal_ema = close.ewm(span=26).mean()
+ exp1 = close.ewm(span=12).mean()
+ exp2 = close.ewm(span=26).mean()
+ macd_s = exp1 - exp2
+ signal_s = macd_s.ewm(span=9).mean()
+ histograma = float(macd_s.iloc[-1] - signal_s.iloc[-1])
+ macd_val = float(macd_s.iloc[-1])
+ signal_val = float(signal_s.iloc[-1])
+
+ # Detectar cruce
+ if len(macd_s) >= 2:
+ prev_hist = float(macd_s.iloc[-2] - signal_s.iloc[-2])
+ if prev_hist < 0 and histograma > 0:
+ macd_señal = "CRUCE ALCISTA — señal de compra"
+ macd_color = "#00c851"
+ elif prev_hist > 0 and histograma < 0:
+ macd_señal = "CRUCE BAJISTA — señal de venta"
+ macd_color = "#ff4444"
+ elif histograma > 0:
+ macd_señal = "Tendencia alcista"
+ macd_color = "#80d9a0"
+ else:
+ macd_señal = "Tendencia bajista"
+ macd_color = "#ff8888"
+ else:
+ macd_señal = "Sin datos"; macd_color = "#aaaaaa"
+
+ resultado["macd"] = {
+ "macd": round(macd_val, 4),
+ "señal_line": round(signal_val, 4),
+ "histograma": round(histograma, 4),
+ "señal": macd_señal,
+ "color": macd_color,
+ }
+
+ # ── Bandas de Bollinger ───────────────────────────────────────────────────
+ if len(close) >= 20:
+ sma20_s = close.rolling(20).mean()
+ std20_s = close.rolling(20).std()
+ bb_upper = float(sma20_s.iloc[-1] + 2 * std20_s.iloc[-1])
+ bb_lower = float(sma20_s.iloc[-1] - 2 * std20_s.iloc[-1])
+ bb_mid = float(sma20_s.iloc[-1])
+ bb_width = round((bb_upper - bb_lower) / bb_mid * 100, 2)
+ bb_pct_b = round((precio_actual - bb_lower) / (bb_upper - bb_lower), 3) if bb_upper != bb_lower else None
+
+ if precio_actual > bb_upper: bb_señal = "SOBRECOMPRADO (sobre banda superior)"
+ elif precio_actual < bb_lower: bb_señal = "SOBREVENDIDO (bajo banda inferior)"
+ else: bb_señal = f"Dentro de bandas ({bb_pct_b:.0%} del rango)" if bb_pct_b else "Dentro de bandas"
+
+ resultado["bollinger"] = {
+ "superior": round(bb_upper, 4),
+ "media": round(bb_mid, 4),
+ "inferior": round(bb_lower, 4),
+ "ancho_pct": bb_width,
+ "pct_b": bb_pct_b,
+ "señal": bb_señal,
+ }
+
+ # ── ATR (Average True Range) — volatilidad ────────────────────────────────
+ if len(df) >= 14:
+ h_l = high - low
+ h_cp = (high - close.shift()).abs()
+ l_cp = (low - close.shift()).abs()
+ tr = pd.concat([h_l, h_cp, l_cp], axis=1).max(axis=1)
+ atr = float(tr.rolling(14).mean().iloc[-1])
+ atr_pct = round(atr / precio_actual * 100, 2)
+ resultado["atr"] = {
+ "valor": round(atr, 4),
+ "pct": atr_pct,
+ "señal": "Alta volatilidad" if atr_pct > 3 else "Volatilidad moderada" if atr_pct > 1.5 else "Baja volatilidad",
+ }
+
+ # ── Estocástico ───────────────────────────────────────────────────────────
+ if len(close) >= 14:
+ lowest_low = low.rolling(14).min()
+ highest_high = high.rolling(14).max()
+ k_pct = (close - lowest_low) / (highest_high - lowest_low) * 100
+ k_val = float(k_pct.iloc[-1]) if not k_pct.empty else None
+ d_val = float(k_pct.rolling(3).mean().iloc[-1]) if not k_pct.empty else None
+
+ if k_val:
+ if k_val >= 80: esto_señal = "SOBRECOMPRADO"
+ elif k_val <= 20: esto_señal = "SOBREVENDIDO"
+ else: esto_señal = "NEUTRO"
+ else:
+ esto_señal = "Sin datos"
+
+ resultado["estocastico"] = {
+ "k": round(k_val, 2) if k_val else None,
+ "d": round(d_val, 2) if d_val else None,
+ "señal": esto_señal,
+ }
+
+ # ── Volumen ───────────────────────────────────────────────────────────────
+ if not vol.empty and len(vol) >= 20:
+ vol_actual = float(vol.iloc[-1])
+ vol_prom20 = float(vol.rolling(20).mean().iloc[-1])
+ ratio_vol = round(vol_actual / vol_prom20, 2) if vol_prom20 else None
+
+ resultado["volumen"] = {
+ "actual": int(vol_actual),
+ "promedio_20": int(vol_prom20),
+ "ratio": ratio_vol,
+ "señal": (
+ "VOLUMEN ALTO — confirma movimiento" if (ratio_vol and ratio_vol > 1.5) else
+ "Volumen normal" if (ratio_vol and ratio_vol > 0.7) else
+ "Volumen bajo — posible trampa"
+ ),
+ }
+
+ # ── Soporte y Resistencia ─────────────────────────────────────────────────
+ if len(close) >= 20:
+ ventana = min(len(close), 60)
+ ultimos = close.tail(ventana)
+ soporte = round(float(ultimos.quantile(0.10)), 4)
+ resistencia= round(float(ultimos.quantile(0.90)), 4)
+ resultado["soporte_resistencia"] = {
+ "soporte": soporte,
+ "resistencia": resistencia,
+ "precio": round(precio_actual, 4),
+ "distancia_soporte_pct": round((precio_actual - soporte) / soporte * 100, 1),
+ "distancia_resistencia_pct": round((resistencia - precio_actual) / precio_actual * 100, 1),
+ }
+
+ # ── Tendencia general ─────────────────────────────────────────────────────
+ tendencias = _detectar_tendencia(close, sma20, sma50, sma200)
+ resultado["tendencia"] = tendencias
+
+ # ── Score técnico 0-10 ────────────────────────────────────────────────────
+ resultado["score"] = _calcular_score_tecnico(resultado, precio_actual)
+
+ return resultado
+
+
+def _detectar_tendencia(close, sma20, sma50, sma200) -> dict:
+ precio = float(close.iloc[-1])
+ puntos = 0
+ señales = []
+
+ if sma20 and precio > sma20:
+ puntos += 1; señales.append("Sobre SMA20 ↑")
+ elif sma20:
+ puntos -= 1; señales.append("Bajo SMA20 ↓")
+
+ if sma50 and precio > sma50:
+ puntos += 1; señales.append("Sobre SMA50 ↑")
+ elif sma50:
+ puntos -= 1; señales.append("Bajo SMA50 ↓")
+
+ if sma200 and precio > sma200:
+ puntos += 2; señales.append("Sobre SMA200 ↑ (tendencia alcista LP)")
+ elif sma200:
+ puntos -= 2; señales.append("Bajo SMA200 ↓ (tendencia bajista LP)")
+
+ if sma50 and sma200 and sma50 > sma200:
+ puntos += 2; señales.append("Golden Cross activo ✓")
+ elif sma50 and sma200:
+ puntos -= 2; señales.append("Death Cross activo ✗")
+
+ if puntos >= 3:
+ tendencia = "ALCISTA"; color = "#00c851"
+ elif puntos >= 1:
+ tendencia = "Alcista moderada"; color = "#80d9a0"
+ elif puntos == 0:
+ tendencia = "LATERAL"; color = "#ffc107"
+ elif puntos >= -2:
+ tendencia = "Bajista moderada"; color = "#ff8888"
+ else:
+ tendencia = "BAJISTA"; color = "#ff4444"
+
+ return {"tendencia": tendencia, "color": color, "puntos": puntos, "señales": señales}
+
+
+def _calcular_score_tecnico(res: dict, precio: float) -> dict:
+ puntos = 5.0 # neutro
+
+ rsi = res.get("rsi", {}).get("valor")
+ if rsi:
+ if 40 <= rsi <= 60: puntos += 0.5
+ elif rsi < 30: puntos += 1.5 # sobrevendido = oportunidad
+ elif rsi > 70: puntos -= 1.5
+
+ macd_hist = res.get("macd", {}).get("histograma")
+ if macd_hist:
+ if macd_hist > 0: puntos += 1
+ else: puntos -= 1
+
+ tend_pts = res.get("tendencia", {}).get("puntos", 0)
+ puntos += tend_pts * 0.3
+
+ bb = res.get("bollinger", {})
+ pct_b = bb.get("pct_b")
+ if pct_b is not None:
+ if pct_b < 0.2: puntos += 0.5
+ elif pct_b > 0.8: puntos -= 0.5
+
+ vol_ratio = res.get("volumen", {}).get("ratio")
+ if vol_ratio and vol_ratio > 1.5:
+ tend = res.get("tendencia", {}).get("puntos", 0)
+ if tend > 0: puntos += 0.5
+ else: puntos -= 0.5
+
+ score = round(min(max(puntos, 0), 10), 1)
+
+ if score >= 7: señal = "SEÑAL TÉCNICA ALCISTA"; color = "#00c851"
+ elif score >= 5: señal = "Señal técnica neutral"; color = "#ffc107"
+ else: señal = "SEÑAL TÉCNICA BAJISTA"; color = "#ff4444"
+
+ return {"score": score, "señal": señal, "color": color}
+
+
+# ── Retornos históricos ───────────────────────────────────────────────────────
+
+def retornos_historicos(df: pd.DataFrame) -> dict:
+ """Calcula retornos para diferentes horizontes."""
+ if df is None or df.empty:
+ return {}
+
+ close = df["Close"].dropna()
+ precio_actual = float(close.iloc[-1])
+
+ def ret(dias):
+ if len(close) > dias:
+ p_ant = float(close.iloc[-dias - 1])
+ return round((precio_actual / p_ant - 1) * 100, 2) if p_ant else None
+ return None
+
+ # Volatilidad anualizada
+ diarios = close.pct_change().dropna()
+ vol_anual = round(float(diarios.std() * np.sqrt(252) * 100), 2) if len(diarios) > 1 else None
+
+ # Máximo drawdown (1 año)
+ ultimos_252 = close.tail(252)
+ if len(ultimos_252) > 1:
+ peak = ultimos_252.cummax()
+ dd = (ultimos_252 - peak) / peak
+ max_dd = round(float(dd.min()) * 100, 2)
+ else:
+ max_dd = None
+
+ return {
+ "1d": ret(1),
+ "5d": ret(5),
+ "1m": ret(21),
+ "3m": ret(63),
+ "6m": ret(126),
+ "1y": ret(252),
+ "ytd": _retorno_ytd(close),
+ "vol_anual_pct": vol_anual,
+ "max_drawdown_pct": max_dd,
+ }
+
+
+def _retorno_ytd(close: pd.Series) -> Optional[float]:
+ """Retorno desde inicio del año calendario."""
+ try:
+ año_actual = close.index[-1].year
+ inicio_año = close[close.index.year == año_actual].iloc[0]
+ return round((float(close.iloc[-1]) / float(inicio_año) - 1) * 100, 2)
+ except Exception:
+ return None
diff --git a/core/analysis/valuation.py b/core/analysis/valuation.py
new file mode 100644
index 0000000..cd203ab
--- /dev/null
+++ b/core/analysis/valuation.py
@@ -0,0 +1,398 @@
+"""
+InvestSkill — Motor de Valoración
+DCF con escenarios, múltiplos comparables, margen de seguridad.
+Todos los cálculos usan datos reales del FetcherMercado.
+"""
+import numpy as np
+from typing import Optional
+from config import (
+ TASA_LIBRE_RIESGO, PRIMA_RIESGO_MERCADO,
+ TASA_IMPUESTO, TASA_CRECIMIENTO_TERMINAL, WACC_DEFECTO
+)
+
+
+# ── DCF — Flujo de Caja Descontado ────────────────────────────────────────────
+
+def calcular_dcf(fundamentales: dict, piotroski_raw: dict,
+ crec_fase1: float = 0.10, crec_fase2: float = 0.04,
+ margen_op_objetivo: float = None,
+ wacc_override: float = None,
+ g_terminal: float = TASA_CRECIMIENTO_TERMINAL) -> dict:
+ """
+ DCF en 3 escenarios (alcista / base / bajista) con datos reales.
+
+ Args:
+ fundamentales : dict del FetcherMercado.fundamentales()
+ piotroski_raw : dict del FetcherMercado.datos_piotroski()
+ crec_fase1 : tasa crecimiento años 1-5 (caso base)
+ crec_fase2 : tasa crecimiento años 6-10 (caso base)
+ margen_op_objetivo: margen operativo objetivo (usa real si None)
+ wacc_override : WACC personalizado (calcula automáticamente si None)
+ g_terminal : tasa crecimiento perpetuo (defecto 2.5%)
+ """
+ # ── Datos base ────────────────────────────────────────────────────────────
+ fcf = fundamentales.get("fcf") or 0
+ ocf = fundamentales.get("ocf") or 0
+ capex = fundamentales.get("capex") or 0
+ acciones = fundamentales.get("acciones") or 1
+ deuda_neta = fundamentales.get("deuda_neta") or 0
+ beta = fundamentales.get("beta") or 1.2
+ precio = fundamentales.get("precio") or 0
+ cap_mercado = fundamentales.get("cap_mercado") or 0
+
+ # FCF base: preferir FCF directo, sino OCF - CapEx
+ fcf_base = fcf if fcf and fcf != 0 else (ocf - abs(capex)) if ocf else 0
+
+ if fcf_base == 0:
+ return {"error": "FCF insuficiente para calcular DCF — empresa sin historial de flujos"}
+
+ # ── WACC ─────────────────────────────────────────────────────────────────
+ if wacc_override:
+ wacc = wacc_override
+ else:
+ ke = TASA_LIBRE_RIESGO + beta * PRIMA_RIESGO_MERCADO
+ deuda_total = fundamentales.get("deuda_total") or 0
+ valor_total = cap_mercado + deuda_total if cap_mercado else 1
+ peso_e = cap_mercado / valor_total if valor_total else 0.8
+ peso_d = 1 - peso_e
+ kd = 0.06 * (1 - TASA_IMPUESTO)
+ wacc = peso_e * ke + peso_d * kd
+
+ # ── Escenarios ────────────────────────────────────────────────────────────
+ escenarios = {
+ "alcista": {
+ "crec_f1": crec_fase1 * 1.5,
+ "crec_f2": crec_fase2 * 1.3,
+ "wacc": wacc - 0.01,
+ "g": g_terminal + 0.005,
+ "prob": 0.25,
+ },
+ "base": {
+ "crec_f1": crec_fase1,
+ "crec_f2": crec_fase2,
+ "wacc": wacc,
+ "g": g_terminal,
+ "prob": 0.55,
+ },
+ "bajista": {
+ "crec_f1": crec_fase1 * 0.4,
+ "crec_f2": crec_fase2 * 0.5,
+ "wacc": wacc + 0.02,
+ "g": g_terminal - 0.01,
+ "prob": 0.20,
+ },
+ }
+
+ resultados = {}
+
+ for nombre, params in escenarios.items():
+ cf1 = params["crec_f1"]
+ cf2 = params["crec_f2"]
+ w = params["wacc"]
+ g = params["g"]
+
+ if w <= g:
+ g = w - 0.01 # evitar división por cero
+
+ # Proyectar 10 años de FCF
+ fcfs_proyectados = []
+ fcf_t = fcf_base
+ for t in range(1, 11):
+ tasa = cf1 if t <= 5 else cf2
+ fcf_t = fcf_t * (1 + tasa)
+ vp = fcf_t / (1 + w) ** t
+ fcfs_proyectados.append({"año": t, "fcf": round(fcf_t / 1e6, 2), "vp": round(vp / 1e6, 2)})
+
+ # Valor terminal (Gordon Growth)
+ fcf_year10 = fcf_t
+ valor_terminal = fcf_year10 * (1 + g) / (w - g)
+ vp_terminal = valor_terminal / (1 + w) ** 10
+
+ # Enterprise Value
+ suma_vp_fcfs = sum(p["vp"] for p in fcfs_proyectados) * 1e6
+ ev = suma_vp_fcfs + vp_terminal
+
+ # Equity Value
+ equity_value = ev - deuda_neta
+ valor_por_accion = equity_value / acciones if acciones > 0 else 0
+
+ # Margen de seguridad
+ if precio and precio > 0 and valor_por_accion > 0:
+ margen_seg = (valor_por_accion - precio) / valor_por_accion * 100
+ else:
+ margen_seg = None
+
+ # % valor terminal sobre EV total
+ pct_terminal = vp_terminal / ev * 100 if ev > 0 else None
+
+ resultados[nombre] = {
+ "crec_fase1_pct": round(cf1 * 100, 1),
+ "crec_fase2_pct": round(cf2 * 100, 1),
+ "wacc_pct": round(w * 100, 2),
+ "g_terminal_pct": round(g * 100, 2),
+ "fcfs_proyectados": fcfs_proyectados,
+ "suma_vp_fcfs_mm": round(suma_vp_fcfs / 1e6, 1),
+ "valor_terminal_mm": round(valor_terminal / 1e6, 1),
+ "vp_terminal_mm": round(vp_terminal / 1e6, 1),
+ "ev_mm": round(ev / 1e6, 1),
+ "equity_value_mm": round(equity_value / 1e6, 1),
+ "valor_intrinseco": round(valor_por_accion, 2),
+ "precio_mercado": precio,
+ "margen_seguridad_pct": round(margen_seg, 1) if margen_seg is not None else None,
+ "pct_valor_terminal": round(pct_terminal, 1) if pct_terminal else None,
+ "probabilidad": params["prob"],
+ }
+
+ # ── Precio ponderado por probabilidad ────────────────────────────────────
+ precio_ponderado = sum(
+ resultados[e]["valor_intrinseco"] * escenarios[e]["prob"]
+ for e in escenarios
+ if resultados[e]["valor_intrinseco"]
+ )
+
+ margen_seg_base = resultados["base"]["margen_seguridad_pct"]
+
+ if margen_seg_base is not None:
+ if margen_seg_base > 30:
+ señal = "SIGNIFICATIVAMENTE INFRAVALORADO — margen de seguridad > 30%"
+ color = "#00c851"
+ elif margen_seg_base > 10:
+ señal = "Moderadamente infravalorado — margen de seguridad 10–30%"
+ color = "#80d9a0"
+ elif margen_seg_base > -10:
+ señal = "Valoración justa — cotiza cerca del valor intrínseco"
+ color = "#ffc107"
+ else:
+ señal = "SOBREVALORADO — cotiza por encima del valor intrínseco"
+ color = "#ff4444"
+ else:
+ señal = "Sin datos suficientes"
+ color = "#aaaaaa"
+
+ return {
+ "error": None,
+ "fcf_base_mm": round(fcf_base / 1e6, 2),
+ "acciones_mm": round(acciones / 1e6, 2),
+ "deuda_neta_mm": round(deuda_neta / 1e6, 2),
+ "wacc_base_pct": round(wacc * 100, 2),
+ "escenarios": resultados,
+ "precio_ponderado": round(precio_ponderado, 2),
+ "precio_mercado": precio,
+ "señal": señal,
+ "color": color,
+ }
+
+
+# ── Tabla de sensibilidad DCF ─────────────────────────────────────────────────
+
+def tabla_sensibilidad(fundamentales: dict, piotroski_raw: dict,
+ waccs: list = None, tasas_g: list = None) -> dict:
+ """
+ Genera una tabla de sensibilidad: valor intrínseco para combinaciones de WACC y g.
+ """
+ if waccs is None:
+ waccs = [0.07, 0.08, 0.09, 0.10, 0.11, 0.12]
+ if tasas_g is None:
+ tasas_g = [0.015, 0.020, 0.025, 0.030, 0.035]
+
+ tabla = {}
+ for w in waccs:
+ fila = {}
+ for g in tasas_g:
+ dcf = calcular_dcf(fundamentales, piotroski_raw,
+ wacc_override=w, g_terminal=g)
+ if not dcf.get("error"):
+ vi = dcf["escenarios"]["base"]["valor_intrinseco"]
+ fila[f"{g*100:.1f}%"] = vi
+ else:
+ fila[f"{g*100:.1f}%"] = None
+ tabla[f"{w*100:.0f}%"] = fila
+
+ return tabla
+
+
+# ── Valoración por múltiplos ──────────────────────────────────────────────────
+
+def valoracion_multiples(fundamentales: dict) -> dict:
+ """
+ Estima valor intrínseco usando múltiplos de mercado:
+ P/E, EV/EBITDA, P/S, P/FCF.
+ """
+ precio = fundamentales.get("precio") or 0
+ eps_ttm = None
+ margen_n = fundamentales.get("margen_neto")
+ pe_ttm = fundamentales.get("pe_ttm")
+ pe_fwd = fundamentales.get("pe_forward")
+ ev_ebitda = fundamentales.get("ev_ebitda")
+ ps = fundamentales.get("precio_ventas")
+ cap = fundamentales.get("cap_mercado") or 0
+ fcf = fundamentales.get("fcf") or 0
+ acciones = fundamentales.get("acciones") or 1
+
+ resultados = {}
+
+ # Múltiplo P/E histórico (15x = media histórica S&P 500)
+ if pe_ttm and precio:
+ eps_ttm = precio / pe_ttm
+ pe_hist = 17.0 # promedio histórico S&P 500
+ vi_pe = eps_ttm * pe_hist
+ resultados["PE_historico"] = {
+ "metodo": "P/E Histórico S&P 500 (17x)",
+ "valor": round(vi_pe, 2),
+ "multiplo": pe_hist,
+ "base": round(eps_ttm, 4),
+ "unidad_base":"EPS TTM",
+ }
+
+ # Múltiplo P/E sector (usa forward PE si disponible)
+ if pe_fwd and precio:
+ eps_fwd = precio / pe_fwd
+ pe_sect = 20.0 # estimado sector tecnología / promedio
+ vi_pe_fwd = eps_fwd * pe_sect
+ resultados["PE_forward"] = {
+ "metodo": "P/E Forward Sector (20x)",
+ "valor": round(vi_pe_fwd, 2),
+ "multiplo": pe_sect,
+ "base": round(eps_fwd, 4),
+ "unidad_base":"EPS Forward",
+ }
+
+ # EV/EBITDA
+ if ev_ebitda and cap > 0 and acciones > 0:
+ ebitda = cap / (ps or 1) # aproximación si no hay EBITDA directo
+ ev = fundamentales.get("ev") or 0
+ if ev and ev_ebitda:
+ ebitda_real = ev / ev_ebitda
+ ev_objetivo = ebitda_real * 12 # 12x = múltiplo neutral
+ deuda_neta = fundamentales.get("deuda_neta") or 0
+ equity_obj = ev_objetivo - deuda_neta
+ vi_ev_ebitda = equity_obj / acciones
+ resultados["EV_EBITDA"] = {
+ "metodo": "EV/EBITDA Neutral (12x)",
+ "valor": round(vi_ev_ebitda, 2),
+ "multiplo": 12,
+ "base": round(ebitda_real / 1e6, 1),
+ "unidad_base":"EBITDA (MM USD)",
+ }
+
+ # P/FCF
+ if fcf and acciones > 0:
+ fcf_por_accion = fcf / acciones
+ pfcf_neutral = 20.0 # 20x FCF = valoración neutral
+ vi_pfcf = fcf_por_accion * pfcf_neutral
+ resultados["P_FCF"] = {
+ "metodo": "P/FCF Neutral (20x)",
+ "valor": round(vi_pfcf, 2),
+ "multiplo": pfcf_neutral,
+ "base": round(fcf_por_accion, 4),
+ "unidad_base":"FCF por acción",
+ }
+
+ # ── Precio promedio de métodos disponibles ──
+ valores = [v["valor"] for v in resultados.values() if v["valor"] and v["valor"] > 0]
+ promedio = round(sum(valores) / len(valores), 2) if valores else None
+ upside = round((promedio / precio - 1) * 100, 1) if (promedio and precio) else None
+
+ return {
+ "metodos": resultados,
+ "precio_promedio": promedio,
+ "precio_mercado": precio,
+ "upside_pct": upside,
+ "señal": (
+ "INFRAVALORADO" if (upside and upside > 15) else
+ "SOBREVALORADO" if (upside and upside < -15) else
+ "VALORACIÓN JUSTA" if upside is not None else "Sin datos"
+ ),
+ }
+
+
+# ── Regla de Chowder (dividendos) ────────────────────────────────────────────
+
+def regla_chowder(fundamentales: dict) -> Optional[dict]:
+ """
+ Chowder Rule: Dividend Yield + Dividend Growth Rate >= 12% (acciones valor)
+ o >= 8% (acciones crecimiento con yield < 3%).
+ """
+ div_yield = fundamentales.get("div_yield") or 0
+ crec_gan = fundamentales.get("crec_ganancias") or 0
+
+ if div_yield == 0:
+ return None
+
+ chowder_num = div_yield + crec_gan
+ umbral = 8.0 if div_yield < 3 else 12.0
+
+ return {
+ "div_yield_pct": div_yield,
+ "crec_estimado_pct": crec_gan,
+ "chowder_number": round(chowder_num, 2),
+ "umbral": umbral,
+ "pasa": chowder_num >= umbral,
+ "señal": f"{'PASA' if chowder_num >= umbral else 'FALLA'} — Chowder Number {chowder_num:.1f}% vs umbral {umbral}%",
+ }
+
+
+# ── Score de valoración global ────────────────────────────────────────────────
+
+def score_valoracion(dcf_result: dict, multiples_result: dict, fundamentales: dict) -> dict:
+ """
+ Genera un score de valoración global 0-10 combinando DCF y múltiplos.
+ """
+ puntos = 0
+ max_pts = 10
+ detalles = []
+
+ # DCF base (4 puntos)
+ if not dcf_result.get("error"):
+ mg = dcf_result["escenarios"]["base"].get("margen_seguridad_pct")
+ if mg is not None:
+ if mg > 40: pts = 4; desc = "DCF: gran descuento (+40%)"
+ elif mg > 20: pts = 3; desc = "DCF: buen descuento (20-40%)"
+ elif mg > 0: pts = 2; desc = "DCF: ligero descuento (0-20%)"
+ elif mg > -20:pts = 1; desc = "DCF: ligera prima (0-20%)"
+ else: pts = 0; desc = "DCF: prima elevada (>20%)"
+ puntos += pts
+ detalles.append(desc)
+
+ # Múltiplos (3 puntos)
+ upside = multiples_result.get("upside_pct")
+ if upside is not None:
+ if upside > 30: pts = 3; desc = "Múltiplos: muy atractivos"
+ elif upside > 10: pts = 2; desc = "Múltiplos: atractivos"
+ elif upside > -10:pts = 1; desc = "Múltiplos: neutral"
+ else: pts = 0; desc = "Múltiplos: caros"
+ puntos += pts
+ detalles.append(desc)
+
+ # FCF yield (2 puntos)
+ fcf_yield = fundamentales.get("fcf_yield")
+ if fcf_yield is not None:
+ if fcf_yield > 6: pts = 2; desc = f"FCF yield {fcf_yield:.1f}% — excelente"
+ elif fcf_yield > 3: pts = 1; desc = f"FCF yield {fcf_yield:.1f}% — aceptable"
+ else: pts = 0; desc = f"FCF yield {fcf_yield:.1f}% — bajo"
+ puntos += pts
+ detalles.append(desc)
+
+ # PEG (1 punto)
+ peg = fundamentales.get("peg")
+ if peg is not None:
+ if peg < 1: pts = 1; desc = f"PEG {peg:.2f} — infravalorado vs crecimiento"
+ else: pts = 0; desc = f"PEG {peg:.2f} — caro vs crecimiento"
+ puntos += pts
+ detalles.append(desc)
+
+ score = round(min(puntos / max_pts * 10, 10), 1)
+
+ if score >= 7:
+ señal = "COMPRAR"; color = "#00c851"
+ elif score >= 5:
+ señal = "MANTENER/VIGILAR"; color = "#ffc107"
+ else:
+ señal = "CAUTELA/VENDER"; color = "#ff4444"
+
+ return {
+ "score": score,
+ "señal": señal,
+ "color": color,
+ "detalles": detalles,
+ }
diff --git a/core/data/__init__.py b/core/data/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/data/fetcher.py b/core/data/fetcher.py
new file mode 100644
index 0000000..3284b86
--- /dev/null
+++ b/core/data/fetcher.py
@@ -0,0 +1,656 @@
+"""
+InvestSkill — Motor universal de datos en tiempo real
+Fuente principal: yfinance (Yahoo Finance) — gratuito, sin API key
+Soporta: acciones USA, mercados LatAm, criptomonedas, índices
+"""
+import yfinance as yf
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta
+from typing import Optional
+import warnings
+warnings.filterwarnings("ignore")
+
+
+# ── Helpers de símbolo ───────────────────────────────────────────────────────
+
+def normalizar_ticker(ticker: str, mercado: str = "us") -> str:
+ """Normaliza un ticker según el mercado seleccionado."""
+ t = ticker.strip().upper()
+ sufijos = {
+ "mx": ".MX", "br": ".SA", "ar": ".BA",
+ "co": ".CL", "cl": ".SN",
+ }
+ if mercado in sufijos and not t.endswith(sufijos[mercado]):
+ return t + sufijos[mercado]
+ if mercado == "crypto" and not t.endswith("-USD"):
+ return t + "-USD"
+ return t
+
+
+def detectar_tipo_activo(ticker: str) -> str:
+ """Detecta si el ticker es acción, crypto, ETF, índice o forex."""
+ t = ticker.upper()
+ if t.startswith("^"):
+ return "indice"
+ if t.endswith("-USD") or t in ("BTC-USD","ETH-USD","SOL-USD","ADA-USD","BNB-USD"):
+ return "crypto"
+ if t.endswith("=F"):
+ return "futuro"
+ if t.endswith("=X"):
+ return "forex"
+ return "accion"
+
+
+# ── Clase principal ──────────────────────────────────────────────────────────
+
+class FetcherMercado:
+ """
+ Motor central de datos de mercado.
+ Todos los métodos retornan diccionarios listos para consumir por la UI y el motor de análisis.
+ """
+
+ def __init__(self, ticker: str, mercado: str = "us"):
+ self.ticker_raw = ticker.strip().upper()
+ self.mercado = mercado
+ self.ticker = normalizar_ticker(self.ticker_raw, mercado)
+ self.tipo = detectar_tipo_activo(self.ticker)
+ self._yf = yf.Ticker(self.ticker)
+ self._info = None
+ self._precio_hist = None
+
+ # ── Propiedades base ─────────────────────────────────────────────────────
+
+ @property
+ def info(self) -> dict:
+ if self._info is None:
+ try:
+ self._info = self._yf.info or {}
+ except Exception:
+ self._info = {}
+ return self._info
+
+ def precio_historico(self, periodo: str = "1y", intervalo: str = "1d") -> pd.DataFrame:
+ """Devuelve OHLCV histórico. Caché por sesión."""
+ clave = f"{periodo}_{intervalo}"
+ if self._precio_hist is None or clave not in getattr(self, "_cache_periodos", {}):
+ if not hasattr(self, "_cache_periodos"):
+ self._cache_periodos = {}
+ try:
+ df = self._yf.history(period=periodo, interval=intervalo, auto_adjust=True)
+ df.index = pd.to_datetime(df.index)
+ if df.index.tz is not None:
+ df.index = df.index.tz_localize(None)
+ self._cache_periodos[clave] = df
+ except Exception:
+ self._cache_periodos[clave] = pd.DataFrame()
+ return self._cache_periodos[clave]
+
+ # ── Precio actual ────────────────────────────────────────────────────────
+
+ def precio_actual(self) -> dict:
+ """Precio en tiempo real (retrasado ~15 min en Yahoo Finance gratuito)."""
+ info = self.info
+ hist = self.precio_historico("5d", "1d")
+
+ precio = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose", 0)
+ apertura = info.get("open") or info.get("regularMarketOpen", precio)
+ maximo = info.get("dayHigh") or info.get("regularMarketDayHigh", precio)
+ minimo = info.get("dayLow") or info.get("regularMarketDayLow", precio)
+ volumen = info.get("volume") or info.get("regularMarketVolume", 0)
+ cierre_anterior = info.get("previousClose") or info.get("regularMarketPreviousClose", precio)
+
+ # Calcular variación diaria
+ cambio = precio - cierre_anterior if cierre_anterior else 0
+ cambio_pct = (cambio / cierre_anterior * 100) if cierre_anterior else 0
+
+ # 52 semanas
+ max_52s = info.get("fiftyTwoWeekHigh", 0)
+ min_52s = info.get("fiftyTwoWeekLow", 0)
+
+ # Precio actual como % del rango 52 semanas
+ rango_52s_pct = None
+ if max_52s and min_52s and max_52s != min_52s:
+ rango_52s_pct = round((precio - min_52s) / (max_52s - min_52s) * 100, 1)
+
+ # Medias móviles rápidas
+ sma50 = sma200 = None
+ if not hist.empty and len(hist) >= 5:
+ if len(hist) >= 50:
+ sma50 = round(hist["Close"].rolling(50).mean().iloc[-1], 2)
+ if len(hist) >= 200:
+ sma200 = round(hist["Close"].rolling(200).mean().iloc[-1], 2)
+
+ return {
+ "ticker": self.ticker,
+ "nombre": info.get("longName") or info.get("shortName", self.ticker),
+ "precio": round(float(precio), 4) if precio else None,
+ "cambio": round(float(cambio), 4),
+ "cambio_pct": round(float(cambio_pct), 2),
+ "apertura": round(float(apertura), 4) if apertura else None,
+ "maximo_dia": round(float(maximo), 4) if maximo else None,
+ "minimo_dia": round(float(minimo), 4) if minimo else None,
+ "volumen": int(volumen) if volumen else None,
+ "vol_promedio": info.get("averageVolume"),
+ "max_52s": round(float(max_52s), 4) if max_52s else None,
+ "min_52s": round(float(min_52s), 4) if min_52s else None,
+ "rango_52s_pct": rango_52s_pct,
+ "cap_mercado": info.get("marketCap"),
+ "moneda": info.get("currency", "USD"),
+ "intercambio": info.get("exchange", ""),
+ "sector": info.get("sector", ""),
+ "industria": info.get("industry", ""),
+ "pais": info.get("country", ""),
+ "empleados": info.get("fullTimeEmployees"),
+ "sma50": sma50,
+ "sma200": sma200,
+ "tipo": self.tipo,
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ }
+
+ # ── Datos fundamentales ──────────────────────────────────────────────────
+
+ def fundamentales(self) -> dict:
+ """Métricas de valoración y rentabilidad desde yfinance."""
+ info = self.info
+
+ def s(key, default=None):
+ v = info.get(key)
+ if v is None or (isinstance(v, float) and np.isnan(v)):
+ return default
+ return v
+
+ # Múltiplos de valoración
+ pe_ttm = s("trailingPE")
+ pe_fwd = s("forwardPE")
+ peg = s("pegRatio")
+ pb = s("priceToBook")
+ ps_ttm = s("priceToSalesTrailing12Months")
+ ev_ebitda = s("enterpriseToEbitda")
+ ev_revenue = s("enterpriseToRevenue")
+
+ # Rentabilidad
+ roe = s("returnOnEquity")
+ roa = s("returnOnAssets")
+ margen_bruto = s("grossMargins")
+ margen_op = s("operatingMargins")
+ margen_neto = s("profitMargins")
+ margen_ebitda = s("ebitdaMargins")
+
+ # Crecimiento
+ crec_rev_trim = s("revenueGrowth")
+ crec_ganancias = s("earningsGrowth")
+ crec_rev_5y = s("revenueQuarterlyGrowth")
+
+ # Balance
+ deuda_equity = s("debtToEquity")
+ ratio_corriente = s("currentRatio")
+ ratio_rapido = s("quickRatio")
+ caja = s("totalCash")
+ caja_por_accion = s("totalCashPerShare")
+ deuda_total = s("totalDebt")
+
+ # Flujos de caja
+ fcf = s("freeCashflow")
+ ocf = s("operatingCashflow")
+ capex = (ocf - fcf) if (ocf and fcf and ocf > 0) else None
+
+ # Dividendo
+ div_yield = s("dividendYield")
+ div_rate = s("dividendRate")
+ payout_ratio = s("payoutRatio")
+ ex_div_date = s("exDividendDate")
+
+ # Acciones
+ acciones_circulacion = s("sharesOutstanding")
+ beta = s("beta")
+
+ # Precio vs. valor contable
+ precio = s("currentPrice") or s("regularMarketPrice", 0)
+ bvps = s("bookValue")
+
+ # EV y deuda neta
+ ev = s("enterpriseValue")
+ caja_v = s("totalCash", 0)
+ deuda_neta = (deuda_total - caja_v) if (deuda_total and caja_v is not None) else None
+
+ # FCF yield
+ cap_mercado = s("marketCap")
+ fcf_yield = (fcf / cap_mercado * 100) if (fcf and cap_mercado and cap_mercado > 0) else None
+
+ return {
+ # Valoración
+ "pe_ttm": round(pe_ttm, 2) if pe_ttm else None,
+ "pe_forward": round(pe_fwd, 2) if pe_fwd else None,
+ "peg": round(peg, 2) if peg else None,
+ "precio_valor_libro": round(pb, 2) if pb else None,
+ "precio_ventas": round(ps_ttm, 2) if ps_ttm else None,
+ "ev_ebitda": round(ev_ebitda, 2) if ev_ebitda else None,
+ "ev_revenue": round(ev_revenue, 2) if ev_revenue else None,
+ "ev": ev,
+ # Rentabilidad
+ "roe": round(roe * 100, 2) if roe else None,
+ "roa": round(roa * 100, 2) if roa else None,
+ "margen_bruto": round(margen_bruto * 100, 2) if margen_bruto else None,
+ "margen_operativo":round(margen_op * 100, 2) if margen_op else None,
+ "margen_neto": round(margen_neto * 100, 2) if margen_neto else None,
+ "margen_ebitda": round(margen_ebitda * 100, 2) if margen_ebitda else None,
+ # Crecimiento
+ "crec_ingresos_anual": round(crec_rev_trim * 100, 2) if crec_rev_trim else None,
+ "crec_ganancias": round(crec_ganancias * 100, 2) if crec_ganancias else None,
+ # Balance
+ "deuda_equity": round(deuda_equity, 2) if deuda_equity else None,
+ "ratio_corriente": round(ratio_corriente, 2) if ratio_corriente else None,
+ "ratio_rapido": round(ratio_rapido, 2) if ratio_rapido else None,
+ "caja_total": caja,
+ "caja_por_accion": caja_por_accion,
+ "deuda_total": deuda_total,
+ "deuda_neta": deuda_neta,
+ # Flujos
+ "fcf": fcf,
+ "ocf": ocf,
+ "capex": capex,
+ "fcf_yield": round(fcf_yield, 2) if fcf_yield else None,
+ # Dividendo
+ "div_yield": round(div_yield * 100, 2) if div_yield else None,
+ "div_rate": div_rate,
+ "payout_ratio": round(payout_ratio * 100, 2) if payout_ratio else None,
+ # Otros
+ "beta": round(beta, 3) if beta else None,
+ "acciones": acciones_circulacion,
+ "cap_mercado": cap_mercado,
+ "bvps": bvps,
+ "precio": precio,
+ }
+
+ # ── Estados financieros ──────────────────────────────────────────────────
+
+ def estados_financieros(self) -> dict:
+ """
+ Retorna los últimos 4 años de estados financieros reales:
+ cuenta de resultados, balance y flujo de caja.
+ """
+ resultado = {"ingresos": {}, "balance": {}, "flujo_caja": {}, "error": None}
+
+ try:
+ # Cuenta de resultados anual
+ income = self._yf.financials
+ if income is not None and not income.empty:
+ income = income.iloc[:, :4] # últimas 4 columnas (años)
+ años = [str(c.year) for c in income.columns]
+
+ def fila(key):
+ """Extrae una fila del dataframe de forma segura."""
+ for k in income.index:
+ if key.lower() in str(k).lower():
+ row = income.loc[k]
+ return {años[i]: _fmt_millon(row.iloc[i]) for i in range(len(años))}
+ return {a: None for a in años}
+
+ resultado["ingresos"] = {
+ "años": años,
+ "ingresos_totales": fila("Total Revenue"),
+ "beneficio_bruto": fila("Gross Profit"),
+ "ebitda": fila("EBITDA"),
+ "ebit": fila("EBIT"),
+ "beneficio_neto": fila("Net Income"),
+ "eps_basico": fila("Basic EPS"),
+ "eps_diluido": fila("Diluted EPS"),
+ "gastos_ida": fila("Selling General Administrative"),
+ "i_d": fila("Research And Development"),
+ }
+
+ except Exception as e:
+ resultado["error"] = str(e)
+
+ try:
+ # Balance general anual
+ balance = self._yf.balance_sheet
+ if balance is not None and not balance.empty:
+ balance = balance.iloc[:, :4]
+ años_b = [str(c.year) for c in balance.columns]
+
+ def fila_b(key):
+ for k in balance.index:
+ if key.lower() in str(k).lower():
+ row = balance.loc[k]
+ return {años_b[i]: _fmt_millon(row.iloc[i]) for i in range(len(años_b))}
+ return {a: None for a in años_b}
+
+ resultado["balance"] = {
+ "años": años_b,
+ "activos_totales": fila_b("Total Assets"),
+ "activos_corrientes": fila_b("Current Assets"),
+ "caja": fila_b("Cash And Cash Equivalents"),
+ "inventario": fila_b("Inventory"),
+ "pasivos_totales": fila_b("Total Liabilities"),
+ "pasivos_corrientes": fila_b("Current Liabilities"),
+ "deuda_largo_plazo": fila_b("Long Term Debt"),
+ "patrimonio": fila_b("Stockholders Equity"),
+ "acciones_emitidas": fila_b("Ordinary Shares Number"),
+ }
+
+ except Exception:
+ pass
+
+ try:
+ # Flujo de caja anual
+ cashflow = self._yf.cashflow
+ if cashflow is not None and not cashflow.empty:
+ cashflow = cashflow.iloc[:, :4]
+ años_c = [str(c.year) for c in cashflow.columns]
+
+ def fila_c(key):
+ for k in cashflow.index:
+ if key.lower() in str(k).lower():
+ row = cashflow.loc[k]
+ return {años_c[i]: _fmt_millon(row.iloc[i]) for i in range(len(años_c))}
+ return {a: None for a in años_c}
+
+ resultado["flujo_caja"] = {
+ "años": años_c,
+ "flujo_operativo": fila_c("Operating Cash Flow"),
+ "capex": fila_c("Capital Expenditure"),
+ "flujo_libre": fila_c("Free Cash Flow"),
+ "dividendos_pagados": fila_c("Payment Of Dividends"),
+ "recompra_acciones": fila_c("Repurchase Of Capital Stock"),
+ "deuda_emitida": fila_c("Issuance Of Debt"),
+ "deuda_repagada": fila_c("Repayment Of Debt"),
+ }
+
+ except Exception:
+ pass
+
+ return resultado
+
+ # ── Datos para Piotroski F-Score ─────────────────────────────────────────
+
+ def datos_piotroski(self) -> dict:
+ """
+ Extrae las métricas necesarias para calcular el Piotroski F-Score
+ usando datos anuales reales de los últimos 2 años.
+ """
+ try:
+ income = self._yf.financials
+ balance = self._yf.balance_sheet
+ cashflow = self._yf.cashflow
+
+ if income is None or balance is None or cashflow is None:
+ return {"error": "Estados financieros no disponibles"}
+ if income.empty or balance.empty or cashflow.empty:
+ return {"error": "Estados financieros vacíos"}
+
+ def get_val(df, keyword, col=0):
+ for k in df.index:
+ if keyword.lower() in str(k).lower():
+ v = df.iloc[df.index.tolist().index(k), col]
+ return float(v) if pd.notna(v) else None
+ return None
+
+ # Año actual (col=0) y año anterior (col=1)
+ net_income_0 = get_val(income, "Net Income", 0)
+ net_income_1 = get_val(income, "Net Income", 1)
+
+ total_assets_0 = get_val(balance, "Total Assets", 0)
+ total_assets_1 = get_val(balance, "Total Assets", 1)
+
+ ocf_0 = get_val(cashflow, "Operating Cash Flow", 0)
+
+ long_debt_0 = get_val(balance, "Long Term Debt", 0) or 0
+ long_debt_1 = get_val(balance, "Long Term Debt", 1) or 0
+
+ curr_assets_0 = get_val(balance, "Current Assets", 0)
+ curr_liab_0 = get_val(balance, "Current Liabilities", 0)
+ curr_assets_1 = get_val(balance, "Current Assets", 1)
+ curr_liab_1 = get_val(balance, "Current Liabilities", 1)
+
+ shares_0 = get_val(balance, "Ordinary Shares Number", 0) or 0
+ shares_1 = get_val(balance, "Ordinary Shares Number", 1) or 0
+
+ gross_profit_0 = get_val(income, "Gross Profit", 0)
+ revenue_0 = get_val(income, "Total Revenue", 0)
+ gross_profit_1 = get_val(income, "Gross Profit", 1)
+ revenue_1 = get_val(income, "Total Revenue", 1)
+
+ return {
+ "net_income_0": net_income_0,
+ "net_income_1": net_income_1,
+ "total_assets_0": total_assets_0,
+ "total_assets_1": total_assets_1,
+ "ocf_0": ocf_0,
+ "long_debt_0": long_debt_0,
+ "long_debt_1": long_debt_1,
+ "curr_assets_0": curr_assets_0,
+ "curr_liab_0": curr_liab_0,
+ "curr_assets_1": curr_assets_1,
+ "curr_liab_1": curr_liab_1,
+ "shares_0": shares_0,
+ "shares_1": shares_1,
+ "gross_profit_0": gross_profit_0,
+ "revenue_0": revenue_0,
+ "gross_profit_1": gross_profit_1,
+ "revenue_1": revenue_1,
+ "error": None,
+ }
+ except Exception as e:
+ return {"error": str(e)}
+
+ # ── Noticias recientes ───────────────────────────────────────────────────
+
+ def noticias(self, limite: int = 10) -> list[dict]:
+ """Noticias más recientes del activo desde Yahoo Finance."""
+ try:
+ noticias_raw = self._yf.news or []
+ resultado = []
+ for n in noticias_raw[:limite]:
+ content = n.get("content", {})
+ titulo = content.get("title", n.get("title", "Sin título"))
+ url = content.get("canonicalUrl", {}).get("url", n.get("link", ""))
+ pub = content.get("pubDate", n.get("providerPublishTime", ""))
+ fuente = content.get("provider", {}).get("displayName", "Yahoo Finance")
+ resumen= content.get("summary", "")
+
+ # Parsear fecha
+ fecha_str = ""
+ if pub:
+ try:
+ if isinstance(pub, (int, float)):
+ fecha_str = datetime.fromtimestamp(pub).strftime("%d/%m/%Y %H:%M")
+ else:
+ fecha_str = str(pub)[:16]
+ except Exception:
+ fecha_str = str(pub)[:16]
+
+ resultado.append({
+ "titulo": titulo,
+ "url": url,
+ "fecha": fecha_str,
+ "fuente": fuente,
+ "resumen": resumen,
+ })
+ return resultado
+ except Exception:
+ return []
+
+ # ── Analistas ────────────────────────────────────────────────────────────
+
+ def consenso_analistas(self) -> dict:
+ """Recomendaciones y precios objetivo de analistas."""
+ info = self.info
+
+ precio_obj_medio = info.get("targetMeanPrice")
+ precio_obj_alto = info.get("targetHighPrice")
+ precio_obj_bajo = info.get("targetLowPrice")
+ precio_actual = info.get("currentPrice") or info.get("regularMarketPrice", 0)
+
+ upside = None
+ if precio_obj_medio and precio_actual:
+ upside = round((precio_obj_medio / precio_actual - 1) * 100, 1)
+
+ recomendacion_media = info.get("recommendationMean")
+ recomendacion_clave = info.get("recommendationKey", "")
+ num_analistas = info.get("numberOfAnalystOpinions", 0)
+
+ # Mapeo de recomendación numérica a texto español
+ mapa_rec = {
+ "strong_buy": "Compra fuerte",
+ "buy": "Comprar",
+ "hold": "Mantener",
+ "underperform":"Bajo rendimiento",
+ "sell": "Vender",
+ }
+ rec_esp = mapa_rec.get(recomendacion_clave, recomendacion_clave)
+
+ try:
+ recs_df = self._yf.recommendations
+ compras = mantener = ventas = 0
+ if recs_df is not None and not recs_df.empty:
+ ultimas = recs_df.tail(5)
+ for _, fila in ultimas.iterrows():
+ g = str(fila.get("To Grade", "")).lower()
+ if "buy" in g or "outperform" in g or "overweight" in g:
+ compras += 1
+ elif "hold" in g or "neutral" in g or "equal" in g:
+ mantener += 1
+ elif "sell" in g or "underperform" in g or "underweight" in g:
+ ventas += 1
+ except Exception:
+ compras = mantener = ventas = 0
+
+ return {
+ "precio_objetivo_medio": precio_obj_medio,
+ "precio_objetivo_alto": precio_obj_alto,
+ "precio_objetivo_bajo": precio_obj_bajo,
+ "upside_potencial_pct": upside,
+ "recomendacion": rec_esp,
+ "recomendacion_score": recomendacion_media,
+ "num_analistas": num_analistas,
+ "compras_recientes": compras,
+ "mantener_recientes": mantener,
+ "ventas_recientes": ventas,
+ }
+
+ # ── Datos de opciones ────────────────────────────────────────────────────
+
+ def opciones_resumen(self) -> dict:
+ """Resumen del mercado de opciones: IV implícita, put/call ratio."""
+ try:
+ fechas = self._yf.options
+ if not fechas:
+ return {"disponible": False}
+
+ # Usar la cadena más próxima
+ chain = self._yf.option_chain(fechas[0])
+ calls = chain.calls
+ puts = chain.puts
+
+ total_vol_calls = calls["volume"].sum() if "volume" in calls else 0
+ total_vol_puts = puts["volume"].sum() if "volume" in puts else 0
+
+ pcr = round(total_vol_puts / total_vol_calls, 3) if total_vol_calls > 0 else None
+
+ iv_calls_media = calls["impliedVolatility"].mean() if "impliedVolatility" in calls else None
+ iv_puts_media = puts["impliedVolatility"].mean() if "impliedVolatility" in puts else None
+ iv_media = round(((iv_calls_media or 0) + (iv_puts_media or 0)) / 2 * 100, 1)
+
+ return {
+ "disponible": True,
+ "fechas_exp": list(fechas[:5]),
+ "pcr": pcr,
+ "iv_media_pct": iv_media,
+ "vol_calls": int(total_vol_calls),
+ "vol_puts": int(total_vol_puts),
+ }
+ except Exception:
+ return {"disponible": False}
+
+ # ── Comparables de sector ────────────────────────────────────────────────
+
+ def pares_sector(self) -> list[dict]:
+ """Lista de empresas comparables en el mismo sector (de yfinance info)."""
+ similares = []
+ try:
+ comps = [
+ self.info.get("symbol"),
+ *([self.info.get("industryKey")] if self.info.get("industryKey") else [])
+ ]
+ # yfinance no expone directamente peers; devolvemos info básica del ticker
+ precio = self.info.get("currentPrice") or self.info.get("regularMarketPrice")
+ pe = self.info.get("trailingPE")
+ cap = self.info.get("marketCap")
+ similares.append({
+ "ticker": self.ticker,
+ "nombre": self.info.get("longName", self.ticker),
+ "precio": precio,
+ "pe": round(pe, 1) if pe else None,
+ "cap_mercado": cap,
+ })
+ except Exception:
+ pass
+ return similares
+
+ # ── Resumen completo (para análisis de un clic) ──────────────────────────
+
+ def resumen_completo(self) -> dict:
+ """
+ Agrega todos los datos en un solo diccionario.
+ Usado por la UI y por el motor de IA para generar el informe.
+ """
+ precio = self.precio_actual()
+ fundament = self.fundamentales()
+ estados = self.estados_financieros()
+ consenso = self.consenso_analistas()
+ noticias = self.noticias(5)
+ piotroski = self.datos_piotroski()
+
+ return {
+ "ticker": self.ticker,
+ "mercado": self.mercado,
+ "tipo": self.tipo,
+ "precio": precio,
+ "fundamentales": fundament,
+ "estados": estados,
+ "consenso": consenso,
+ "noticias": noticias,
+ "piotroski_raw": piotroski,
+ "descripcion": self.info.get("longBusinessSummary", ""),
+ "sitio_web": self.info.get("website", ""),
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ }
+
+
+# ── Helpers de formato ───────────────────────────────────────────────────────
+
+def _fmt_millon(valor) -> Optional[float]:
+ """Convierte valores en unidades a millones, redondeado a 2 decimales."""
+ if valor is None or (isinstance(valor, float) and np.isnan(valor)):
+ return None
+ return round(float(valor) / 1_000_000, 2)
+
+
+def formatear_numero(n, decimales: int = 2, prefijo: str = "") -> str:
+ """Formatea números grandes de forma legible en español."""
+ if n is None:
+ return "N/D"
+ try:
+ n = float(n)
+ if abs(n) >= 1e12:
+ return f"{prefijo}{n/1e12:.{decimales}f}B" # billones
+ if abs(n) >= 1e9:
+ return f"{prefijo}{n/1e9:.{decimales}f}MM" # miles de millones
+ if abs(n) >= 1e6:
+ return f"{prefijo}{n/1e6:.{decimales}f}M" # millones
+ if abs(n) >= 1e3:
+ return f"{prefijo}{n/1e3:.{decimales}f}K"
+ return f"{prefijo}{n:.{decimales}f}"
+ except Exception:
+ return str(n)
+
+
+def color_cambio(valor: float) -> str:
+ """Retorna color CSS según si el cambio es positivo, negativo o neutro."""
+ if valor > 0:
+ return "#00c851" # verde
+ if valor < 0:
+ return "#ff4444" # rojo
+ return "#aaaaaa" # gris
diff --git a/core/data/macro.py b/core/data/macro.py
new file mode 100644
index 0000000..5f848f3
--- /dev/null
+++ b/core/data/macro.py
@@ -0,0 +1,189 @@
+"""
+InvestSkill — Datos macroeconómicos
+Fuente: FRED (Federal Reserve) via pandas_datareader — gratuito, sin API key
+"""
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta
+import requests
+import warnings
+warnings.filterwarnings("ignore")
+
+try:
+ import pandas_datareader.data as web
+ PANDAS_DATAREADER_OK = True
+except ImportError:
+ PANDAS_DATAREADER_OK = False
+
+
+# ── Indicadores FRED ─────────────────────────────────────────────────────────
+
+INDICADORES_FRED = {
+ # Crecimiento
+ "PIB_real": ("GDP", "PIB Real EE.UU. (Miles de MM USD)"),
+ "PIB_crecimiento": ("A191RL1Q225SBEA", "Crecimiento PIB (%)"),
+ # Empleo
+ "desempleo": ("UNRATE", "Tasa de Desempleo (%)"),
+ "nominas_no_agricolas": ("PAYEMS", "Nóminas No Agrícolas (miles)"),
+ "solicitudes_desempleo": ("ICSA", "Solicitudes Desempleo Iniciales"),
+ # Inflación
+ "cpi": ("CPIAUCSL", "IPC (Índice)"),
+ "cpi_anual": ("CPIAUCNS", "Inflación IPC YoY (%)"),
+ "pce": ("PCEPI", "PCE Deflactor (Índice)"),
+ "pce_central": ("PCEPILFE","PCE Subyacente (%)"),
+ # Tasas de interés
+ "fed_funds": ("FEDFUNDS","Tasa Fondos Federales (%)"),
+ "t_10y": ("DGS10", "Rendimiento Bono 10 años (%)"),
+ "t_2y": ("DGS2", "Rendimiento Bono 2 años (%)"),
+ "t_3m": ("DGS3MO", "Rendimiento Bono 3 meses (%)"),
+ "spread_10y_2y": ("T10Y2Y", "Spread 10Y-2Y (curva inversión)"),
+ "spread_10y_3m": ("T10Y3M", "Spread 10Y-3M"),
+ # Manufactura / PMI
+ "produccion_industrial": ("INDPRO", "Producción Industrial (índice)"),
+ "utilizacion_capacidad": ("TCU", "Utilización Capacidad Industrial (%)"),
+ # Vivienda
+ "permisos_construccion": ("PERMIT", "Permisos de Construcción (miles)"),
+ "ventas_casas_nuevas": ("HSN1F", "Ventas Casas Nuevas (miles)"),
+ # Consumo / Confianza
+ "ventas_minoristas": ("RSAFS", "Ventas Minoristas (MM USD)"),
+ "confianza_consumidor": ("UMCSENT","Confianza Consumidor U. Michigan"),
+ # Dinero y crédito
+ "m2": ("M2SL", "Oferta Monetaria M2 (Miles de MM)"),
+}
+
+
+def obtener_indicador_fred(codigo: str, inicio: str = "2018-01-01") -> pd.Series:
+ """Obtiene un indicador de FRED. Retorna Series vacía si falla."""
+ if not PANDAS_DATAREADER_OK:
+ return pd.Series(dtype=float)
+ try:
+ df = web.DataReader(codigo, "fred", inicio, datetime.today())
+ return df.iloc[:, 0].dropna()
+ except Exception:
+ return pd.Series(dtype=float)
+
+
+def dashboard_macro() -> dict:
+ """
+ Retorna los indicadores macroeconómicos clave con el último valor disponible.
+ """
+ resultado = {}
+ inicio = (datetime.today() - timedelta(days=365 * 3)).strftime("%Y-%m-%01")
+
+ for nombre, (codigo, descripcion) in INDICADORES_FRED.items():
+ serie = obtener_indicador_fred(codigo, inicio)
+ if not serie.empty:
+ ultimo_val = float(serie.iloc[-1])
+ anterior = float(serie.iloc[-2]) if len(serie) >= 2 else None
+ hace_1y = float(serie.iloc[-13]) if len(serie) >= 13 else None
+ cambio_mom = round(ultimo_val - anterior, 3) if anterior is not None else None
+ cambio_anual = round(ultimo_val - hace_1y, 3) if hace_1y is not None else None
+
+ resultado[nombre] = {
+ "descripcion": descripcion,
+ "codigo_fred": codigo,
+ "valor": round(ultimo_val, 3),
+ "fecha": serie.index[-1].strftime("%Y-%m-%d"),
+ "cambio_mom": cambio_mom,
+ "cambio_anual": cambio_anual,
+ "serie": serie.tail(36).to_dict(), # últimos 3 años
+ }
+ else:
+ resultado[nombre] = {
+ "descripcion": descripcion,
+ "codigo_fred": codigo,
+ "valor": None,
+ "fecha": None,
+ "cambio_mom": None,
+ "cambio_anual": None,
+ "serie": {},
+ }
+
+ return resultado
+
+
+def curva_rendimientos() -> dict:
+ """
+ Construye la curva de rendimientos del Tesoro de EE.UU.
+ Señala inversión si el spread 10Y-2Y es negativo.
+ """
+ plazos = {
+ "3M": "DGS3MO", "6M": "DGS6MO",
+ "1Y": "DGS1", "2Y": "DGS2",
+ "3Y": "DGS3", "5Y": "DGS5",
+ "7Y": "DGS7", "10Y": "DGS10",
+ "20Y": "DGS20", "30Y": "DGS30",
+ }
+
+ puntos = {}
+ for plazo, codigo in plazos.items():
+ serie = obtener_indicador_fred(codigo, "2023-01-01")
+ if not serie.empty:
+ puntos[plazo] = round(float(serie.iloc[-1]), 3)
+
+ # Detectar inversión
+ invertida = False
+ spread_10y_2y = None
+ if "10Y" in puntos and "2Y" in puntos:
+ spread_10y_2y = round(puntos["10Y"] - puntos["2Y"], 3)
+ invertida = spread_10y_2y < 0
+
+ return {
+ "puntos": puntos,
+ "spread_10y_2y": spread_10y_2y,
+ "invertida": invertida,
+ "señal": "CURVA INVERTIDA — señal histórica de recesión" if invertida else "Curva normal",
+ "fecha": datetime.today().strftime("%Y-%m-%d"),
+ }
+
+
+def condiciones_mercado() -> dict:
+ """
+ Resumen ejecutivo de las condiciones de mercado actuales
+ basado en indicadores FRED clave.
+ """
+ # Indicadores clave
+ desempleo_serie = obtener_indicador_fred("UNRATE", "2020-01-01")
+ inflacion_serie = obtener_indicador_fred("CPIAUCSL", "2018-01-01")
+ fed_funds_serie = obtener_indicador_fred("FEDFUNDS", "2020-01-01")
+ t10y_serie = obtener_indicador_fred("DGS10", "2020-01-01")
+ spread_serie = obtener_indicador_fred("T10Y2Y", "2020-01-01")
+
+ def ultimo(serie):
+ return round(float(serie.iloc[-1]), 3) if not serie.empty else None
+
+ def cambio_yoy_pct(serie):
+ if len(serie) >= 13:
+ v0 = float(serie.iloc[-13])
+ v1 = float(serie.iloc[-1])
+ if v0 != 0:
+ return round((v1 - v0) / abs(v0) * 100, 2)
+ return None
+
+ desemp = ultimo(desempleo_serie)
+ inflacion_idx = ultimo(inflacion_serie)
+ inflacion_yoy = cambio_yoy_pct(inflacion_serie)
+ fed = ultimo(fed_funds_serie)
+ t10y = ultimo(t10y_serie)
+ spread_10_2 = ultimo(spread_serie)
+
+ # Señal de régimen macro
+ if inflacion_yoy and inflacion_yoy > 4 and fed and fed > 4:
+ regimen = "Restrictivo — inflación elevada, tasas altas"
+ elif inflacion_yoy and inflacion_yoy < 2.5 and fed and fed < 3:
+ regimen = "Acomodaticio — inflación baja, tasas bajas"
+ elif spread_10_2 is not None and spread_10_2 < 0:
+ regimen = "Transición — curva invertida, posible desaceleración"
+ else:
+ regimen = "Neutral — condiciones moderadas"
+
+ return {
+ "desempleo_pct": desemp,
+ "inflacion_yoy_pct": inflacion_yoy,
+ "fed_funds_pct": fed,
+ "t10y_pct": t10y,
+ "spread_10y_2y": spread_10_2,
+ "curva_invertida": spread_10_2 < 0 if spread_10_2 is not None else False,
+ "regimen_macro": regimen,
+ "fecha": datetime.today().strftime("%Y-%m-%d"),
+ }
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..b7de520
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,43 @@
+# ============================================================
+# InvestSkill — Dependencias del sistema
+# ============================================================
+
+# Datos de mercado (gratuitos)
+yfinance>=0.2.40
+pandas>=2.0.0
+pandas-datareader>=0.10.0 # FRED macro data
+numpy>=1.24.0
+requests>=2.31.0
+beautifulsoup4>=4.12.0 # Scraping noticias / SEC
+feedparser>=6.0.10 # RSS feeds de noticias
+lxml>=4.9.0
+
+# Análisis técnico
+ta>=0.11.0 # Technical Analysis library
+scipy>=1.11.0 # Estadísticas
+
+# IA — Claude API (narrativas en español)
+anthropic>=0.25.0
+
+# Interfaz web (Streamlit)
+streamlit>=1.35.0
+plotly>=5.18.0
+altair>=5.0.0
+Pillow>=10.0.0
+openpyxl>=3.1.0 # Exportar a Excel
+kaleido>=0.2.1 # Exportar gráficos como imagen
+
+# Bot de Telegram
+python-telegram-bot>=21.0
+
+# Bot de WhatsApp (Twilio)
+twilio>=8.5.0
+flask>=3.0.0 # Webhook para WhatsApp
+gunicorn>=21.0.0
+
+# Utilidades
+python-dotenv>=1.0.0
+pytz>=2024.1
+tabulate>=0.9.0
+jinja2>=3.1.0 # Templates para reportes HTML
+pdfkit>=1.0.0 # Exportar PDF (requiere wkhtmltopdf)
diff --git a/start.sh b/start.sh
new file mode 100644
index 0000000..b502f89
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,118 @@
+#!/bin/bash
+# ============================================================
+# InvestSkill — Script de inicio
+# Uso: ./start.sh [app|telegram|whatsapp|all]
+# ============================================================
+
+set -e
+
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$PROJECT_DIR"
+
+VERDE='\033[0;32m'
+AMARILLO='\033[1;33m'
+ROJO='\033[0;31m'
+AZUL='\033[0;34m'
+NC='\033[0m'
+
+banner() {
+ echo -e "${AZUL}"
+ echo " ██╗███╗ ██╗██╗ ██╗███████╗███████╗████████╗"
+ echo " ██║████╗ ██║██║ ██║██╔════╝██╔════╝╚══██╔══╝"
+ echo " ██║██╔██╗ ██║██║ ██║█████╗ ███████╗ ██║ "
+ echo " ██║██║╚██╗██║╚██╗ ██╔╝██╔══╝ ╚════██║ ██║ "
+ echo " ██║██║ ╚████║ ╚████╔╝ ███████╗███████║ ██║ "
+ echo " ╚═╝╚═╝ ╚═══╝ ╚═══╝ ╚══════╝╚══════╝ ╚═╝ "
+ echo " ███████╗██╗ ██╗██╗██╗ ██╗ "
+ echo " ██╔════╝██║ ██╔╝██║██║ ██║ "
+ echo " ███████╗█████╔╝ ██║██║ ██║ "
+ echo " ╚════██║██╔═██╗ ██║██║ ██║ "
+ echo " ███████║██║ ██╗██║███████╗███████╗ "
+ echo " ╚══════╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝ v2.0.0 "
+ echo -e "${NC}"
+ echo -e " ${VERDE}Plataforma de Análisis de Inversiones en Tiempo Real${NC}"
+ echo ""
+}
+
+verificar_entorno() {
+ echo -e "${AMARILLO}▶ Verificando entorno...${NC}"
+
+ # Python
+ if ! command -v python3 &>/dev/null; then
+ echo -e "${ROJO}✗ Python 3 no encontrado. Instala Python 3.9+${NC}"
+ exit 1
+ fi
+ echo -e " ${VERDE}✓ Python $(python3 --version | awk '{print $2}')${NC}"
+
+ # .env
+ if [ ! -f ".env" ]; then
+ echo -e " ${AMARILLO}⚠ Archivo .env no encontrado. Copiando desde .env.example...${NC}"
+ cp .env.example .env
+ echo -e " ${AMARILLO} → Edita .env con tus claves API antes de usar funciones avanzadas${NC}"
+ else
+ echo -e " ${VERDE}✓ Archivo .env encontrado${NC}"
+ fi
+
+ # Dependencias
+ if ! python3 -c "import streamlit" &>/dev/null; then
+ echo -e " ${AMARILLO}⚠ Instalando dependencias...${NC}"
+ pip3 install -r requirements.txt -q
+ echo -e " ${VERDE}✓ Dependencias instaladas${NC}"
+ else
+ echo -e " ${VERDE}✓ Dependencias OK${NC}"
+ fi
+}
+
+iniciar_app() {
+ echo -e "\n${VERDE}▶ Iniciando aplicación web en http://localhost:8501${NC}\n"
+ streamlit run app/main.py \
+ --server.port 8501 \
+ --server.address localhost \
+ --browser.gatherUsageStats false \
+ --theme.primaryColor "#1a73e8" \
+ --theme.backgroundColor "#0e1117" \
+ --theme.secondaryBackgroundColor "#1e2530" \
+ --theme.textColor "#ffffff"
+}
+
+iniciar_telegram() {
+ echo -e "\n${VERDE}▶ Iniciando bot de Telegram...${NC}\n"
+ python3 bots/telegram_bot.py
+}
+
+iniciar_whatsapp() {
+ echo -e "\n${VERDE}▶ Iniciando webhook de WhatsApp en puerto 5000...${NC}\n"
+ python3 bots/whatsapp_bot.py
+}
+
+iniciar_todo() {
+ echo -e "\n${VERDE}▶ Iniciando todos los servicios...${NC}\n"
+ python3 bots/telegram_bot.py &
+ TELEGRAM_PID=$!
+ python3 bots/whatsapp_bot.py &
+ WHATSAPP_PID=$!
+ echo -e " ${VERDE}✓ Telegram PID: $TELEGRAM_PID${NC}"
+ echo -e " ${VERDE}✓ WhatsApp PID: $WHATSAPP_PID${NC}"
+ iniciar_app
+}
+
+# ── Menú principal ───────────────────────────────────────────
+banner
+verificar_entorno
+
+MODO="${1:-app}"
+
+case "$MODO" in
+ app) iniciar_app ;;
+ telegram) iniciar_telegram ;;
+ whatsapp) iniciar_whatsapp ;;
+ all) iniciar_todo ;;
+ *)
+ echo -e "${ROJO}Uso: ./start.sh [app|telegram|whatsapp|all]${NC}"
+ echo " app → Solo la interfaz web Streamlit (defecto)"
+ echo " telegram → Solo el bot de Telegram"
+ echo " whatsapp → Solo el webhook de WhatsApp"
+ echo " all → Todo simultáneamente"
+ exit 1
+ ;;
+esac