Markowitz con ML para carteras de inversión: LSTM + CAPM + Optimización

Sigue leyendo si vas a construir un pipeline de finanzas cuantitativas « tesis, trabajo o portafolio » y no te basta con copiar código: quieres entender por qué se tomó cada decisión (LSTM vs. medias históricas, mínima volatilidad vs. máximo Sharpe) para poder desarrollar tu propio proyecto.

  • 45 min de lectura
  • Python · TensorFlow · scikit-learn · yfinance · scipy
  • Datos: precios históricos reales vía Yahoo Finance API
  • Aplicable a tesis de finanzas, economía e ingeniería financiera
markowitz-ml-carteras-inversion

Parte 1 de 3

Contexto del proyecto

Al terminar, sabrás hacer esto

No son conceptos abstractos. Son decisiones que podrás tomar y justificar en una tesis, en una entrevista o en un proyecto real.

  • Pipeline de punta a punta: construyes un flujo completo de finanzas cuantitativas que integra Deep Learning, teoría financiera y optimización numérica, con datos reales de 10 acciones de 2020 a 2024 vía Yahoo Finance.
  • Evaluación sin fugas: diseñas un backtest walk-forward con purga y embargo, en el que ningún modelo ni portafolio ve un solo día posterior a la fecha en que tendría que decidir.
  • Ingeniería de features: calculas indicadores técnicos desde cero con pandas «SMA, Bandas de Bollinger, RSI y autocorrelación» en forma relativa al precio, para que ninguno crezca sin límite.
  • Comparación honesta de modelos: pones a competir LSTM, GRU, Ridge y Gradient Boosting contra la media histórica y el paseo aleatorio, con R² fuera de muestra y coeficiente de información.
  • Selección con CAPM: dejas pasar al optimizador solo las acciones cuyo pronóstico supera lo que su riesgo de mercado exige.
  • Optimización con Markowitz: estimas la covarianza con Ledoit-Wolf, comparas mínima volatilidad contra máximo Sharpe y entiendes por qué el objetivo decide si tu pronóstico sirve o no.
  • Criterio para adaptarlo: identificas en qué punto del pipeline tocar el código si cambian tus datos, tu modelo o tu objetivo, y cómo saber si el cambio realmente mejoró algo.

Modos de uso

  • Tesis: replicación crítica de un pipeline LSTM + Markowitz, comparado contra la media histórica, pesos iguales y el S&P 500.
  • Trabajo: pipeline walk-forward replicable con cualquier universo de acciones.
  • Portafolio: demuestra que sabes evaluar un modelo sin fugas y contra la alternativa simple, no solo entrenarlo.

Prerrequisitos

  • Python intermedio: funciones, listas/diccionarios, y haber usado pandas o numpy alguna vez. No necesitas ser experto.
  • Nociones de estadística básica: qué es una media, una desviación estándar y una correlación. Se usa constantemente en los tres pilares.
  • Cero experiencia previa en LSTM o en finanzas cuantitativas. Eso es justo lo que cubre la siguiente sección.

Ir directo al código →

Brief del proyecto

Un portafolio de inversión es una combinación de distintos activos « acciones, bonos, fondos indexados, etc. » repartidos en ciertas proporciones.

Por ejemplo, para armar un portafolio con $100.00, podrías destinar $30.00 a acciones, $50.00 a bonos, y los $20.00 restantes a un fondo indexado.

Por regla general, un portafolio es mejor que apostarlo todo a una sola opción. Dicho de otra forma: no se trata de elegir un solo activo "ganador", sino de decidir cómo repartir tu dinero entre varios.

Ahí es donde el problema deja de ser trivial: hay que decidir qué porcentaje de tu dinero va a cada activo, y el número de formas posibles de hacer ese reparto crece mucho más rápido de lo que parece.

Un ejemplo para ilustrar el problema. Si tenemos $100.00 a repartir en 4 activos y calculamos las combinaciones posibles en incrementos de $1.00, existen 176,851 formas distintas de hacer ese reparto entre solo cuatro fondos.

Ahora imagínate con 10 fondos « algo común en un portafolio real » el número de combinaciones se cuenta en billones: 4,263,421,511,271 para ser exactos. La Figura 1 muestra qué tan rápido crece.

Combinaciones posibles al repartir $100 según número de activos

Figura 1. El eje vertical es logarítmico: cada línea horizontal representa diez mil millones de veces más combinaciones que la anterior. Aun en esa escala, que comprime los números enormes, la curva no deja de subir. Pasa el cursor sobre los puntos para ver cuánto tardaría una computadora en probarlas todas.

Qué está en juego: El rendimiento de tu portafolio. Poner 70% en un activo que cae, y 20% en uno que sube se traduce en un menor rendimiento del esperado, o en pérdidas.

Este es un proyecto demostrativo, no un consejo financiero: hay demasiadas variables en juego para tomarlo como tal.

Para elegir un buen portafolio con buen rendimiento, necesitas responder estas dos preguntas:

  • ¿Cómo anticipar qué le espera a cada activo antes de que pase?
  • ¿Cómo repartir tu dinero entre todos, sabiendo eso?

La primera parece un trabajo para Machine Learning: patrones que cambian con el tiempo y que un modelo podría aprender por sí mismo. Pero antes de confiar en un modelo hay que comprobar que le gana a la alternativa más simple, que es suponer que el futuro se parece al promedio del pasado. Este proyecto hace justo esa prueba.

La segunda necesita optimización: repartir tu dinero entre fondos tiene millones de combinaciones "correctas", pero ¿Cuál es la óptima que maximiza el rendimiento?

Diagrama de la solución

El pipeline responde dos preguntas en orden, y la respuesta de la primera es el insumo de la segunda (Figura 2).

Diagrama de componentes agrupado por las dos preguntas que responde el pipeline Grupo 1 (rendimiento esperado): datos abiertos de yFinance, features estacionarias y pronóstico a 21 días con seis modelos contra dos referencias. Grupo 2 (pesos óptimos): filtro CAPM por alfa, optimización de Markowitz y portafolio del mes siguiente medido con costos. Pregunta 1 ➲ ¿cuál es el rendimiento esperado de cada activo? Datos abiertos yFinance ➲ 10 acciones + S&P 500 + T-bill AMZN · META · AAPL Features estacionarias Todas relativas al precio ➲ ninguna crece sin límite Rendimiento · SMA · Bollinger · RSI · Autocorr. Pronóstico a 21 días 6 modelos contra 2 referencias que no aprenden LSTM · GRU · Ridge · Gradient Boosting rendimientos esperados Pregunta 2 ➲ ¿qué pesos minimizan el riesgo del portafolio? Filtro CAPM Entra si el pronóstico supera lo exigido ➲ alfa > 0 Optimización de Markowitz Covarianza Ledoit-Wolf + SLSQP Portafolio del mes siguiente Se mide con rendimientos reales y costos
Figura 2. Léelo de arriba abajo. Cada recuadro punteado agrupa las piezas que responden una pregunta: las de arriba estiman cuánto va a rendir cada acción; las de abajo deciden cuánto dinero poner en cada una. La única conexión entre los dos grupos son los rendimientos esperados, por eso un mal pronóstico arrastra a todo lo que sigue.

Datos y fricción real

Antes de tocar el pipeline, esto es lo que realmente te vas a encontrar en los datos de este proyecto — no una advertencia genérica sobre "datos sucios", sino la fricción concreta que después justifica decisiones puntuales más adelante.

  • La tasa libre de riesgo no es un número fijo. El T-bill a 13 semanas pasó de −0.10% a 5.35% en el periodo. Suponer un 2% fijo, como hacen muchos tutoriales, habría dejado el umbral de CAPM y el Sharpe muy por debajo de la realidad de 2023 y 2024; por eso el proyecto descarga la tasa del mercado.
  • Historial desigual entre activos. Una acción con eventos corporativos (splits, dividendos) tiene saltos que no son movimiento real de precio si no usas el precio ajustado. La descarga pide auto_adjust=True de forma explícita, para no depender del valor por defecto de cada versión de yfinance.
  • Los indicadores técnicos consumen filas al inicio. Con SMA y Bollinger de 20 días, RSI de 14 y autocorrelación móvil de 10, los indicadores pierden 18 filas respecto a usar solo el rendimiento. Con 4 años de historial no es grave; con menos de un año puede dejarte con muy pocas filas útiles (ver la decisión «¿Solo precio de cierre o con indicadores técnicos?» en el Desarrollo).
  • yfinance depende de un servicio externo que a veces cambia sin aviso. Cambios en la API no oficial de Yahoo Finance pueden romper una descarga que funcionaba la semana anterior. Si tu descarga falla, primero revisa la versión de yfinance antes de asumir que el código tiene un error.
  • Sesgo de supervivencia. Las 10 acciones se eligieron porque hoy siguen cotizando y crecieron. Eso infla el desempeño de cualquier estrategia, empezando por la más simple: repartir el dinero en partes iguales.
  • Lo que este dataset no resuelve. No incluye datos fundamentales ni noticias o sentimiento de mercado: los modelos pronostican solo a partir del precio y de indicadores derivados del precio. Si tu caso necesita esas fuentes, es un cambio de features, no de arquitectura.

Fundamentos teóricos de la solución

Estas cuatro ideas son la base conceptual de todo lo que sigue. Si ya conoces alguno de estos temas, puedes saltarlo.

No hay código aquí a propósito. La idea es que entiendas qué resuelve cada pieza antes de ver cómo se implementa.

En resumen:

  • LSTM responde "¿cuánto espero ganar?".
  • CAPM responde "¿vale la pena el riesgo de este activo?".
  • Markowitz + optimización responden "¿cuánto le pongo a cada uno?".

Con estas cuatro ideas ya tienes el mapa mental completo: lo que sigue es ver cómo se traduce en código.

¿Qué es la Teoría Moderna de Carteras de Markowitz?

La idea central, en una frase: no se trata de elegir los mejores activos individualmente, sino los que combinados reducen el riesgo total. Dos acciones que suben y bajan juntas «alta correlación» no te protegen entre sí; dos que se mueven de forma independiente «baja correlación», sí.

Markowitz formaliza esta intuición en un problema matemático: dado un conjunto de activos, sus rendimientos esperados y cómo se mueven entre sí «la matriz de covarianza», encuentra la combinación de pesos que minimiza el riesgo del portafolio para un nivel de retorno dado. El resultado es la Frontera Eficiente: la curva de todas las combinaciones "óptimas" posibles.

Frontera eficiente de Markowitz, curva convexa suave que abre hacia la derecha Área en forma de U rotada, sin picos: contiene todas las combinaciones posibles de fondos. El portafolio óptimo se ubica sobre el borde superior, equilibrando riesgo y rendimiento. Retorno esperado Riesgo Frontera eficiente Portafolio óptimo Más riesgo Más rendimiento Portafolio óptimo
Figura 3. Hacia la derecha, más riesgo; hacia arriba, más rendimiento. El área sombreada contiene todas las combinaciones posibles, pero solo las del borde superior, la Frontera Eficiente, valen la pena: cualquier punto debajo de ella se puede mejorar sin aceptar más riesgo.

