6. Caso práctico 1: red neuronal para una acción
Nivel 11: Redes Neuronales para Trading
Vamos a montar la estrategia completa: descargar precios, construir señales, entrenar una red y ver qué habría rentado.
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
import pandas_ta as pta
from sklearn.preprocessing import StandardScaler
df = yf.download('TSLA', start='2018-01-01', end='2023-01-01')
df = df.dropna()
df = df[['Open', 'High', 'Low', 'Close']]Preparamos las variables de entrada que usará la red para predecir. Definimos: precio máximo menos mínimo, apertura menos cierre, medias móviles de 3, 10 y 30 días, desviación típica de 5 días, el RSI y el Williams %R.
df['O-C'] = df.Open - df.Close
df['H-L'] = df.High - df.Low
df['3day MA'] = df.Close.shift(1).rolling(window=3).mean()
df['10day MA'] = df.Close.shift(1).rolling(window=10).mean()
df['30day MA'] = df.Close.shift(1).rolling(window=30).mean()
df['Std_dev'] = df.Close.rolling(5).std()
df['RSI'] = pta.rsi(df.Close, length=9)
df['Williams %R'] = pta.willr(df.High, df.Low, df.Close, length=7)
df = df.dropna()Fíjate en el shift(1) de las medias móviles: sirve para que la media del día de hoy no incluya el cierre de hoy. Ahora bien, Std_dev, RSI y Williams %R no llevan ese shift, y por tanto usan el cierre del propio día. Si tu intención es decidir la operación al cierre de hoy sobre el cierre de mañana, eso es admisible; si querías decidir en la apertura, tienes fuga de información. Es exactamente el tipo de detalle que separa un backtest honesto de uno inflado, y hay que revisarlo columna por columna.
La variable objetivo es la subida de precio: vale 1 cuando el cierre de mañana es mayor que el de hoy.
df['Price_Rise'] = np.where(df.Close.shift(-1) > df.Close, 1, 0)
df = df.dropna()
X = df.iloc[:, 4:-1]
y = df.iloc[:, -1]
split = int(len(df) * 0.8)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
sc = StandardScaler()
X_train = sc.fit_transform(X_train)
X_test = sc.transform(X_test)El escalado es obligatorio aquí: las medias móviles valen cientos y el RSI va de 0 a 100, así que sin estandarizar las sigmoides saturarían de inmediato. Y observa que el escalador se ajusta solo con el entrenamiento y luego se aplica al test: usar fit_transform sobre el conjunto completo filtraría la media y la desviación del futuro hacia el pasado.
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Dropout
classifier = Sequential()
classifier.add(Dense(units=32, kernel_initializer='uniform',
activation='relu', input_dim=X.shape[1]))
classifier.add(Dense(units=32, kernel_initializer='uniform', activation='relu'))
classifier.add(Dense(units=1, kernel_initializer='uniform', activation='sigmoid'))
classifier.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
model = classifier.fit(X_train, y_train, batch_size=5, epochs=10)Qué significa cada argumento:
- units: número de nodos o neuronas de esa capa.
- kernel_initializer: los valores iniciales de los pesos. Con 'uniform' se inicializan desde una distribución uniforme.
- activation: la función de activación de esa capa. Aquí la unidad lineal rectificada, 'relu'.
- input_dim: número de entradas de la primera capa, igual al número de columnas de las señales. No hace falta en las capas siguientes, porque el modelo ya sabe cuántas salidas produjo la anterior.
- optimizer: 'adam', una extensión del descenso de gradiente estocástico.
- loss: la pérdida a optimizar. Al ser una clasificación binaria usamos entropía cruzada binaria.
- metrics: las métricas evaluadas durante entrenamiento y test. Aquí la exactitud.
La capa de salida usa sigmoide precisamente porque queremos que la predicción sea una probabilidad de que el mercado suba.
y_pred = classifier.predict(X_test)
y_pred = (y_pred > 0.5)
df['y_pred'] = np.nan
df.iloc[(len(df) - len(y_pred)):, -1:] = y_pred.astype(np.float64)
trade_df = df.dropna().copy()
trade_df['Tomorrows Returns'] = np.log(trade_df['Close'] / trade_df['Close'].shift(1))
trade_df['Tomorrows Returns'] = trade_df['Tomorrows Returns'].shift(-1)
trade_df['Strategy Returns'] = np.where(trade_df['y_pred'] == True,
trade_df['Tomorrows Returns'],
-trade_df['Tomorrows Returns'])
trade_df['Cumulative Market Returns'] = np.cumsum(trade_df['Tomorrows Returns'])
trade_df['Cumulative Strategy Returns'] = np.cumsum(trade_df['Strategy Returns'])
plt.figure(figsize=(10, 5))
plt.plot(trade_df['Cumulative Market Returns'], color='r', label='Mercado')
plt.plot(trade_df['Cumulative Strategy Returns'], color='g', label='Estrategia')
plt.legend()
plt.show()La regla de trading es siempre estar dentro: largo si la probabilidad supera 0.5, corto si no. Como los retornos son logarítmicos, sumarlos acumulados es correcto. Lo que no aparece por ninguna parte son las comisiones ni el deslizamiento, y con una señal que puede cambiar de largo a corto cada día eso no es un detalle menor.