6. Case study 1: a neural network for one stock
Level 11: Neural Networks for Trading
Let us build the complete strategy: download prices, construct signals, train a network and see what it would have returned.
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']]We prepare the input variables the network will use to predict. We define: high minus low, close minus open, 3-, 10- and 30-day moving averages, the 5-day standard deviation, the RSI and 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()Notice the shift(1) on the moving averages: it makes sure today's average does not include today's close. That said, Std_dev, RSI and Williams %R do not carry that shift, and therefore use the current day's close. If your intention is to decide the trade at today's close about tomorrow's close, that is acceptable; if you meant to decide at the open, you have a leak. This is exactly the kind of detail that separates an honest backtest from an inflated one, and it has to be reviewed column by column.
The target variable is the price rise: it equals 1 when tomorrow's close is higher than today's.
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)Scaling is compulsory here: the moving averages are in the hundreds and the RSI runs from 0 to 100, so without standardizing, the sigmoids would saturate immediately. And note that the scaler is fitted only on the training data and then applied to the test data: using fit_transform on the whole dataset would leak the mean and standard deviation of the future back into the past.
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)What each argument means:
- units: the number of nodes or neurons in that layer.
- kernel_initializer: the initial values of the weights. With 'uniform' they are initialized from a uniform distribution.
- activation: the activation function of that layer. Here the rectified linear unit, 'relu'.
- input_dim: the number of inputs of the first layer, equal to the number of signal columns. It is not needed in the following layers, because the model already knows how many outputs the previous one produced.
- optimizer: 'adam', an extension of stochastic gradient descent.
- loss: the loss to optimize. Since this is binary classification we use binary cross-entropy.
- metrics: the metrics evaluated during training and test. Here, accuracy.
The output layer uses a sigmoid precisely because we want the prediction to be a probability that the market goes up.
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='Market')
plt.plot(trade_df['Cumulative Strategy Returns'], color='g', label='Strategy')
plt.legend()
plt.show()The trading rule is to always be in the market: long if the probability exceeds 0.5, short otherwise. Since the returns are logarithmic, adding them cumulatively is correct. What appears nowhere at all are commissions and slippage, and with a signal that can flip from long to short every single day, that is not a minor detail.