Como ves en el azul claro, hay fondos con más riesgo que rendimiento; en el verde, fondos con más rendimiento que riesgo. Ninguno de los dos extremos es el punto de partida ideal.

El portafolio óptimo, en verde menta sobre la curva, es el que amortigua ambos extremos: busca el mayor rendimiento posible para el menor riesgo posible.

En la práctica, utilizamos un optimizador numérico como SLSQP de SciPy.

Explorador de decisiones

Antes de leer cómo se construyó el pipeline, prueba tú mismo dos de sus decisiones. Es una muestra rápida — las 4 decisiones del proyecto se exploran igual, justo donde aparecen en el desarrollo.

Explorador de decisiones — prueba tú esta alternativa 2 min

¿Solo precio de cierre o con indicadores técnicos?

Explorador de decisiones — prueba tú esta alternativa 2 min

¿Minimizar volatilidad o maximizar el ratio de Sharpe?

Parte 2 de 3

Desarrollo del proyecto

¿Qué construyes?

Este proyecto integra tres pilares de las finanzas cuantitativas en un solo pipeline: pronóstico de rendimientos con Deep Learning, selección de activos basada en riesgo sistemático y optimización matemática de carteras. Lo que lo distingue es cómo se evalúa: cada pieza se pone a prueba, fuera de muestra, contra la alternativa más simple que existe.

El universo de inversión son 10 acciones de alta capitalización bursátil (AAPL, MSFT, GOOGL, AMZN, TSLA, META, NFLX, NVDA, JPM, JNJ), con datos del 1 de enero de 2020 al 1 de julio de 2024. Desde enero de 2023, cada 21 días hábiles «aproximadamente un mes» el pipeline pronostica el rendimiento del mes siguiente, filtra, optimiza e invierte; después mide qué pasó de verdad.

Esta es la segunda versión del proyecto. La primera pronosticaba precios y se evaluaba con un solo corte de 30 días; en la Etapa 3 verás qué fallaba y por qué la corrección cambió los resultados.

Decisión clave

¿Esto es un problema de regresión o de clasificación?

Pronosticar el rendimiento de una acción es regresión «el objetivo es un valor continuo». Algunos proyectos lo convierten en clasificación (¿sube o baja?), lo cual simplifica pero pierde información clave para Markowitz, que necesita magnitudes, no solo dirección.

Se eligió regresión: el rendimiento pronosticado a 21 días entra directamente como rendimiento esperado al filtro CAPM y al optimizador.

¿Y si tu caso es distinto? Si a ti te piden explícitamente «¿sube o baja?» (por ejemplo, una señal de trading binaria), clasificación puede bastar y es más simple de evaluar. Pero si tu salida alimenta un optimizador como Markowitz, necesitas magnitudes: quédate con regresión.

Insight de experto: La pregunta más importante de todo el pipeline no es técnica «es conceptual: »¿los rendimientos esperados son correctos? Markowitz es tan bueno como los insumos que recibe: si los rendimientos esperados son malos, el portafolio óptimo también lo es. Por eso la pregunta que recorre todo el proyecto es si un modelo de Deep Learning estima esos rendimientos mejor que la media histórica del enfoque clásico.

Arquitectura del pipeline

  1. Datos: precios ajustados de las 10 acciones, el S&P 500 como mercado y la tasa del T-bill a 13 semanas como tasa libre de riesgo, descargados con yfinance.
  2. Features y objetivo: rendimiento diario e indicadores técnicos relativos al precio; el objetivo es el rendimiento logarítmico de los 21 días siguientes.
  3. Pronóstico: seis modelos (LSTM, GRU, Ridge y Gradient Boosting) contra la media histórica y el paseo aleatorio, reentrenados cada trimestre.
  4. Filtro CAPM: entra al optimizador la acción cuyo pronóstico supera el rendimiento que su beta exige.
  5. Markowitz y medición: covarianza con Ledoit-Wolf, mínima volatilidad o máximo Sharpe; el portafolio se mantiene 21 días y se mide con rendimientos reales y costos.

El Diagrama de la solución (Figura 2) muestra estas mismas piezas agrupadas por la pregunta que responde cada una.

Stack tecnológico

  • Python «lenguaje principal»
  • yfinance «descarga de precios, S&P 500 y tasa del T-bill desde Yahoo Finance»
  • TensorFlow / Keras «redes LSTM y GRU con Dropout, regularización L2 y paro temprano»
  • scikit-learn «StandardScaler, Ridge, HistGradientBoostingRegressor y covarianza Ledoit-Wolf»
  • scipy «optimización con minimize (SLSQP) y correlación de rangos de Spearman para el IC»
  • pandas / numpy «manipulación de datos y álgebra matricial»
  • matplotlib «todas las figuras de resultados»

Probado de principio a fin el 25 de septiembre de 2026 con numpy 2.1.3, pandas 2.2.3, scipy 1.16.3, scikit-learn 1.6.1, TensorFlow 2.20.0 y yfinance 0.2.66.

Etapa 1 de 7

Datos y reproducibilidad

Fija todo el experimento en una sola celda y descarga los datos que el pipeline necesita, incluidos los dos que casi siempre se inventan: el mercado y la tasa libre de riesgo.

El proyecto corre en Google Colab o localmente. En Colab activa un entorno con GPU «Entorno de ejecución → Cambiar tipo de entorno de ejecución»: el backtest entrena 240 redes pequeñas y con GPU tarda alrededor de media hora.

Configuración del experimento

Todas las decisiones numéricas viven en una sola celda, para que cambiarlas no requiera buscar en el resto del notebook. Se fija una semilla y se activa el determinismo de TensorFlow, así dos corridas dan los mismos números, y se imprimen las versiones de cada librería, porque un resultado sin su entorno no se puede reproducir.

Python «configuración del experimento» (fragmento; las importaciones completas están en el notebook)
# ── Universo y fechas ──
SYMBOLS = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NFLX', 'NVDA', 'JPM', 'JNJ']
FECHA_INICIAL   = "2020-01-01"
FECHA_FINAL     = "2024-07-01"
INICIO_BACKTEST = "2023-01-01"   # desde aquí todo es fuera de muestra

# ── Diseño del experimento ──
H              = 21     # horizonte y periodo de tenencia: 21 días hábiles, aprox. un mes
WINDOW         = 20     # días de historia que ve cada modelo por predicción
LOOKBACK_COV   = 252    # un año de rendimientos diarios para covarianza
LOOKBACK_BETA  = 252    # un año de rendimientos diarios para beta
RETRAIN_EVERY  = 3      # los modelos se reentrenan cada 3 rebalanceos, aprox. trimestral
VAL_FRAC       = 0.2    # fracción final del entrenamiento usada como validación interna
EPOCHS_MAX     = 60
PATIENCE       = 8
BATCH_SIZE     = 32
COSTO_TRANSACCION = 0.001   # 10 puntos base por unidad de rotación del portafolio
MIN_ACTIVOS    = 3          # mínimo de activos que deben entrar al optimizador
N_BOOTSTRAP    = 2000
SEED           = 42

tf.keras.utils.set_random_seed(SEED)
try:
    tf.config.experimental.enable_op_determinism()
except Exception as e:
    print("Aviso: no se pudo activar el determinismo de TensorFlow:", e)

print("Ejecutado:", datetime.now().strftime("%Y-%m-%d %H:%M"))
for lib in [np, pd, scipy, sklearn, tf, yf]:
    print(f"{lib.__name__:12s} {lib.__version__}")
print("GPU disponible:", bool(tf.config.list_physical_devices('GPU')))
Salida
Ejecutado: 2026-09-25 16:18
numpy        2.1.3
pandas       2.2.3
scipy        1.16.3
sklearn      1.6.1
tensorflow   2.20.0
yfinance     0.2.66
GPU disponible: True

Error común

Mezclar versiones de Keras. La API de Keras cambió entre versiones de TensorFlow. Si ves errores como module 'keras' has no attribute 'Sequential', importa desde tensorflow.keras y compara tus versiones con las que imprime esta celda (TensorFlow 2.20.0 en la corrida de referencia).

Descarga de datos con yfinance

Además de las 10 acciones se descargan dos series que el pipeline necesita para no inventar supuestos: el S&P 500 como proxy del mercado para calcular beta, y la tasa del T-bill a 13 semanas como tasa libre de riesgo del periodo. El rango queda fijo, del 1 de enero de 2020 al 1 de julio de 2024, para que el resultado sea reproducible.

Python «descarga de precios, mercado y tasa libre de riesgo»
def descargar_cierres(tickers, start, end):
    """Descarga precios de cierre ajustados. auto_adjust=True hace que 'Close'
    ya incorpore dividendos y splits, que es el valor por defecto actual de
    yfinance; se declara explícito para que no dependa de la versión."""
    df = yf.download(tickers, start=start, end=end, auto_adjust=True, progress=False)
    close = df['Close']
    if isinstance(close, pd.Series):
        close = close.to_frame(tickers if isinstance(tickers, str) else tickers[0])
    return close

precios_raw = descargar_cierres(SYMBOLS, FECHA_INICIAL, FECHA_FINAL)[SYMBOLS]
sp500_raw   = descargar_cierres('^GSPC', FECHA_INICIAL, FECHA_FINAL).iloc[:, 0]
irx_raw     = descargar_cierres('^IRX',  FECHA_INICIAL, FECHA_FINAL).iloc[:, 0]

# Calendario común: solo días en que cotizan las 10 acciones y el índice.
# La tasa se rellena hacia adelante porque el mercado de bonos cierra en
# algunos feriados en que la bolsa sí abre; nunca se rellena hacia atrás.
idx = precios_raw.dropna().index.intersection(sp500_raw.dropna().index)
precios = precios_raw.loc[idx]
sp500   = sp500_raw.loc[idx].rename('SP500')
irx     = irx_raw.reindex(idx).ffill()

assert precios.notna().all().all(), "Hay precios faltantes en el calendario común"
assert irx.notna().all(), "La tasa libre de riesgo tiene huecos al inicio de la serie"

rf_diaria = (irx / 100.0) / 252          # tasa anual en % convertida a rendimiento diario
ret_simple = precios.pct_change()         # rendimientos simples diarios, para portafolio y CAPM
ret_mkt    = sp500.pct_change()
logp       = np.log(precios)

print(f"Periodo: {idx[0].date()} → {idx[-1].date()}  |  {len(idx)} días hábiles  |  {precios.shape[1]} acciones")
precios.tail(3)
Salida
Periodo: 2020-01-02 → 2024-06-28  |  1130 días hábiles  |  10 acciones

Qué significa cada dato

  • Precio de cierre ajustado. El último precio al que se negoció la acción en la sesión, incorporando dividendos y splits, para que un salto por split no se confunda con un movimiento real.
  • Rendimiento diario. Cambio porcentual del precio de un día al siguiente. Es la unidad con la que trabajan CAPM y Markowitz.
  • Rendimiento logarítmico a 21 días. Lo que se va a pronosticar: cuánto sube o baja la acción en el mes siguiente a la fecha de decisión.
  • Tasa libre de riesgo. Rendimiento anual del T-bill a 13 semanas, convertido a diario para restarlo y obtener rendimientos en exceso.

