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