/ Proyectos / Finanzas / Markowitz con ML
Markowitz con ML para carteras de inversión: LSTM + CAPM + Optimización
Pipeline cuantitativo completo: predicción de precios de activos con redes LSTM, selección de acciones con CAPM y construcción de la Frontera Eficiente con la Teoría Moderna de Carteras de Markowitz.
Resumen del proyectoWiki
¿Qué encontrarás en este proyecto?
- Descarga de precios históricos de 10 acciones tecnológicas y financieras vía Yahoo Finance API.
- Predicción del precio futuro de cada activo con redes LSTM entrenadas con indicadores técnicos (SMA, Bollinger, RSI, autocorrelación).
- Filtrado de activos con el modelo CAPM: beta, costo de equity y selección de acciones con rendimiento esperado atractivo.
- Optimización del portafolio con la Teoría Moderna de Carteras de Markowitz: Frontera Eficiente y portafolio de mínima volatilidad.
Este proyecto integra tres pilares de las finanzas cuantitativas modernas en un solo pipeline: predicción de series temporales con Deep Learning, selección de activos basada en riesgo sistemático y optimización matemática de carteras. El resultado es un portafolio con pesos óptimos que maximiza el retorno ajustado al riesgo según las predicciones del modelo LSTM.
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 históricos desde el 1 de enero de 2020. La red neuronal predice el precio para los próximos 30 días usando los precios de cierre ajustados — un precio que incorpora dividendos, splits y consolidaciones, permitiendo comparaciones históricas precisas.
Arquitectura del pipeline
- Obtención de datos: descarga de precios ajustados de cierre con
yfinancepara 10 acciones desde 2020 a la fecha actual. - Predicción LSTM: entrenamiento de un modelo por acción con indicadores técnicos como features. Los últimos 30 días son el conjunto de prueba; el modelo predice los precios futuros que se usarán como rendimientos esperados.
- Selección CAPM: cálculo de betas con regresión lineal sobre el S&P 500, aplicación del CAPM y filtrado de acciones cuyo retorno esperado ajustado por riesgo supera el exceso de retorno de mercado.
- Optimización Markowitz: cálculo de la matriz de covarianza, trazado de la Frontera Eficiente y obtención de los pesos óptimos que minimizan la volatilidad del portafolio.
Stack tecnológico
- Python 3.10+ — lenguaje principal
- yfinance — descarga de datos históricos de Yahoo Finance
- TensorFlow / Keras — arquitectura LSTM con capas de Dropout y regularización L2
- scikit-learn — MinMaxScaler, StandardScaler, mean_squared_error
- scipy — optimización numérica con minimize (SLSQP) y regresión lineal (stats.linregress)
- pandas / numpy — manipulación de datos y álgebra matricial
- matplotlib — visualización de precios, curvas de pérdida y Frontera Eficiente
Setup del entornoConfiguración del sistema
El proyecto puede ejecutarse en Google Colab o localmente. Se recomienda Colab por el acceso a GPU gratuita para acelerar el entrenamiento de los modelos LSTM, especialmente cuando se entrena un modelo por acción.
Instalación de dependencias
pip install yfinance tensorflow scikit-learn scipy pandas numpy matplotlib
Estructura del proyecto
proyecto-markowitz-lstm/
├── notebooks/
│ ├── 01_obtencion_datos.ipynb
│ ├── 02_prediccion_lstm.ipynb
│ ├── 03_capm_seleccion.ipynb
│ └── 04_markowitz_optimizacion.ipynb
├── models/
│ ├── lstm_AAPL.h5
│ ├── lstm_MSFT.h5
│ └── ... # un modelo por acción
├── data/
│ └── closing_prices.csv # precios descargados y cacheados
└── requirements.txt
Obtención de datos con yfinance
Se descarga el histórico de precios de cierre ajustados para 10 acciones desde el 1 de enero de 2020 hasta la fecha actual. El precio de cierre ajustado incorpora dividendos, splits y consolidaciones, lo que permite comparaciones históricas precisas entre períodos.
from datetime import datetime
import yfinance as yf
import pandas as pd
# Universo de inversión: 10 acciones de alta capitalización
symbols = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA',
'META', 'NFLX', 'NVDA', 'JPM', 'JNJ']
fecha_inicial = "2020-01-01"
fecha_final = datetime.today().strftime('%Y-%m-%d')
# Descargar datos históricos
main_data = yf.download(symbols, start=fecha_inicial, end=fecha_final)
# Extraer precios de cierre ajustados
closing_prices = main_data['Adj Close']
print(f"Período: {fecha_inicial} → {fecha_final}")
print(f"Shape: {closing_prices.shape}")
print(closing_prices.tail(3))
import matplotlib.pyplot as plt
closing_prices.plot(figsize=(12, 6))
plt.title('Precios de Cierre Ajustados — 10 Acciones (2020 a la fecha)')
plt.xlabel('Fecha')
plt.ylabel('Precio de Cierre Ajustado (USD)')
plt.legend(loc='upper left', fontsize=8)
plt.tight_layout()
plt.show()
Deep Learning para series temporalesPredicción de precios con LSTM
Las redes LSTM (Long Short-Term Memory) son especialmente adecuadas para series temporales financieras porque pueden capturar dependencias de largo plazo entre observaciones — algo que las redes neuronales estándar no logran. Se entrena un modelo LSTM independiente por cada acción del universo.
División de datos e indicadores técnicos
Los datos de entrenamiento incluyen todos los registros excepto los últimos 30 días. Los últimos 30 días forman el conjunto de prueba. Sobre cada serie se calculan cuatro indicadores técnicos que enriquecen las features del modelo:
- SMA de 20 días — suaviza las fluctuaciones e identifica la tendencia general. Un valor alto o bajo puede indicar niveles de soporte o resistencia.
- Bandas de Bollinger (20 días, 2σ) — miden la volatilidad del mercado. Precios que tocan las bandas sugieren condiciones de sobrecompra o sobreventa.
- RSI de 14 días — oscila entre 0 y 100. RSI > 70 indica sobrecompra; RSI < 30 indica sobreventa. Útil para anticipar cambios de tendencia.
- Autocorrelación lag-1 — mide si el precio de hoy está correlacionado con el de ayer. Detecta patrones repetitivos y ciclos.
import pandas as pd
import numpy as np
from sklearn.preprocessing import MinMaxScaler
def calculate_technical_indicators(data, stock_name):
"""Calcula SMA, Bandas de Bollinger, RSI y autocorrelación."""
data = pd.DataFrame(data.copy())
# SMA de 20 días
data['SMA_20'] = data[stock_name].rolling(window=20).mean()
# Bandas de Bollinger (20 días, desviación estándar de 2)
rolling_mean = data[stock_name].rolling(window=20).mean()
rolling_std = data[stock_name].rolling(window=20).std()
data['BB_upper'] = rolling_mean + 2 * rolling_std
data['BB_lower'] = rolling_mean - 2 * rolling_std
# RSI de 14 días
delta = data[stock_name].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
RS = gain / loss
data['RSI_14'] = 100 - (100 / (1 + RS))
# Autocorrelación lag-1 (valor escalar — se usa como referencia)
autocorr_value = data[stock_name].autocorr()
return data.dropna()
def preprocess_data(data):
"""Elimina NaNs y normaliza con MinMaxScaler al rango [0, 1]."""
data.dropna(inplace=True)
scaler = MinMaxScaler(feature_range=(0, 1))
scaled_data = scaler.fit_transform(data)
return scaled_data, scaler
Arquitectura de la red LSTM
La red usa un modelo secuencial de Keras con tres capas LSTM apiladas y capas de Dropout para reducir el sobreajuste. La capa densa de salida tiene regularización L2 para penalizar pesos grandes.
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import LSTM, Dense, Dropout
from tensorflow.keras.regularizers import L2
def build_lstm_model(input_shape):
model = Sequential([
# Primera capa LSTM — devuelve secuencias para la siguiente capa
LSTM(units=50, return_sequences=True, input_shape=input_shape),
# Segunda capa LSTM con Dropout del 20%
LSTM(units=50, return_sequences=True),
Dropout(0.2),
# Tercera capa LSTM — solo devuelve la última salida
LSTM(units=50, return_sequences=False),
Dropout(0.1),
# Capa densa de salida con regularización L2
Dense(units=1, kernel_regularizer=L2(0.01))
])
model.compile(optimizer='adam', loss='mean_squared_error')
return model
# Ejemplo: entrenamiento para AAPL
stock = 'AAPL'
test_data = closing_prices[stock].iloc[-30:]
train_data = closing_prices[stock].iloc[:-30]
train_with_ind = calculate_technical_indicators(train_data, stock)
train_processed, scaler = preprocess_data(train_with_ind)
X_train = train_processed[:, :-1]
y_train = train_processed[:, -1]
X_train = X_train.reshape((X_train.shape[0], X_train.shape[1], 1))
model = build_lstm_model(input_shape=(X_train.shape[1], 1))
history = model.fit(
X_train, y_train,
epochs=50,
batch_size=32,
validation_split=0.1,
verbose=1
)
Curvas de aprendizaje
import matplotlib.pyplot as plt
plt.figure(figsize=(14, 5))
plt.plot(history.history['loss'], label='Training Loss')
plt.plot(history.history['val_loss'], label='Validation Loss')
plt.title('Curvas de Aprendizaje — LSTM')
plt.xlabel('Épocas')
plt.ylabel('Pérdida (MSE)')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Entrenamiento en lote: un modelo por acción
Para el pipeline completo se entrena un modelo LSTM por cada acción del universo y se almacenan las predicciones de los últimos 30 días para usarlas como rendimientos esperados en el CAPM y Markowitz.
from sklearn.metrics import mean_squared_error
import numpy as np
models = {}
predictions = {}
errors = {}
for column in closing_prices.columns:
test_data = closing_prices[column].iloc[-30:]
train_data = closing_prices[column].iloc[:-30]
# Indicadores técnicos y normalización
train_with_ind = calculate_technical_indicators(train_data, column)
train_processed, scaler = preprocess_data(train_with_ind)
X_train = train_processed[:, :-1].reshape(-1, train_processed.shape[1]-1, 1)
y_train = train_processed[:, -1]
# Construir y entrenar el modelo
model = build_lstm_model(input_shape=(X_train.shape[1], 1))
model.fit(X_train, y_train, epochs=50, batch_size=32, verbose=0)
# Predicción en test
test_with_ind = calculate_technical_indicators(test_data, column)
test_processed, _ = preprocess_data(test_with_ind)
X_test = test_processed[:, :-1].reshape(-1, test_processed.shape[1]-1, 1)
pred_scaled = model.predict(X_test)
pred = scaler.inverse_transform(
np.concatenate((X_test[:, :, 0], pred_scaled), axis=1))[:, -1]
models[column] = model
predictions[column] = pred
errors[column] = np.sqrt(mean_squared_error(test_data[:len(pred)], pred))
print(f"{column}: RMSE = {errors[column]:.4f}")
print("\nEntrenamiento completado para todas las acciones.")
Filtrado de activos por riesgo sistemáticoSelección con CAPM
Antes de optimizar el portafolio, se filtra el universo de acciones usando el Modelo de Valoración de Activos de Capital (CAPM). La idea central es seleccionar solo las acciones cuyo rendimiento esperado — estimado por las predicciones LSTM — justifica el riesgo sistemático que añaden al portafolio.
Rendimientos esperados desde las predicciones LSTM
Los rendimientos esperados se calculan como el cambio porcentual entre el último precio predicho y el primero: (precio_final - precio_inicial) / precio_inicial. Esto usa la predicción del modelo como estimador del rendimiento futuro en lugar de promedios históricos.
import pandas as pd
er = {} # expected returns
for stock, prices in predictions.items():
er[stock] = (prices[-1] - prices[0]) / prices[0]
# Convertir a Serie de pandas
expected_returns_ = pd.Series(er, name='expected_returns')
print("Rendimientos esperados (basados en predicción LSTM):")
print(expected_returns_.sort_values(ascending=False))
Cálculo de betas y aplicación del CAPM
El beta de cada acción se estima mediante regresión lineal de los rendimientos diarios de la acción contra los rendimientos del S&P 500 (proxy del mercado). Con el beta y una tasa libre de riesgo del 2%, se aplica la ecuación del CAPM para obtener el costo de capital propio de cada acción. Se seleccionan las acciones cuyo costo de equity supera el exceso de retorno del mercado.
import yfinance as yf
import pandas as pd
import numpy as np
from scipy import stats
# Rendimientos históricos de las acciones
data = yf.download(symbols, start=fecha_inicial, end=fecha_final)['Adj Close']
returns = data.pct_change().dropna()
# S&P 500 como proxy del mercado
market_index = yf.download('^GSPC', start=fecha_inicial, end=fecha_final)['Adj Close']
market_index = market_index.pct_change().dropna()
# Parámetros CAPM
rf = 0.02 # Tasa libre de riesgo (supuesto anualizado)
market_return = market_index.mean() * 252 # Rendimiento medio anualizado del mercado
# Calcular beta por acción usando regresión lineal
betas = {}
for ticker in symbols:
slope, intercept, r_value, p_value, std_err = stats.linregress(
market_index, returns[ticker])
betas[ticker] = slope
# Costo de capital propio: CAPM → E(Ri) = Rf + β * (E(Rm) - Rf)
cost_of_equity = {}
for ticker in symbols:
cost_of_equity[ticker] = rf + betas[ticker] * (market_return - rf)
# Seleccionar acciones donde el costo de equity supera el exceso de retorno de mercado
selected_stocks = [t for t in symbols if cost_of_equity[t] > market_return - rf]
print("Betas calculados:")
for t, b in betas.items():
print(f" {t}: β = {b:.4f}")
print(f"\nAcciones seleccionadas por CAPM ({len(selected_stocks)}):")
print(selected_stocks)
Interpretación del CAPM
Teoría Moderna de CarterasOptimización con Markowitz
La Teoría Moderna de Carteras de Harry Markowitz (1952) postula que existe un conjunto de portafolios óptimos que maximizan el rendimiento esperado para cada nivel de riesgo — la Frontera Eficiente. El problema de optimización consiste en encontrar los pesos de asignación que minimizan la volatilidad del portafolio sujeto a un retorno objetivo.
Preparación: rendimientos esperados y matriz de covarianza
import numpy as np
from scipy.optimize import minimize
import yfinance as yf
# Rendimientos esperados solo para las acciones seleccionadas por CAPM
er = {}
for stock, prices in predictions.items():
if stock in selected_stocks:
er[stock] = (prices[-1] - prices[0]) / prices[0]
expected_returns_ = pd.Series(er, name='expected_returns')
print("Rendimientos esperados (acciones seleccionadas):")
print(expected_returns_)
# Matriz de covarianza sobre rendimientos diarios históricos
data_sel = yf.download(selected_stocks, start=fecha_inicial, end=fecha_final)['Adj Close']
daily_returns = data_sel.pct_change().dropna()
cov_matrix = daily_returns.cov()
print(f"\nMatriz de covarianza ({cov_matrix.shape}):")
print(cov_matrix.round(6))
Optimización del portafolio de mínima volatilidad
La función objetivo es la volatilidad del portafolio: √(wT · Σ · w). Se minimiza con SLSQP (Sequential Least Squares Programming) sujeto a que los pesos sumen 1 y sean no negativos (sin posiciones cortas).
import numpy as np
from scipy.optimize import minimize
num_assets = len(selected_stocks)
# Función objetivo: volatilidad del portafolio
def objective(weights):
return np.sqrt(weights.T @ cov_matrix @ weights)
# Restricciones: pesos suman a 1 y son no negativos
constraints = (
{'type': 'eq', 'fun': lambda w: np.sum(w) - 1},
{'type': 'ineq', 'fun': lambda w: w}
)
bounds = tuple((0, None) for _ in range(num_assets))
initial_guess = [1.0 / num_assets] * num_assets
# Optimización
optimized = minimize(objective, initial_guess,
method='SLSQP', bounds=bounds,
constraints=constraints)
w_optimal = optimized.x
print("Pesos óptimos del portafolio (mínima volatilidad):")
for i, symbol in enumerate(selected_stocks):
print(f" {symbol}: {w_optimal[i]:.4f} ({w_optimal[i]*100:.2f}%)")
Funciones auxiliares para la Frontera Eficiente
import pandas as pd
import matplotlib.pyplot as plt
def portfolio_return(weights, returns):
"""Retorno esperado del portafolio: w^T · μ"""
return weights.T @ returns
def portfolio_vol(weights, covmat):
"""Volatilidad del portafolio: √(w^T · Σ · w)"""
return (weights.T @ covmat @ weights) ** 0.5
def minimize_vol(target_return, er, cov):
"""Encuentra los pesos que minimizan volatilidad para un retorno objetivo."""
n = er.shape[0]
init_guess = np.repeat(1/n, n)
bounds = ((0.0, 1.0),) * n
constraints = (
{'type': 'eq', 'fun': lambda w: np.sum(w) - 1},
{'type': 'eq', 'args': (er,),
'fun': lambda w, er: target_return - portfolio_return(w, er)}
)
result = minimize(portfolio_vol, init_guess, args=(cov,),
method='SLSQP', constraints=constraints,
bounds=bounds, options={'disp': False})
return result.x
def plot_ef(n_points, er, cov):
"""Traza la Frontera Eficiente con n_points portafolios óptimos."""
min_ret = max(0, er.min())
target_rs = np.linspace(min_ret, er.max(), n_points)
weights = [minimize_vol(tr, er, cov) for tr in target_rs]
rets = [portfolio_return(w, er) for w in weights]
vols = [portfolio_vol(w, cov) for w in weights]
ef = pd.DataFrame({"Returns": rets, "Volatility": vols})
return rets, ef.plot.line(x="Volatility", y="Returns", style='.-')
# Graficar la Frontera Eficiente
er_array = expected_returns_.values
rets, ax = plot_ef(100, er_array, cov_matrix)
# Calcular y mostrar el portafolio óptimo
retorno_optimo = portfolio_return(w_optimal, er_array)
riesgo_optimo = portfolio_vol(w_optimal, cov_matrix)
ax.scatter(riesgo_optimo, retorno_optimo,
color='red', marker='*', s=200, label='Portafolio Óptimo', zorder=5)
ax.set_xlabel('Volatilidad (Desviación Estándar)')
ax.set_ylabel('Rendimiento Esperado')
ax.set_title('Frontera Eficiente — Teoría de Markowitz')
ax.legend()
print(f"\nPortafolio óptimo:")
print(f" Retorno esperado: {retorno_optimo:.4f} ({retorno_optimo*100:.2f}%)")
print(f" Volatilidad: {riesgo_optimo:.4f} ({riesgo_optimo*100:.2f}%)")
plt.show()
Métricas y resultadosEvaluación
La evaluación se realiza en dos niveles: la calidad de las predicciones LSTM (RMSE por acción) y las métricas financieras del portafolio resultante (retorno esperado, volatilidad y ratio de Sharpe implícito).
Calidad de las predicciones LSTM
import pandas as pd
# Resumen de errores RMSE por acción
error_df = pd.DataFrame({
'Acción': list(errors.keys()),
'RMSE (USD)': [round(v, 4) for v in errors.values()]
}).sort_values('RMSE (USD)')
print(error_df.to_string(index=False))
Tabla comparativa de resultados del portafolio
| Componente | Descripción | Resultado típico | Observaciones |
|---|---|---|---|
| LSTM — Training Loss | Error cuadrático medio en entrenamiento | 0.143 → 0.029 | Descenso consistente en 50 épocas |
| LSTM — Validation Loss | Error cuadrático medio en validación | 0.154 → 0.047 | Fluctuaciones normales; sin sobreajuste severo |
| CAPM — Acciones seleccionadas | Filtrado por costo de equity vs. exceso de mercado | 4–7 de 10 | Varía según el período y condiciones de mercado |
| Portafolio — Retorno esperado | wT · μ sobre acciones seleccionadas | Depende de predicciones LSTM | Basado en cambio % de precio predicho en 30 días |
| Portafolio — Volatilidad óptima | √(wT · Σ · w) en el portafolio de mínima vol. | Mínimo de la Frontera Eficiente | Pesos no negativos — sin posiciones cortas |
Visualización del portafolio óptimo
import matplotlib.pyplot as plt
import numpy as np
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Gráfico de barras: pesos por acción
axes[0].bar(selected_stocks, w_optimal * 100, color='steelblue', edgecolor='white')
axes[0].set_title('Asignación del Portafolio Óptimo')
axes[0].set_ylabel('Peso (%)')
axes[0].set_xlabel('Acción')
axes[0].tick_params(axis='x', rotation=45)
# Gráfico de torta: distribución del portafolio
axes[1].pie(w_optimal, labels=selected_stocks, autopct='%1.1f%%',
startangle=90, pctdistance=0.85)
axes[1].set_title('Distribución del Portafolio')
plt.tight_layout()
plt.show()
Conclusiones y trabajo futuroHallazgos
Hallazgos principales
LSTM captura patrones de corto plazo con precisión razonable
La pérdida de validación desciende de 0.154 a 0.047 en 50 épocas sin sobreajuste significativo. La diferencia entre training loss y validation loss se mantiene pequeña, lo que indica que la arquitectura de tres capas LSTM con Dropout tiene capacidad de generalización adecuada para horizontes de 30 días. Los indicadores técnicos (especialmente RSI y Bandas de Bollinger) enriquecen el poder predictivo respecto a usar solo el precio de cierre.
El CAPM elimina activos con riesgo sistemático no compensado
El filtro CAPM reduce el universo de 10 a 4–7 acciones según las condiciones de mercado. Las acciones con beta muy alto (como TSLA o NVDA en períodos de alta volatilidad) pueden ser excluidas si su exceso de retorno predicho no justifica el riesgo que añaden. Esto protege la cartera de concentrar peso en activos altamente volátiles cuya prima de riesgo no está siendo compensada.
Markowitz concentra el peso en activos de baja covarianza
El optimizador de mínima volatilidad tiende a asignar pesos altos a pares de acciones con baja correlación entre sí — por ejemplo, una acción tecnológica (MSFT o GOOGL) junto con una financiera o defensiva (JPM o JNJ). Esta diversificación matemática es la ventaja central de Markowitz: no se trata de elegir los mejores activos individualmente, sino los que combinados reducen el riesgo global.
Usar predicciones LSTM como rendimientos esperados mejora la relevancia del modelo
El enfoque clásico de Markowitz usa medias históricas de rendimientos como estimadores de retornos esperados futuros — lo cual asume que el pasado predice el futuro directamente. Sustituir esas medias históricas por las predicciones del modelo LSTM introduce una estimación dinámica que reacciona a condiciones recientes del mercado, lo que puede generar portafolios más adaptados al contexto actual.
Limitaciones y trabajo futuro
- El modelo LSTM predice solo precio, no distribución de probabilidad — una extensión con predicción de intervalo de confianza (Bayesian LSTM o Monte Carlo Dropout) cuantificaría la incertidumbre de los rendimientos esperados.
- El horizonte de predicción es fijo en 30 días. Una arquitectura multi-step (predicción de múltiples pasos futuros de forma directa) permitiría ajustar el horizonte del portafolio de manera más flexible.
- No se incorpora rebalanceo periódico del portafolio. En producción, el pipeline debería ejecutarse periódicamente (mensual o trimestralmente) para actualizar predicciones LSTM y pesos óptimos.
- El universo se limita a 10 acciones. Ampliar a un índice completo (S&P 500) requeriría técnicas de clustering para agrupar acciones y entrenar modelos por cluster, reduciendo el costo computacional.
- SHAP aplicado sobre el modelo LSTM permitiría explicar qué indicadores técnicos contribuyen más a cada predicción individual, mejorando la interpretabilidad para el analista.