La Tabla 1 resume cada acción en el periodo previo al backtest, que es lo único que un analista habría conocido el 1 de enero de 2023.

Python «resumen por acción antes del backtest»
pre = ret_simple.loc[:INICIO_BACKTEST].dropna()
resumen = pd.DataFrame({
    'Rend. anual medio': pre.mean() * 252,
    'Volatilidad anual': pre.std() * np.sqrt(252),
    'Peor día': pre.min(),
    'Mejor día': pre.max(),
    'Autocorr. lag 1': pre.apply(lambda s: s.autocorr(1)),
})
print(f"Tasa T-bill: mín {irx.min():.2f}%, máx {irx.max():.2f}%, al inicio del backtest {irx.loc[:INICIO_BACKTEST].iloc[-1]:.2f}%")
resumen.style.format({'Rend. anual medio': '{:.1%}', 'Volatilidad anual': '{:.1%}',
                      'Peor día': '{:.1%}', 'Mejor día': '{:.1%}', 'Autocorr. lag 1': '{:.3f}'})
Salida
Tasa T-bill: mín -0.10%, máx 5.35%, al inicio del backtest 4.26%
Tabla 1. Resumen de cada acción de 2020 a 2022, lo único que se conocía antes del backtest. Compara rendimiento con volatilidad: TSLA rindió 74.7% anual, pero con 72.2% de volatilidad; JNJ fue la más estable, con 21.8%. La última columna anticipa la Etapa 2: todas las autocorrelaciones son negativas y pequeñas.
AcciónRend. anual medioVolatilidad anualPeor díaMejor díaAutocorr. lag 1
AAPL25.8%36.9%−12.9%12.0%−0.151
MSFT20.4%34.8%−14.7%14.2%−0.230
GOOGL14.4%34.5%−11.6%9.2%−0.148
AMZN3.6%39.1%−14.0%13.5%−0.031
TSLA74.7%72.2%−21.1%19.9%−0.008
META−6.3%48.7%−26.4%17.6%−0.067
NFLX10.4%51.9%−35.1%16.9%−0.062
NVDA45.5%56.0%−18.5%17.2%−0.127
JPM8.5%37.9%−15.0%18.0%−0.150
JNJ11.3%21.8%−7.3%8.0%−0.166

Punto de adaptación: Este es el primer lugar donde tocarías el código si tu proyecto usa otros datos: cambia SYMBOLS por tu propio universo y FECHA_FINAL si quieres datos más recientes. El resto del pipeline se ajusta solo.

Checkpoint «Datos»

  • El calendario común tiene 1,130 días hábiles, del 2 de enero de 2020 al 28 de junio de 2024, y los assert de datos faltantes pasan
  • La celda de configuración imprime las versiones de numpy, pandas, scipy, scikit-learn, TensorFlow y yfinance
  • La tasa del T-bill va de −0.10% a 5.35% y está en 4.26% al inicio del backtest

Sigue: Etapa 2 · Análisis exploratorio

Etapa 2 de 7

Análisis exploratorio

Tres gráficas, cada una responde una pregunta que después condiciona una decisión del pipeline.

¿Cómo se comparan los activos entre sí?

Los precios en base 100 ponen a las 10 acciones en la misma escala (Figura 5). A la izquierda de la línea punteada está todo lo que un analista conocía el 1 de enero de 2023; a la derecha, el periodo en el que se mide el pipeline.

Precios de las 10 acciones en base 100, en escala logarítmica, de 2020 a 2024, con una línea punteada al inicio del backtest en enero de 2023
Figura 5. Cada línea muestra en qué se habrían convertido 100 dólares invertidos el 2 de enero de 2020. En escala logarítmica la misma distancia vertical es el mismo cambio porcentual, así que las pendientes se comparan directamente. Todo lo que queda a la izquierda de la línea punteada sirve para aprender; lo de la derecha, solo para evaluar.

¿Hay diversificación posible?

La matriz de correlación es el insumo central de Markowitz (Figura 6): correlaciones bajas significan que combinar activos reduce el riesgo.

Matriz de correlación de los rendimientos diarios de las 10 acciones, de 2020 a 2022
Figura 6. Cada celda es la correlación de los rendimientos diarios de dos acciones, de 2020 a 2022. Los tonos más oscuros, cercanos a 1, indican que se mueven juntas y se protegen poco entre sí; los cian y turquesa, por debajo de 0.4, que se mueven de forma bastante independiente. JNJ y JPM tienen las correlaciones más bajas con las tecnológicas: son las que Markowitz buscará para bajar el riesgo.

¿Hay algo que pronosticar?

Si la autocorrelación de los rendimientos diarios es casi cero, ningún modelo va a sacar mucha señal del precio pasado, y la vara con la que se mida cualquier modelo tiene que ser una alternativa simple (Figura 7).

Autocorrelación de rezago 1 de los rendimientos diarios de cada acción, de 2020 a 2022, con una franja de significancia al 95%
Figura 7. Cada barra mide cuánto se parece el rendimiento de hoy al de ayer. La franja cian es la zona que no se distingue de cero con 95% de confianza: META, NFLX, AMZN y TSLA caen dentro. Las barras verdes salen de la franja, pero son negativas y pequeñas: un día bueno tiende a seguirse de uno ligeramente peor. En cualquier caso, hay muy poca memoria que un modelo pueda aprovechar.

Lo que dice la Figura 7 marca todo lo que sigue: la memoria de corto plazo de los rendimientos es débil y, en varias acciones, indistinguible de cero. Por eso cada modelo de este proyecto se compara contra dos alternativas que no aprenden nada: la media histórica, que es el insumo clásico de Markowitz, y el paseo aleatorio, que pronostica rendimiento cero. Si un modelo no les gana a esas dos, su complejidad no se justifica.

El código de las tres gráficas está en el notebook; aquí se muestran solo sus resultados.

Checkpoint «Exploración»

  • Identificas qué acciones tienen correlación baja con el resto (JNJ y JPM) y por qué importan para Markowitz
  • Sabes qué acciones tienen autocorrelación distinguible de cero y cuáles no
  • Tienes claras las dos vallas que cualquier modelo tiene que superar: la media histórica y el paseo aleatorio

Sigue: Etapa 3 · Diseño de la evaluación

Etapa 3 de 7

Diseño de la evaluación

Antes de entrenar nada se decide cómo se va a medir. Esta etapa existe porque la primera versión de este proyecto se midió mal.

Lo que falló en la primera versión

La primera versión pronosticaba el nivel del precio y calculaba el rendimiento esperado con las predicciones de los mismos 30 días de prueba. Tenía dos problemas que no se veían en las métricas. Se dejan documentados porque son el tipo de error que más se repite en proyectos como este.

Error común

Pronosticar el nivel del precio con un escalado MinMax. El escalado se ajusta con el entrenamiento. Cuando el precio de prueba supera el máximo histórico, la red no sabe extrapolar y su predicción se queda plana: en NVDA quedó en unos 88 dólares mientras el precio real subía de 94 a 135.

Precio de NVDA de 2020 a 2024: en azul el periodo de entrenamiento de la primera versión, en verde los 30 días de prueba, por encima del máximo de entrenamiento de 95 dólares
Figura 8. En azul, lo que la primera versión usó para entrenar; en verde, sus 30 días de prueba. La línea punteada es el precio más alto que el modelo llegó a ver: 95 dólares. Todo lo que queda por encima está fuera del rango que conoce, y ahí una red escalada con MinMax no puede seguir al precio.

El segundo problema es más difícil de ver: cada predicción de un día usaba los precios reales de los días anteriores del mismo periodo de prueba, así que el «rendimiento esperado» del mes ya contenía casi todo el mes. Ese número no lo habría tenido nadie el día en que tenía que invertir.

La corrección es la base de esta versión: se pronostica el rendimiento a 21 días, que no tiene tendencia y se mantiene en rango, y se pronostica desde la fecha de decisión hacia adelante, con datos que solo llegan hasta ese día. En rendimientos, el periodo de prueba de NVDA (de −0.069 a 0.089 diario) cabe de sobra dentro del rango de entrenamiento (de −0.204 a 0.218).

Walk-forward con purga

Decisión clave

¿Evaluar con un solo corte de prueba o con walk-forward?

Un solo corte: más simple y rápido, pero mide un solo mes y es fácil que información del periodo de prueba se filtre al entrenamiento.
Walk-forward: muchas fechas de decisión, cada una con información solo hasta ese día, a cambio de más código y más cómputo.

Se eligió: walk-forward con purga y embargo, porque es la única forma de simular lo que un analista habría podido hacer en cada fecha.

¿Y si tu caso es distinto? Si solo quieres comparar arquitecturas rápido, un corte único sirve como primera exploración, pero no para reportar resultados: cualquier número que publiques debe salir de una evaluación que respete el tiempo.

  • Fechas de decisión. Desde enero de 2023, una cada 21 días hábiles. En cada una se pronostica, se filtra, se optimiza y se invierte por los 21 días siguientes.
  • Purga. El objetivo de un día es el rendimiento de los 21 días siguientes. Una muestra solo entra al entrenamiento si esos 21 días ya terminaron antes de T; sin esto, el modelo entrenaría con rendimientos que todavía no ocurrieron.
  • Embargo en la validación. La validación interna, que decide cuándo detener el entrenamiento, es el último 20% del periodo de entrenamiento, separado por 21 días para que ningún objetivo se traslape entre las dos partes. El periodo de prueba nunca se usa para validar.
  • Reentrenamiento trimestral. Los modelos se reentrenan cada tres fechas de decisión. Entre reentrenamientos se usa el último modelo con los datos nuevos de entrada, que es lo que haría un analista en la práctica.

La Figura 9 muestra ese ciclo en el tiempo. Es la misma cadena del Diagrama de la solución (Figura 2), pero repetida mes a mes.

Diagrama del pipeline walk-forward: datos hasta T, features estacionarias, pronóstico a 21 días, filtro CAPM, Markowitz y mes siguiente, con una flecha que regresa al inicio
Figura 9. Cada caja es una pieza del pipeline, en el orden en que se ejecuta en cada fecha de decisión T. La flecha de regreso es lo importante: después de medir el mes siguiente, T avanza un mes y todo se repite, sin mirar nunca a la derecha de T.
Python «fechas de decisión y de reentrenamiento»
p_inicio = idx.searchsorted(pd.Timestamp(INICIO_BACKTEST))
REBAL = list(range(p_inicio, len(idx) - H, H))           # posiciones de las fechas de decisión
RETRAIN = REBAL[::RETRAIN_EVERY]                         # posiciones donde se reentrena

assert REBAL[0] - max(LOOKBACK_COV, LOOKBACK_BETA) >= 1, "No hay historia suficiente antes del backtest"
assert all(p + H <= len(idx) - 1 for p in REBAL), "Algún periodo de tenencia se sale de los datos"

print(f"{len(REBAL)} fechas de decisión: {idx[REBAL[0]].date()} a {idx[REBAL[-1]].date()}")
print(f"{len(RETRAIN)} reentrenamientos por modelo y por acción")
print(f"Periodo fuera de muestra evaluado: {idx[REBAL[0] + 1].date()} a {idx[REBAL[-1] + H].date()}")
Salida
17 fechas de decisión: 2023-01-03 a 2024-05-06
6 reentrenamientos por modelo y por acción
Periodo fuera de muestra evaluado: 2023-01-04 a 2024-06-05

Checkpoint «Evaluación»

  • 17 fechas de decisión, del 3 de enero de 2023 al 6 de mayo de 2024, con un periodo fuera de muestra que termina el 5 de junio de 2024
  • 6 reentrenamientos por modelo y por acción
  • Ningún objetivo usado en entrenamiento termina después de T, y ninguno del ajuste se traslapa con la validación

Sigue: Etapa 4 · Features

Etapa 4 de 7

Features

Convierte el precio en información que un modelo pueda usar sin extrapolar.

Decisión clave

¿Solo precio de cierre o con indicadores técnicos?

Solo precio: más simple y con menos riesgo de sobreajuste, pero pierde señales de momentum y volatilidad.
Con indicadores: más contexto para el modelo, a cambio de más features que calcular y más filas perdidas por las ventanas móviles.

Se eligió: indicadores técnicos, como en la versión original del proyecto. Esta vez las dos opciones se corren de verdad, con el mismo modelo y las mismas fechas.

¿Y si tu caso es distinto? Con menos de un año de historial, agregar indicadores es justo la opción que más riesgo corre de dejarte con muy pocas filas útiles para entrenar, porque cada ventana móvil consume filas al principio de la serie.

Explorador de decisiones — prueba tú esta alternativa 2 min

¿Solo precio de cierre o con indicadores técnicos?

Features estacionarias

Para que las dos opciones sean comparables, el precio entra como rendimiento diario y los indicadores se expresan en forma relativa al precio. Así ninguna feature crece sin límite, como lo hacía el precio en la versión anterior:

  1. SMA de 20 días, como distancia del precio a su promedio: precio / SMA − 1.
  2. Bandas de Bollinger de 20 días y 2 desviaciones, como posición dentro de las bandas «%B» y como ancho relativo de las bandas.
  3. RSI de 14 días, escalado de 0 a 1.
  4. Autocorrelación de rezago 1 en ventana móvil de 10 días, calculada sobre los rendimientos, un valor por día.

El objetivo es el rendimiento logarítmico de t a t+21. En el día t todavía no se conoce: solo existe en t+21, y por eso la purga lo respeta.

Python «features estacionarias y objetivo a 21 días»
FEATS_PRECIO = ['ret_1']
FEATS_IND    = ['ret_1', 'sma_ratio', 'bb_pctb', 'bb_width', 'rsi_14', 'autocorr_10']

def calcular_features(close):
    """Todas las ventanas miran solo hacia atrás: el valor del día t usa
    datos hasta t. Por eso se pueden calcular sobre la serie completa sin
    filtrar el futuro, y el checklist final lo comprueba recalculando sobre
    series recortadas."""
    f = pd.DataFrame(index=close.index)
    r = np.log(close).diff()
    f['ret_1'] = r
    sma = close.rolling(20).mean()
    std = close.rolling(20).std()
    upper, lower = sma + 2 * std, sma - 2 * std
    f['sma_ratio'] = close / sma - 1
    f['bb_pctb']   = (close - lower) / (upper - lower)
    f['bb_width']  = (upper - lower) / sma
    delta = close.diff()
    gain = delta.clip(lower=0).rolling(14).mean()
    loss = (-delta.clip(upper=0)).rolling(14).mean()
    f['rsi_14'] = (100 - 100 / (1 + gain / loss)) / 100
    f['autocorr_10'] = r.rolling(10).apply(lambda s: pd.Series(s).autocorr(lag=1), raw=True)
    return f.replace([np.inf, -np.inf], np.nan)

def calcular_objetivo(close, h=H):
    """Rendimiento logarítmico de t a t+h. En el día t todavía no se conoce:
    solo existe en t+h, y por eso la purga del entrenamiento lo respeta."""
    lc = np.log(close)
    return lc.shift(-h) - lc

FEATURES = {s: calcular_features(precios[s]) for s in SYMBOLS}
OBJETIVO = {s: calcular_objetivo(precios[s]) for s in SYMBOLS}

primer_valido_precio = FEATURES['AAPL'][FEATS_PRECIO].dropna().index[0]
primer_valido_ind    = FEATURES['AAPL'][FEATS_IND].dropna().index[0]
print("Primer día utilizable, solo precio:  ", primer_valido_precio.date())
print("Primer día utilizable, con indicadores:", primer_valido_ind.date())
print("Filas perdidas por las ventanas móviles:",
      FEATURES['AAPL'].index.get_loc(primer_valido_ind) - FEATURES['AAPL'].index.get_loc(primer_valido_precio))
FEATURES['AAPL'][FEATS_IND].describe().T[['mean', 'std', 'min', 'max']]
Salida
Primer día utilizable, solo precio:   2020-01-03
Primer día utilizable, con indicadores: 2020-01-30
Filas perdidas por las ventanas móviles: 18

                 mean       std       min       max
ret_1        0.000939  0.020581 -0.137708  0.113157
sma_ratio    0.009131  0.046420 -0.168029  0.147493
bb_pctb      0.575826  0.327447 -0.260390  1.370145
bb_width     0.128782  0.058040  0.032976  0.362568
rsi_14       0.545923  0.170513  0.031798  0.932418
autocorr_10 -0.122656  0.292054 -0.930479  0.867402

La salida confirma dos cosas: con indicadores pierdes 18 filas al inicio de cada serie, y todas las features viven en rangos acotados, sin la tendencia que tenía el precio.

Checkpoint «Features»

  • Recalcular los indicadores con la serie recortada en T da exactamente el mismo valor en T que calcularlos con la serie completa: ninguno mira el futuro
  • Con indicadores, el primer día utilizable es el 30 de enero de 2020, 18 filas después que solo con el rendimiento
  • El objetivo coincide con el rendimiento logarítmico a 21 días recalculado desde los precios

Sigue: Etapa 5 · Modelos y pronóstico

Etapa 5 de 7

Modelos y pronóstico

Pon a competir seis modelos, con la misma información, contra dos alternativas que no aprenden nada.

Decisión clave

¿Cuántas capas LSTM y cuántas unidades?

Una sola capa de 50 unidades: entrena más rápido y es menos propensa a sobreajustar, pero puede no capturar patrones complejos.
Tres capas apiladas con Dropout: más capacidad de representación, a costa de más tiempo de entrenamiento y más riesgo de memorizar ruido.

Se eligió: tres capas de 50 unidades con Dropout de 0.2 y 0.1 y salida con regularización L2, la arquitectura original del proyecto. Para saber si la profundidad aporta, la comparación de abajo aísla una variable a la vez.

¿Y si tu caso es distinto? Si quieres probar otra arquitectura, por ejemplo un Transformer de series de tiempo, construir_rnn es la única pieza que reemplazas: el resto del pipeline no se entera, porque solo espera un rendimiento esperado por activo.

Explorador de decisiones — prueba tú esta alternativa 2 min

¿Cuántas capas LSTM y cuántas unidades?

Modelos candidatos

  • LSTM de 3 capas: la arquitectura original del proyecto.
  • LSTM de 1 capa: cambia solo la profundidad.
  • GRU de 3 capas: cambia solo el tipo de celda. La GRU tiene menos parámetros que la LSTM y suele entrenar más rápido con resultados parecidos.
  • Ridge y Gradient Boosting: reciben exactamente la misma ventana de 20 días, aplanada. Si una red recurrente no le gana a un modelo lineal con la misma información, la memoria de la red no está aportando.
  • LSTM de 3 capas solo con precio: la otra opción de la decisión de features.
  • Media histórica y paseo aleatorio: las dos vallas mínimas de la Etapa 2.
Python «arquitecturas y modelos candidatos»
def construir_rnn(tipo):
    """Sin input_shape: Keras construye el modelo con la forma del primer lote."""
    if tipo == 'LSTM-3':
        capas = [LSTM(50, return_sequences=True), LSTM(50, return_sequences=True), Dropout(0.2),
                 LSTM(50), Dropout(0.1), Dense(1, kernel_regularizer=L2(0.01))]
    elif tipo == 'LSTM-1':
        capas = [LSTM(50), Dropout(0.2), Dense(1, kernel_regularizer=L2(0.01))]
    elif tipo == 'GRU-3':
        capas = [GRU(50, return_sequences=True), GRU(50, return_sequences=True), Dropout(0.2),
                 GRU(50), Dropout(0.1), Dense(1, kernel_regularizer=L2(0.01))]
    else:
        raise ValueError(tipo)
    model = Sequential(capas)
    model.compile(optimizer='adam', loss='mean_squared_error')
    return model

# nombre visible → (familia, arquitectura, features)
MODELOS = {
    'Ridge':                 ('tabular', 'ridge',  FEATS_IND),
    'Gradient Boosting':     ('tabular', 'hgb',    FEATS_IND),
    'LSTM 1 capa':           ('rnn',     'LSTM-1', FEATS_IND),
    'LSTM 3 capas':          ('rnn',     'LSTM-3', FEATS_IND),
    'GRU 3 capas':           ('rnn',     'GRU-3',  FEATS_IND),
    'LSTM 3 capas, solo precio': ('rnn', 'LSTM-3', FEATS_PRECIO),
}
BASELINES = ['Media histórica', 'Paseo aleatorio']
MODELO_PROYECTO = 'LSTM 3 capas'   # la elección original del artículo

n_rnn = sum(1 for v in MODELOS.values() if v[0] == 'rnn')
print(f"Redes a entrenar: {n_rnn} arquitecturas × {len(SYMBOLS)} acciones × {len(RETRAIN)} reentrenamientos = {n_rnn * len(SYMBOLS) * len(RETRAIN)}")
Salida
Redes a entrenar: 4 arquitecturas × 10 acciones × 6 reentrenamientos = 240

Entrenamiento sin fugas

Cada modelo se entrena con información disponible hasta la fecha de decisión T y solo pronostica de T en adelante. La función aplica la purga, separa la validación con un embargo de 21 días y ajusta el escalado únicamente con las filas del ajuste. Las redes se detienen cuando la validación deja de mejorar y conservan los pesos de su mejor época; en Ridge y Gradient Boosting, la regularización y el número de árboles también se eligen con esa validación temporal, nunca con una partición aleatoria.

Python «entrenar_y_predecir: purga, embargo y escalado» (fragmento)
def entrenar_y_predecir(nombre, activo, p_T, pred_pos, guardar_historia=False):
    """Entrena con información disponible hasta p_T y pronostica en pred_pos (todas ≥ p_T)."""
    familia, arq, cols = MODELOS[nombre]
    F = FEATURES[activo][cols].values.astype(float)
    y = OBJETIVO[activo].values.astype(float)
    valid = ventanas_validas(F, WINDOW)

    # Purga: el objetivo de t termina en t+H y tiene que haber ocurrido a más tardar en T.
    pos_train = [t for t in range(len(F)) if valid[t] and t + H <= p_T and not np.isnan(y[t])]
    n_val = int(len(pos_train) * VAL_FRAC)
    pos_val = pos_train[-n_val:]
    pos_fit = [t for t in pos_train[:-n_val] if t + H < pos_val[0]]   # embargo de H días

    # Escalado ajustado solo con las filas que entran al ajuste del modelo.
    fin_scaler = pos_fit[-1]
    filas_fit = F[:fin_scaler + 1][~np.isnan(F[:fin_scaler + 1]).any(axis=1)]
    scaler = StandardScaler().fit(filas_fit)
    assert np.allclose(scaler.mean_, filas_fit.mean(axis=0)), "El scaler no corresponde a las filas de entrenamiento"
    Fs = np.where(np.isnan(F), np.nan, (F - scaler.mean_) / scaler.scale_)
    y_mu, y_sd = y[pos_fit].mean(), y[pos_fit].std()

    X_fit, X_val = armar_X(Fs, pos_fit, WINDOW), armar_X(Fs, pos_val, WINDOW)
    X_pred = armar_X(Fs, pred_pos, WINDOW)
    assert not np.isnan(X_pred).any(), f"Ventana de pronóstico incompleta para {activo}"
    yz_fit, yz_val = (y[pos_fit] - y_mu) / y_sd, (y[pos_val] - y_mu) / y_sd

Error común

Ajustar el escalado con todo el dataset. Si el scaler ve el periodo de validación o de prueba, el modelo aprende de valores que en producción todavía no existirían (data leakage). Aquí se ajusta solo con las filas del ajuste, y un assert comprueba que sus medias coinciden con esas filas.

Un modelo de muestra antes del backtest completo

Antes de entrenar los 240 modelos, uno de prueba: el LSTM de 3 capas para AAPL en la primera fecha de decisión, solo con datos anteriores a enero de 2023. La Figura 10 compara la pérdida de entrenamiento contra la de validación interna; el periodo de prueba no participa.

Curva de aprendizaje del LSTM de 3 capas para AAPL: la pérdida de entrenamiento baja en cada época y la de validación sube después de la segunda época
Figura 10. Una curva de aprendizaje compara dos errores por época. El azul, de entrenamiento, baja siempre, porque la red cada vez se aprende mejor sus propios datos. El verde, de validación, es el que importa: aquí es más bajo en la época 2 y después sube. A partir de ahí la red memoriza ruido en vez de aprender patrones, y el paro temprano conserva los pesos de esa época.

Si la validación deja de bajar en las primeras épocas no es un error del código: es la señal honesta de que hay poca estructura que aprender en el precio pasado, lo mismo que anticipaba la Figura 7. El pronóstico de esta red de muestra para AAPL, a 21 días desde el 3 de enero de 2023, fue de +0.50%.

Backtest de pronósticos

Cada modelo produce un pronóstico por acción en cada fecha de decisión, con el modelo del último reentrenamiento anterior o igual a esa fecha. Los resultados se guardan en disco: si vuelves a correr el notebook con la misma configuración, esta celda los carga en vez de entrenar todo otra vez.

Python «pronósticos de todos los modelos, acciones y fechas»
CONFIG = dict(SYMBOLS=SYMBOLS, FECHA_INICIAL=FECHA_INICIAL, FECHA_FINAL=FECHA_FINAL, INICIO_BACKTEST=INICIO_BACKTEST,
              H=H, WINDOW=WINDOW, RETRAIN_EVERY=RETRAIN_EVERY, VAL_FRAC=VAL_FRAC, EPOCHS_MAX=EPOCHS_MAX,
              PATIENCE=PATIENCE, BATCH_SIZE=BATCH_SIZE, SEED=SEED, MODELOS=list(MODELOS), n_dias=len(idx))
CACHE = f"pronosticos_{hashlib.md5(json.dumps(CONFIG, sort_keys=True).encode()).hexdigest()[:10]}.pkl"

def bloques_de_prediccion():
    """Asigna a cada reentrenamiento las fechas de decisión que le tocan."""
    for k, p_T in enumerate(RETRAIN):
        siguiente = RETRAIN[k + 1] if k + 1 < len(RETRAIN) else len(idx)
        yield p_T, [p for p in REBAL if p_T <= p < siguiente]

if os.path.exists(CACHE):
    with open(CACHE, 'rb') as fh:
        PRONOSTICOS, LOG_ENTRENAMIENTO = pickle.load(fh)
    print("Pronósticos cargados de", CACHE)
else:
    filas = []
    t_inicio = time.time()
    for nombre in MODELOS:
        t_modelo = time.time()
        for activo in SYMBOLS:
            for p_T, pred_pos in bloques_de_prediccion():
                preds = entrenar_y_predecir(nombre, activo, p_T, pred_pos)
                for p, yhat in zip(pred_pos, preds):
                    filas.append((idx[p], activo, nombre, float(yhat)))
        print(f"{nombre:28s} listo en {(time.time() - t_modelo) / 60:5.1f} min")
    PRONOSTICOS = pd.DataFrame(filas, columns=['fecha', 'activo', 'modelo', 'pred'])
    with open(CACHE, 'wb') as fh:
        pickle.dump((PRONOSTICOS, LOG_ENTRENAMIENTO), fh)
    print(f"Total: {(time.time() - t_inicio) / 60:.1f} min. Guardado en {CACHE}")

# Baselines: no se entrenan, se calculan con datos hasta T.
lr_diario = logp.diff()
filas = []
for p in REBAL:
    for activo in SYMBOLS:
        media = lr_diario[activo].iloc[p - LOOKBACK_COV + 1:p + 1].mean() * H
        filas.append((idx[p], activo, 'Media histórica', float(media)))
        filas.append((idx[p], activo, 'Paseo aleatorio', 0.0))
PRONOSTICOS = pd.concat([PRONOSTICOS, pd.DataFrame(filas, columns=PRONOSTICOS.columns)], ignore_index=True)

# Rendimiento real a 21 días, solo para evaluar, nunca como insumo.
PRONOSTICOS['real'] = [OBJETIVO[a].loc[f] for a, f in zip(PRONOSTICOS['activo'], PRONOSTICOS['fecha'])]
assert PRONOSTICOS['real'].notna().all()

# Tabla fecha × acción por modelo, para consultar rápido el pronóstico de una fecha.
PRED = {m: g.pivot(index='fecha', columns='activo', values='pred')[SYMBOLS] for m, g in PRONOSTICOS.groupby('modelo')}
def mu_de(modelo, p):
    """Rendimiento esperado simple a 21 días de cada acción, pronosticado en la fecha p."""
    return np.expm1(PRED[modelo].loc[idx[p]])

PRONOSTICOS.head()
Salida
Ridge                        listo en   0.0 min
Gradient Boosting            listo en   0.5 min
LSTM 1 capa                  listo en   4.5 min
LSTM 3 capas                 listo en   7.4 min
GRU 3 capas                  listo en   8.1 min
LSTM 3 capas, solo precio    listo en   9.6 min
Total: 30.1 min. Guardado en pronosticos_f391c179fe.pkl

¿Qué modelo pronostica mejor?

Decisión clave

¿Con qué métrica decides qué modelo es mejor?

Error de pronóstico (MAE, RMSE): fácil de leer, pero no dice si el modelo le gana a no hacer nada ni si ordena bien las acciones.
Métricas relativas: R² fuera de muestra contra el paseo aleatorio e IC, que miden si el modelo aporta algo sobre la alternativa simple.

Se eligió: cuatro métricas de pronóstico, porque cada una responde algo distinto, y una segunda capa de negocio en la Etapa 7: el Sharpe del portafolio, fuera de muestra y con costos. Son para audiencias distintas.

¿Y si tu caso es distinto? Si tu pronóstico alimenta un optimizador de portafolio, el IC suele importar más que el MAE: no hace falta acertar el número exacto, hace falta ordenar bien qué acción va a rendir más.

  • MAE: error absoluto medio del rendimiento a 21 días. Es la más fácil de leer.
  • R² fuera de muestra contra paseo aleatorio: 1 − error del modelo / error de pronosticar cero. Es la métrica estándar en la literatura de predicción de rendimientos; un valor negativo significa que pronosticar cero habría sido mejor.
  • Acierto de dirección: qué tan seguido el modelo acierta si la acción sube o baja.
  • IC, coeficiente de información: correlación de rangos entre pronóstico y resultado entre las 10 acciones de cada fecha. Es la que más le importa a un portafolio.
Python «métricas de pronóstico»
def metricas_pronostico(df):
    err = df['pred'] - df['real']
    sse0 = (df['real'] ** 2).sum()
    ic_fecha = df.groupby('fecha').apply(
        lambda g: spearmanr(g['pred'], g['real'])[0] if g['pred'].nunique() > 1 else np.nan)
    return pd.Series({
        'MAE': err.abs().mean(),
        'R² vs paseo aleatorio': 1 - (err ** 2).sum() / sse0,
        'Acierto de dirección': (np.sign(df['pred']) == np.sign(df['real'])).mean() if df['pred'].abs().sum() > 0 else np.nan,
        'IC medio': ic_fecha.mean(),
        'IC t-stat': ic_fecha.mean() / ic_fecha.std() * np.sqrt(ic_fecha.notna().sum()) if ic_fecha.notna().sum() > 2 else np.nan,
    })

ORDEN = BASELINES + list(MODELOS)
MET_PRON = PRONOSTICOS.groupby('modelo').apply(metricas_pronostico).loc[ORDEN]
seg = pd.DataFrame(LOG_ENTRENAMIENTO).groupby('modelo')['segundos'].sum()
MET_PRON['Minutos de entrenamiento'] = [seg.get(m, 0) / 60 for m in MET_PRON.index]
MET_PRON.style.format({'MAE': '{:.2%}', 'R² vs paseo aleatorio': '{:+.3f}', 'Acierto de dirección': '{:.1%}',
                       'IC medio': '{:+.3f}', 'IC t-stat': '{:+.2f}', 'Minutos de entrenamiento': '{:.1f}'}, na_rep='—')
Tabla 2. Métricas de pronóstico de cada modelo sobre 170 pronósticos (17 fechas × 10 acciones). Lee primero el R² fuera de muestra contra el paseo aleatorio: positivo significa que el modelo le gana a pronosticar rendimiento cero. Después el IC y su estadístico t: por debajo de 2 en valor absoluto, ordenar bien las acciones no se distingue del azar con esta muestra. La fila resaltada es el modelo original del proyecto.
ModeloMAER²DirecciónICt del ICMinutos
Media histórica8.35%−0.09854.7%+0.065+0.570.0
Paseo aleatorio8.35%+0.000———0.0
Ridge8.39%+0.01355.9%+0.072+0.950.0
Gradient Boosting8.13%+0.06460.0%−0.065−1.130.5
LSTM 1 capa8.53%+0.05254.1%+0.034+0.424.5
LSTM 3 capas8.81%−0.04655.3%−0.010−0.117.4
GRU 3 capas8.81%+0.02854.1%−0.000−0.008.1
LSTM 3 capas, solo precio8.09%+0.15555.3%+0.077+0.979.6
Dos gráficas de barras por modelo: a la izquierda el R² fuera de muestra contra el paseo aleatorio, a la derecha el IC medio
Figura 11. Izquierda: las barras a la derecha del cero le ganan a pronosticar rendimiento cero. Derecha: las barras a la derecha del cero ordenan las acciones mejor que el azar. El verde es el LSTM de 3 capas del proyecto, el único de los seis modelos que queda a la izquierda del cero en las dos; la versión solo con precio, la última barra, es la mejor en ambas.

Las dos decisiones del modelo se leen directo de la Tabla 2 y la Figura 11: sin indicadores, el mismo LSTM pasó de −0.046 a +0.155 de R²; con una sola capa, a +0.052. Ridge, sin ninguna memoria, ordena las acciones (IC de +0.072) casi igual que el mejor modelo. Cinco de los seis modelos le ganan al paseo aleatorio en R², pero ninguno ordena las acciones con un IC distinguible del azar: el estadístico t más alto es 0.97.

Checkpoint «Pronóstico»

  • Cada scaler se ajustó con filas anteriores a la validación y a T: 360 entrenamientos auditados
  • Todo pronóstico se hizo en una fecha igual o posterior al reentrenamiento que lo produjo
  • Cada modelo tiene 17 × 10 pronósticos, y comparaste todos contra la media histórica y el paseo aleatorio antes de elegir

Sigue: Etapa 6 · Selección de activos con CAPM

Etapa 6 de 7

Selección de activos con CAPM

Deja pasar al optimizador solo las acciones cuyo pronóstico paga lo que su riesgo exige.

El Modelo de Valoración de Activos de Capital (CAPM) dice cuánto rendimiento hay que exigirle a una acción por el riesgo de mercado que trae: rendimiento exigido = tasa libre de riesgo + beta × prima de mercado. La regla de filtro compara ese mínimo contra lo que el modelo pronostica: una acción entra al optimizador solo si su rendimiento pronosticado supera su rendimiento exigido, es decir, si tiene alfa positivo. Todo se mide en el mismo horizonte de 21 días, y beta, tasa y prima se calculan con datos hasta T.

Decisión clave

¿Incluir todos los activos en Markowitz o filtrar antes con CAPM?

Todos los activos: Markowitz puede asignar peso cero a los malos por sí solo, pero el optimizador se vuelve más sensible a errores de estimación.
Filtrar con CAPM: reduce el universo a activos con premio por riesgo justificado según el pronóstico.

Se eligió: filtrar primero, como en la versión original. Aquí se corren las dos opciones, con el mismo modelo y el mismo objetivo.

¿Y si tu caso es distinto? Si tu universo ya tiene menos de 10 activos, pregúntate qué tan justificado sigue estando el filtro. Y sin un benchmark de mercado claro, por ejemplo con cripto o activos alternativos, este es el primer paso que hay que repensar.

Explorador de decisiones — prueba tú esta alternativa 2 min

¿Incluir todos los activos en Markowitz o filtrar antes con CAPM?

Si menos de 3 acciones pasan, entran las 3 de mayor alfa, para que siempre haya algo que diversificar. En el backtest pasó en el 18% de las fechas de decisión.

Python «beta, rendimiento exigido y filtro por alfa»
LOG_PORTAFOLIO = []

def capm_en(p):
    """Beta, tasa y prima de mercado con datos hasta la posición p, en horizonte H."""
    v = slice(p - LOOKBACK_BETA + 1, p + 1)
    rf_v = rf_diaria.iloc[v]
    ex_m = ret_mkt.iloc[v] - rf_v
    betas = pd.Series({s: np.cov(ret_simple[s].iloc[v] - rf_v, ex_m)[0, 1] / ex_m.var() for s in SYMBOLS})
    prima_diaria = (ret_mkt.iloc[1:p + 1] - rf_diaria.iloc[1:p + 1]).mean()   # toda la historia hasta T
    rf_H = rf_diaria.iloc[p] * H
    exigido = rf_H + betas * prima_diaria * H
    return betas, exigido, rf_H, ret_mkt.index[v][-1]

def filtro_capm(mu, exigido):
    alfa = mu - exigido
    sel = alfa[alfa > 0].index.tolist()
    forzado = len(sel) < MIN_ACTIVOS
    if forzado:
        sel = alfa.sort_values(ascending=False).index[:MIN_ACTIVOS].tolist()
    return [s for s in SYMBOLS if s in sel], forzado

betas0, exigido0, rf0, _ = capm_en(REBAL[0])
print(f"Ejemplo en {idx[REBAL[0]].date()}: tasa libre de riesgo a 21 días = {rf0:.2%}")
pd.DataFrame({'beta': betas0, 'exigido a 21 días': exigido0}).style.format({'beta': '{:.2f}', 'exigido a 21 días': '{:.2%}'})
Salida
Ejemplo en 2023-01-03: tasa libre de riesgo a 21 días = 0.35%
Tabla 3. Beta y rendimiento exigido por CAPM a cada acción el 3 de enero de 2023, a 21 días. Una beta mayor que 1 significa que la acción amplifica los movimientos del mercado, y por eso se le exige más: NVDA, con beta de 2.21, necesitaba un pronóstico de al menos 1.79% en el mes para entrar; JNJ, con 0.30, solo de 0.55%.
AcciónBetaExigido a 21 días
AAPL1.311.21%
MSFT1.281.19%
GOOGL1.341.23%
AMZN1.641.42%
TSLA1.761.50%
META1.741.49%
NFLX1.661.43%
NVDA2.211.79%
JPM0.880.93%
JNJ0.300.55%

Error común

Usar una tasa libre de riesgo fija. Con un 2% fijo, en 2023 el umbral de CAPM habría quedado muy por debajo de la tasa real del mercado, que al inicio del backtest era de 4.26% anual, y el filtro dejaría pasar acciones que no pagaban ni lo que un bono sin riesgo. Descarga la tasa del periodo y exprésala en el mismo horizonte que el pronóstico.

Punto de adaptación: Si tu universo no tiene un índice de mercado claro contra el cual calcular beta (por ejemplo, activos alternativos o criptomonedas), este es el paso que necesitas repensar primero «no lo asumas, decide qué benchmark usarás antes de avanzar a Markowitz».

Checkpoint «CAPM»

  • Toda beta se calcula con un año de datos que termina en T
  • La tasa libre de riesgo es la del T-bill del periodo, en el mismo horizonte de 21 días: 0.35% en la primera fecha
  • Sabes en qué porcentaje de fechas pasaron menos de 3 acciones: 18%

Sigue: Etapa 7 · Markowitz y backtest de portafolios

Etapa 7 de 7

Markowitz y backtest de portafolios

Reparte el dinero entre las acciones que pasaron el filtro y mide, mes a mes y con costos, qué habría pasado.

  • Covarianza con Ledoit-Wolf. Con 252 días y hasta 10 acciones, la covarianza muestral es ruidosa. Ledoit-Wolf la encoge hacia una estructura más estable y es la práctica estándar para no sobreajustar el optimizador.
  • Todo en el mismo horizonte. Rendimientos esperados, covarianza y tasa libre de riesgo se expresan a 21 días. Mezclar volatilidad diaria con rendimiento mensual haría que cualquier Sharpe no tuviera sentido.
  • Solo posiciones largas. Pesos entre 0 y 1 que suman 1.

Decisión clave

¿Minimizar volatilidad o maximizar el ratio de Sharpe?

Mínima volatilidad: más conservador y numéricamente más estable; es el punto más a la izquierda de la Frontera Eficiente.
Máximo Sharpe: busca el mejor balance entre retorno y riesgo, pero es más sensible a errores en los rendimientos esperados.

Se eligió: mínima volatilidad, como en la versión original. Aquí se corren las dos, más pesos iguales como referencia.

¿Y si tu caso es distinto? Si tus rendimientos esperados vinieran de guía oficial de la empresa en lugar de una predicción, valdría la pena reconsiderar el objetivo. Cambiarlo es un ajuste local dentro de la llamada a scipy.optimize.minimize.

Explorador de decisiones — prueba tú esta alternativa 2 min

¿Minimizar volatilidad o maximizar el ratio de Sharpe?

Insight de experto: un detalle que cambia la lectura de todo el proyecto. El portafolio de mínima volatilidad depende solo de la covarianza, no de los rendimientos esperados. Si el optimizador minimiza volatilidad sobre todas las acciones, el pronóstico del modelo no mueve ni un peso. El pronóstico solo influye si decide qué acciones entran, que es lo que hace el filtro CAPM, o si el objetivo lo usa, que es lo que hace el máximo Sharpe. La verificación de abajo lo comprueba con dos pronósticos distintos.

Python «Ledoit-Wolf, mínima volatilidad y máximo Sharpe»
def cov_en(p, activos):
    v = slice(p - LOOKBACK_COV + 1, p + 1)
    R = ret_simple[activos].iloc[v]
    assert R.index[-1] == idx[p] and R.notna().all().all()
    return LedoitWolf().fit(R.values).covariance_ * H

def _resolver(fun, n):
    cons = ({'type': 'eq', 'fun': lambda w: np.sum(w) - 1},)
    mejor = None
    for w0 in [np.repeat(1 / n, n), np.random.default_rng(SEED).dirichlet(np.ones(n))]:
        r = minimize(fun, w0, method='SLSQP', bounds=[(0, 1)] * n, constraints=cons,
                     options={'ftol': 1e-12, 'maxiter': 1000})
        if r.success and (mejor is None or r.fun < mejor.fun):
            mejor = r
    assert mejor is not None, "El optimizador no convergió"
    w = np.clip(mejor.x, 0, None)
    return w / w.sum()

def min_vol(S):
    return _resolver(lambda w: w @ S @ w, len(S))

def max_sharpe(mu, S, rf):
    if np.max(mu) <= rf:
        return min_vol(S), True          # ningún activo supera la tasa: Sharpe máximo no está definido
    return _resolver(lambda w: -(w @ mu - rf) / np.sqrt(w @ S @ w), len(S)), False

# Verificación de la lección: mínima volatilidad sin filtro ignora el pronóstico.
p_chk = REBAL[0]
S_chk = cov_en(p_chk, SYMBOLS)
w_mv_chk = min_vol(S_chk)   # min_vol solo recibe la covarianza: no hay dónde meter un pronóstico
ws_a, _ = max_sharpe(mu_de(MODELO_PROYECTO, p_chk).values, S_chk, rf_diaria.iloc[p_chk] * H)
ws_b, _ = max_sharpe(mu_de('Media histórica', p_chk).values, S_chk, rf_diaria.iloc[p_chk] * H)
print("Mínima volatilidad: mismos pesos con cualquier pronóstico, porque solo depende de la covarianza")
print("   ", dict(zip(SYMBOLS, np.round(w_mv_chk, 3))))
print("Máximo Sharpe con pronóstico LSTM vs media histórica: diferencia total de pesos =", round(float(np.abs(ws_a - ws_b).sum()), 3))
Salida
Mínima volatilidad: mismos pesos con cualquier pronóstico, porque solo depende de la covarianza
    {'AAPL': np.float64(0.0), 'MSFT': np.float64(0.043), 'GOOGL': np.float64(0.024), 'AMZN': np.float64(0.0), 'TSLA': np.float64(0.011), 'META': np.float64(0.0), 'NFLX': np.float64(0.0), 'NVDA': np.float64(0.0), 'JPM': np.float64(0.153), 'JNJ': np.float64(0.769)}
Máximo Sharpe con pronóstico LSTM vs media histórica: diferencia total de pesos = 2.0

La Frontera Eficiente con el pronóstico del modelo

La Figura 12 muestra la frontera en la última fecha de decisión, con los rendimientos que pronosticó el LSTM de 3 capas. Es la versión con datos reales de la Figura 3.

Frontera eficiente al 6 de mayo de 2024: cada acción como un punto de volatilidad y rendimiento pronosticado, con la curva de la frontera, el portafolio de mínima volatilidad y el de máximo Sharpe
Figura 12. Cada punto es una acción: hacia la derecha, más volatilidad; hacia arriba, más rendimiento según el pronóstico del LSTM, anualizado. La curva es la Frontera Eficiente; la estrella marca el portafolio de mínima volatilidad y el diamante el de máximo Sharpe. Fíjate en la escala: pronósticos de +140% o −110% anual para una sola acción muestran qué tan extremos pueden ser los insumos que recibe el optimizador.

Backtest de portafolios

Cada estrategia se construye en cada fecha de decisión con la información disponible ese día, se mantiene 21 días y se mide con el rendimiento real de esos días.

  • Referencias que no usan pronóstico: S&P 500, pesos iguales y mínima volatilidad sobre las 10 acciones.
  • Por cada modelo de pronóstico: filtro CAPM + mínima volatilidad, que es el pipeline original del proyecto; máximo Sharpe sin filtro, y filtro CAPM + máximo Sharpe.
  • Costos: cada rebalanceo paga 10 puntos base por unidad de rotación. Una estrategia que cambia todo cada mes paga más.
  • Métricas: rendimiento y volatilidad anualizados, Sharpe en exceso de la tasa libre de riesgo con su intervalo de confianza, caída máxima, rotación y número de activos con peso.
Python «backtest de una estrategia» (fragmento)
def correr_backtest(estrategia, modelo=None):
    diarios, rotaciones, n_activos, max_peso = [], [], [], []
    w_prev = pd.Series(0.0, index=SYMBOLS)
    for p in REBAL:
        w, info = pesos_estrategia(estrategia, modelo, p)
        assert abs(w.sum() - 1) < 1e-6 and (w >= -1e-9).all(), "Pesos inválidos"
        R = ret_simple.iloc[p + 1:p + H + 1]              # el periodo de tenencia empieza después de T
        assert R.index[0] > idx[p]
        crecimiento = (1 + R).cumprod()
        valor = (crecimiento * w).sum(axis=1)
        r = valor.pct_change()
        r.iloc[0] = valor.iloc[0] - 1
        rot = float((w - w_prev).abs().sum())
        r.iloc[0] -= COSTO_TRANSACCION * rot
        diarios.append(r)
        rotaciones.append(rot); n_activos.append(int((w > 1e-4).sum())); max_peso.append(float(w.max()))
        w_prev = (w * crecimiento.iloc[-1]) / (w * crecimiento.iloc[-1]).sum()   # pesos a la deriva al cierre
        LOG_PORTAFOLIO.append({'estrategia': estrategia, 'modelo': modelo, 'p': p,
                               'fin_beta': info.get('fin_beta'), 'inicio_tenencia': R.index[0],
                               'forzado': info.get('forzado', False),
                               'sharpe_indefinido': info.get('sharpe_indefinido', False),
                               'pesos': w.round(4).to_dict()})
    serie = pd.concat(diarios)
    return serie, dict(rotacion=np.mean(rotaciones[1:]), n_activos=np.mean(n_activos), max_peso=np.mean(max_peso))
Salida
24 estrategias evaluadas sobre 357 días fuera de muestra

Error común

Evaluar el portafolio con los mismos datos con los que se optimizó. Si optimizas y evalúas sobre el mismo periodo, los resultados son ilusoriamente buenos. Aquí cada periodo de tenencia empieza el día después de T, y un assert lo comprueba en cada una de las 17 fechas.

Tabla 4. Resultados de las estrategias principales del 4 de enero de 2023 al 5 de junio de 2024, con costos de 10 puntos base por rotación. El Sharpe mide el rendimiento en exceso de la tasa libre de riesgo por unidad de volatilidad. Antes de comparar dos Sharpe, mira su intervalo: si se traslapan mucho, la diferencia no está demostrada. La fila resaltada es el pipeline original del proyecto; la tabla completa, con las 24 estrategias, está en el notebook.
EstrategiaRend. anualVolat. anualSharpeIC 95% del SharpeCaída máx.P(> pesos iguales)Activos
Pesos iguales78.5%20.2%2.721.20 a 4.41−11.2%—10.0
S&P 50026.8%12.6%1.550.01 a 3.22−10.3%1%—
LSTM 3 capas, solo precioCAPM + máx. Sharpe128.4%37.0%2.280.32 a 4.18−21.3%23%2.6
LSTM 3 capasCAPM + máx. Sharpe66.4%34.3%1.50−0.54 a 3.27−27.4%2%2.2
LSTM 3 capasCAPM + mín. volatilidad24.4%18.2%1.01−0.55 a 2.55−19.6%0%3.3
Media históricaCAPM + mín. volatilidad19.1%14.8%0.91−0.84 a 2.75−13.7%2%4.1
Mín. volatilidad, 10 acciones7.0%12.6%0.19−1.62 a 2.01−13.7%0%6.2

El intervalo del Sharpe es ancho a propósito: 18 meses son pocos para separar habilidad de suerte. La columna P(> pesos iguales), la probabilidad de que el Sharpe de la estrategia supere al de pesos iguales, resume lo mismo en un número; 50% significaría que, con esta muestra, una estrategia no se distingue de repartir el dinero en partes iguales.

Valor de 100 invertidos en seis estrategias de enero de 2023 a junio de 2024, con costos: S&P 500, pesos iguales, mínima volatilidad, media histórica con CAPM, LSTM con CAPM y LSTM con máximo Sharpe
Figura 13. Valor de 100 dólares invertidos en cada estrategia al inicio del periodo fuera de muestra, ya con costos. La línea más alta no es necesariamente la mejor: compara también qué tan bruscas son sus caídas, como las del máximo Sharpe (rojo punteado). El pipeline del proyecto (verde) termina cerca del S&P 500 (marino), y pesos iguales (azul) termina por encima de todas.

Las dos decisiones del portafolio se leen de la Tabla 4: el filtro CAPM subió el Sharpe de 0.19 a 1.01, porque es la única vía por la que el pronóstico llega a los pesos, y máximo Sharpe llegó a 1.50 a cambio de más volatilidad y concentración. Ninguna estrategia con pronóstico superó a pesos iguales, con un Sharpe de 2.72: en el mejor caso, la probabilidad de superarla fue de 23%.

Checkpoint «Portafolio»

  • Todos los pesos suman 1 y ninguno es negativo: 391 portafolios auditados
  • Toda beta termina en T y todo periodo de tenencia empieza después de T
  • Leíste el intervalo de confianza del Sharpe antes de declarar un ganador

Sigue: Hallazgos

Hallazgos

Hallazgos principales

  • El modelo del proyecto no le ganó a no hacer nada. El LSTM de 3 capas con indicadores tuvo un R² fuera de muestra de −0.046: pronosticar rendimiento cero habría sido mejor. La misma red solo con el rendimiento pasado logró +0.155, el mejor de los seis modelos (Tabla 2).
  • Más capacidad no fue mejor. Con tan poca memoria en los rendimientos (Figura 7), la red de 1 capa (+0.052) superó a la de 3 (−0.046), y Ridge, sin memoria, ordenó las acciones casi igual que la mejor red.
  • El pronóstico tiene que llegar a los pesos. Mínima volatilidad sobre las 10 acciones ignora el pronóstico por completo y logró un Sharpe de 0.19. El filtro CAPM lo subió a 1.01, y máximo Sharpe a 1.50.
  • Ninguna estrategia con pronóstico superó a pesos iguales. Repartir en partes iguales logró un Sharpe de 2.72, contra 1.55 del S&P 500 y 1.01 del pipeline original. Con 18 meses fuera de muestra los intervalos son anchos, y ninguna diferencia a favor de un modelo queda demostrada (Tabla 4).
  • Pronosticar rendimientos y no precios evitó el problema de extrapolar fuera del rango de entrenamiento que tenía la primera versión (Figura 8).

Checklist metodológico verificado

Cada punto se verifica con código en el notebook. Si alguno falla, la celda se detiene con un error en vez de dejar pasar un resultado contaminado.

  • 360 entrenamientos auditados: escalado, purga, embargo y orden temporal correctos.
  • 15 recálculos de indicadores sobre series recortadas coinciden con la serie completa.
  • 15 objetivos recalculados desde los precios coinciden.
  • 391 portafolios auditados: beta hasta T, tenencia después de T y pesos válidos.
  • 8 modelos con 17 × 10 pronósticos cada uno.

Limitaciones y trabajo futuro

El proyecto demuestra el criterio correcto: evaluar sin fugas, contra alternativas simples y fuera de muestra. Sus resultados describen este caso, pero todavía no alcanzan para generalizarlos. Para llevarlo a una revista académica o a un sistema que opere dinero real, estos son los cuatro frentes a fortalecer:

  • Universo. Usar los constituyentes históricos del S&P 500 en cada fecha de decisión, no las acciones que hoy sabemos que sobrevivieron y ganaron. Así se elimina el sesgo de supervivencia, que aquí infla el desempeño de cualquier estrategia, empezando por pesos iguales.
  • Periodo. Extender la evaluación fuera de muestra a por lo menos 10 o 15 años e incluir varios regímenes de mercado: alcistas, bajistas, de alta inflación y de crisis. Un resultado que solo se sostiene en un mercado alcista no es un resultado.
  • Robustez. Entrenar cada red con varias semillas y reportar promedio y dispersión; comparar pronósticos con la prueba de Diebold-Mariano y diferencias de Sharpe con la de Ledoit y Wolf; corregir por comparaciones múltiples con el Deflated Sharpe Ratio, y medir la sensibilidad a costos, horizonte e hiperparámetros. Para producción, además, monitorear la deriva de los datos y definir de antemano qué resultado haría retirar el modelo.
  • Encuadre. Presentarlo como una replicación crítica: muchos artículos que combinan LSTM y Markowitz reportan resultados inflados por fugas como las que este proyecto corrige. Si el resultado se mantiene con un universo y un periodo amplios, que un modelo de Machine Learning no supere a pesos iguales es en sí mismo una contribución, en línea con DeMiguel, Garlappi y Uppal (2009).

Lo que realmente aprendiste

Qué te llevas: un modelo solo vale lo que gana contra la alternativa simple. Sin la media histórica, el paseo aleatorio y pesos iguales como vara, un error bajo o una curva que sube parecen un logro.

Qué repetirías: la evaluación walk-forward con purga, y verificar con código cada supuesto de no-fuga antes de mirar un solo resultado.

Qué evitarías: pronosticar precios, y calcular el rendimiento esperado con información que el día de invertir todavía no existía.

¿Listo para adaptarlo a tu propio caso? La siguiente sección es justo para eso «una guía para identificar qué tocar del código antes de bajarlo y ejecutarlo tú mismo». Ir a Replica este proyecto

Parte 3 de 3

Replica este proyecto

Cómo adaptarlo a tu propio caso. Este sitio no ejecuta código «no hay sandbox ni notebook interactivo». Lo que sí puede darte es claridad total sobre qué parte del pipeline tocar cuando bajes el código y lo corras con tus propios datos, tu propio modelo o tu propio objetivo.

Esta sección reutiliza exactamente las mismas bifurcaciones ("¿Y si tu caso es distinto?") que ya viste en cada paso «aquí las juntamos para que las apliques a tu proyecto concreto».

Punto por punto: qué hacer con cada decisión clave

Si ya jugaste con el Explorador de decisiones, esto retoma cada una donde la dejaste — con la recomendación puntual para tu propio caso.

¿Solo precio de cierre o con indicadores técnicos?

Empieza solo con el rendimiento y agrega indicadores uno por uno, midiendo cada vez el R² fuera de muestra contra el paseo aleatorio. Si tu historial es corto, vigila además cuántas filas se pierden por las ventanas móviles.

Ver la decisión original ↑ Volver a explorarla ↻

¿Cuántas capas LSTM y cuántas unidades?

construir_rnn() es la única pieza que reemplazas si cambias de arquitectura; el resto del pipeline solo espera un rendimiento esperado por activo. Antes de agregar capas, compárala contra Ridge con la misma ventana: si no le gana, la memoria no está aportando.

Ver la decisión original ↑ Volver a explorarla ↻

¿Incluir todos los activos en Markowitz o filtrar antes con CAPM?

Sin un benchmark de mercado claro (cripto, activos alternativos), este es el primer paso que hay que repensar: decide el proxy de mercado antes de calcular ninguna beta. Y recuerda que con mínima volatilidad el filtro es la única vía del pronóstico.

Ver la decisión original ↑ Volver a explorarla ↻

¿Minimizar volatilidad o maximizar el ratio de Sharpe?

Cambiar el objetivo es un ajuste local dentro de la llamada a scipy.optimize.minimize; el resto del pipeline no cambia. Si tus rendimientos esperados vinieran de una fuente más confiable que un modelo (guía de la empresa, consenso de analistas), máximo Sharpe gana sentido.

Ver la decisión original ↑ Volver a explorarla ↻

Qué puedes cambiar sin romper nada

  • El universo de activos «cualquier lista de símbolos que exista en Yahoo Finance funciona con el mismo código de descarga: cambia SYMBOLS».
  • El horizonte «H define a la vez el objetivo, el periodo de tenencia y las unidades de covarianza y tasa; cambiarlo de 21 a 63 días reajusta todo el pipeline».
  • La función objetivo de Markowitz «pasar de mínima volatilidad a máximo Sharpe es un cambio local dentro de la llamada a scipy.optimize.minimize».
  • El rango de fechas «FECHA_INICIAL, FECHA_FINAL e INICIO_BACKTEST viven en la celda de configuración; moverlos no afecta la arquitectura del pipeline».
  • Los costos de transacción «COSTO_TRANSACCION está en 10 puntos base; súbelo para ver qué estrategias dependen de rotar mucho».

Qué es más difícil de tocar «y por qué»

  • Agregar una familia de modelos nueva «una red recurrente distinta solo cambia construir_rnn, pero un modelo estadístico como ARIMA o GARCH necesita su propia rama en entrenar_y_predecir, con su propio ajuste y su propia validación temporal». CAPM y Markowitz no se ven afectados, porque solo reciben un número por activo.
  • Cambiar el benchmark de CAPM «si tu universo no tiene un índice de mercado obvio (por ejemplo, activos alternativos), decidir qué usar como referencia es una decisión de dominio, no técnica, y afecta directamente qué acciones sobreviven el filtro».
  • Permitir posiciones en corto «no es solo cambiar una restricción numérica»; cambia la interpretación económica de "peso negativo" y cómo reportas el riesgo del portafolio.

Guía de auto-diagnóstico «antes de bajar el código»

Responde esto para ti mismo primero. No es un cuestionario para entregar «es para que sepas, antes de tocar una línea, en qué parte vas a pasar más tiempo».

1. ¿Tu universo de activos tiene un benchmark de mercado claro?

Investiga: si no cotizan contra un índice reconocido (S&P 500, Nasdaq, etc.), necesitas decidir qué usarás como proxy de "mercado" para calcular beta.

Señal de que vas bien: puedes justificar en una frase por qué ese benchmark representa el riesgo sistemático de tu universo.

2. ¿Tu variable a predecir tiene memoria de corto plazo o es esencialmente ruido?

Investiga: revisa la autocorrelación de tu serie antes de entrenar nada. Si es cercana a cero, un LSTM probablemente no va a superar a una media móvil simple.

Señal de que vas bien: tu validation loss baja de forma consistente y no solo memoriza ruido «si no baja, el problema puede no ser la arquitectura sino la naturaleza del dato».

3. ¿Necesitas restricciones distintas en el optimizador (short-selling, límites por sector, número mínimo de activos)?

Investiga: cada restricción nueva se agrega como una entrada más en la lista constraints de scipy.optimize.minimize «revisa la documentación de SLSQP antes de improvisar la sintaxis».

Señal de que vas bien: el optimizador converge (no lanza error de factibilidad) y los pesos resultantes cumplen tus restricciones nuevas exactamente, no de forma aproximada.

Variantes ya pensadas, por si buscas un punto de partida

  • Variante 1 — Portafolio de criptomonedas. Cambia el universo por los tickers de yfinance para cripto (ej. BTC-USD, ETH-USD). El punto que requiere trabajo real es CAPM: no hay un "mercado" cripto tan establecido como el S&P 500 «investiga qué índice usarías como proxy antes de calcular betas».
  • Variante 2 — Comparar LSTM contra un modelo GARCH. En vez de reemplazar el LSTM, córrelo junto a un modelo GARCH y compara qué estimador de rendimientos/volatilidad produce un mejor ratio de Sharpe out-of-sample. Esta comparación empírica es un buen eje de contribución original si estás pensando en una tesis.
  • Variante 3 — Ampliar a un índice completo con clustering. Para escalar de 10 a 100+ activos sin entrenar cien modelos LSTM, agrupa acciones similares con k-means sobre sus retornos históricos y entrena un modelo por cluster. CAPM y Markowitz siguen funcionando igual una vez que tienes los rendimientos esperados por activo.

Más información y recursos adicionales

Este proyecto no está solo. Combinar pronóstico de rendimientos con Machine Learning y optimización de Markowitz es un área activa de investigación, y estos tres trabajos recientes lo abordan desde ángulos distintos «no son trabajo propio de FuzzyFrog.AI, se enlazan como referencia del nivel y el tipo de solución».

Adáptalo con IA

Lo anterior es la vía manual: tú decides qué tocar y lo escribes. Esta es la vía asistida — un prompt ya armado con el contexto del proyecto, las mismas restricciones que evitaron los errores comunes de cada etapa, y los mismos checkpoints como criterios para verificar lo que el modelo te entregue.

Prompt listo para copiar
CONTEXTO
Voy a adaptar un pipeline de Machine Learning para optimización de carteras de inversión, con estas etapas en este orden: (1) pronóstico del rendimiento logarítmico a 21 días de cada activo, (2) filtro de activos con CAPM: entra solo el activo cuyo pronóstico supera su rendimiento exigido, (3) optimización de pesos con Markowitz (mínima volatilidad o máximo Sharpe) y (4) backtest walk-forward fuera de muestra con costos. Voy a correrlo con mi propio universo de activos, mi propio rango de fechas y, si aplica, mi propio modelo u objetivo de optimización. No cambies el orden de las etapas.

RESTRICCIONES (no las rompas al generar o modificar código)
1. En cada fecha de decisión T, usa solo información disponible hasta T: el escalador se ajusta solo con las filas de entrenamiento, y ninguna muestra entra al entrenamiento si su objetivo termina después de T (purga). La validación interna va separada del ajuste por un embargo igual al horizonte.
2. Pronostica rendimientos, no niveles de precio, y exprésalo todo en el mismo horizonte: rendimiento esperado, covarianza y tasa libre de riesgo.
3. Usa la tasa libre de riesgo del periodo (por ejemplo, el T-bill a 13 semanas), nunca un valor fijo.
4. Compara cualquier modelo contra la media histórica y el paseo aleatorio, y cualquier portafolio contra pesos iguales y el índice de mercado, fuera de muestra y con costos de transacción.

CRITERIOS DE ACEPTACIÓN (verifica esto antes de dar el resultado por bueno)
1. Hay un assert que comprueba que ningún objetivo de entrenamiento termina después de T y que el escalador se ajustó solo con filas anteriores a la validación.
2. Los indicadores recalculados con la serie recortada en T dan el mismo valor en T que con la serie completa.
3. Cada beta se calcula con una ventana que termina en T, y cada periodo de tenencia empieza después de T.
4. Los pesos de cada portafolio suman 1 y ninguno es negativo, salvo que explícitamente hayas pedido permitir posiciones en corto.
5. El reporte incluye R² fuera de muestra contra el paseo aleatorio, IC, y el Sharpe de cada estrategia con su intervalo de confianza.

Pídeme confirmar cada criterio de aceptación uno por uno antes de continuar a la siguiente etapa.

Si ya conoces la metodología SDD (Constitution/Verify), estas mismas restricciones y criterios son tu Constitution y tu paso de Verify — úsalos ahí en vez del prompt simple de arriba.

Ver la guía completa de adaptación con IA →

Siguiente paso real: abre el notebook y córrelo tal cual, con GPU, antes de cambiar nada. Ver el resultado base, con sus tablas y su checklist verificado, es lo que te va a dejar claro, con evidencia propia, exactamente dónde necesitas intervenir.

¿Necesitas más ayuda?

¿Tu tesis requiere análisis de datos o modelos de ML? Trabajemos juntos.

Quiero información

Ejecuta y explora el código

Todo el código de este proyecto, en un notebook que corres tal cual o en un repositorio que puedes clonar y modificar.

¿Te sirvió este proyecto?

Tal vez te interese también