Using Genetic Algorithms to Build Trading Strategies | by Victor Sim | Towards Data Science

Note from Towards Data Science’s editors: While we allow independent authors to publish articles in accordance with our rules and guidelines, we do not endorse each author’s contribution. You should not rely on an author’s works without seeking professional advice. See our Reader Terms for details.
Genetic algorithms are often ignored, as another unsupervised learning algorithm that fails to converge effectively. This is partly true, as genetic algorithms do not use partial derivatives and therefore the training algorithm is less direct. However, genetic algorithms allow for the formulation of solutions that would have been impossible, using traditional gradient-based optimization.
Why is this so? If the gradient descent based model started at local minimum, it would be permanently stuck, as the gradient of the points on each side would be higher. However, due to the more stochastic nature for genetic algorithms it would eventually find the solution.
Additionally, neural networks require labelled data with well-defined loss functions. Genetic algorithms only require data, and a custom loss function (often called a fitness function for genetic algorithms) can be cleverly crafted to optimize certain features. This flexibility makes genetic algorithm stand out from the rest of the unsupervised algorithms.
Genetic algorithms are a form of a brute-force type attack to the problem. The only differences being using the concept of evolution to speed up the process. Here is how the genetic algorithm works:
An agent is generated. It contains a set of weights that are compatible with the neural network.The agent’s fitness value is calculatedRepeat this many times until you have a “population” of agentsSort the population by their fitness, with the agents with the best fitness at the top of the list.“Breed” the top agents together.Repeat until a satisfactory loss value has been reached.
An agent is generated. It contains a set of weights that are compatible with the neural network.
The agent’s fitness value is calculated
Repeat this many times until you have a “population” of agents
Sort the population by their fitness, with the agents with the best fitness at the top of the list.
“Breed” the top agents together.
Repeat until a satisfactory loss value has been reached.
By changing the fitness function and the input data, the genetic algorithm can be applied to literally every problem!
For elementary tasks such as regression and classification, a deep neural network, trained using a gradient-based optimizer will always converge faster than a genetic algorithm.
Genetic algorithm are not good for finding patterns, but rather having a wide scope of view, to find solutions that have not been considered.
Stock trading is complex, as the noise from different sources dilute the true pattern behind the data. Using a genetic algorithm is like taking a shortcut through all of this; disregarding the pattern recognition and the complex analysis. It simply tests different strategies, and finds the best strategy to trade a security. If a genetic algorithm fails to find a good solution this can only be one (or both!) of these two reasons:
The genetic algorithm has not be trained for a long enough period of time
The genetic algorithm has not be trained for a long enough period of time
Genetic algorithm, being a brute-force algorithm, requires a long period of time to narrow down the results. This is a large hurdle to overcome, as the computing power must be very high to overcome this problem
2. The loss function is faulty
This is much more common. The loss function that evaluates the network simply does not encapsulate the goal of the loss function.
Disclaimer: This code has been adapted from the code here to make it compatible to optimize neural networks that create trading strategies.
import randomimport numpy as npdef sigmoid(x):    return 1/(1+np.exp(-x))
These are the most basic dependencies for the project. To make the learning process better, you can add more activation functions for your suitable datasets.
class Agent:            def __init__(self,network):                class neural_network:                    def __init__(self,network):                        self.weights = []                        self.activations = []                        for layer in network:                            if layer[0] != None:                                input_size = layer[0]                            else:                                input_size = network[network.index(layer)-1][1]                            output_size = layer[1]                            activation = layer[2]                            self.weights.append(np.random.randn(input_size,output_size))                            self.activations.append(activation)                    def propagate(self,data):                        input_data = data                        for i in range(len(self.weights)):                            z = np.dot(input_data,self.weights[i])                            a = self.activations[i](z)                            input_data = a                        yhat = a                        return yhat                self.neural_network = neural_network(network)                self.fitness = 0            def __str__(self):                    return 'Loss: ' + str(self.fitness[0])
Each agent has its own set of weights, generated randomly with the architecture of the neural network setup. The argument network is a list that looks like so:
network = [[5,10,sigmoid],[None,1,sigmoid]]
Each term in the list is describing a layer in the Neural Network. The first term in each nested list is the number of input neurons for the layer, the second term is the output and the third the activation function to be applied for the layer.
def generate_agents(population, network):            return [Agent(network) for _ in range(population)]
This is used to generate the agents, as detailed in step 1 of the methodology to run a genetic algorithm.
def fitness(agents,X):            neutral_range = 0            for agent in agents:                profit = 0                qty = 10                yhat = agent.neural_network.propagate(X[1:])                for i in range(len(yhat)):                    if 0.5 + neutral_range > yhat[i] and yhat[i] > 0.5 -neutral_range:                        yhat[i] = None                    elif 1-yhat[i] > yhat[i]-0:                        yhat[i] = 0                    elif 1-yhat[i] < yhat[i]-0:                        yhat[i] = 1                                        correct_trades = []                for i in range(1,len(X)):                    if X[i][3] > X[i-1][3]:                        correct_trades.append(1)                    elif X[i][3] < X[i-1][3]:                        correct_trades.append(0)                for i in range(len(yhat)):                    if yhat[i]:                        if correct_trades[i] == yhat[i]:                            profit += abs(X[i+1][3] - X[i][3])*qty                        elif correct_trades[i] != yhat[i]:                            profit -= abs(X[i+1][3] - X[i][3])*qty                agent.fitness = profit            return agents
This fitness function is a trading simulation, that uses past data to calculate the profits of the model. Each agent generates a list of trades, by outputting a sigmoid value. These values are then rounded to the nearest value. If this value is 0, the simulation will sell the stock. If this value is 1, the simulation will buy the stock.
It then calculates the profits, by comparing the best possible trade to the actual trade.
def selection(agents):            agents = sorted(agents, key=lambda agent: agent.fitness, reverse=False)            print('\n'.join(map(str, agents)))            agents = agents[:int(0.2 * len(agents))]            return agents
This code essentially sorts the agents by their fitness value, from the lowest value to the highest. Keep in mind that the lower the fitness value, the better. It then shortens the list of agents to only the top 20% of the agents.
Step 6| Agent Crossover:
def unflatten(flattened,shapes):            newarray = []            index = 0            for shape in shapes:                size = np.product(shape)                newarray.append(flattened[index : index + size].reshape(shape))                index += size            return newarray                def crossover(agents,network,pop_size):            offspring = []            for _ in range((pop_size - len(agents)) // 2):                parent1 = random.choice(agents)                parent2 = random.choice(agents)                child1 = Agent(network)                child2 = Agent(network)                                shapes = [a.shape for a in parent1.neural_network.weights]                                genes1 = np.concatenate([a.flatten() for a in parent1.neural_network.weights])                genes2 = np.concatenate([a.flatten() for a in parent2.neural_network.weights])                                split = random.randint(0,len(genes1)-1)child1_genes = np.array(genes1[0:split].tolist() + genes2[split:].tolist())                child2_genes = np.array(genes1[0:split].tolist() + genes2[split:].tolist())                                child1.neural_network.weights = unflatten(child1_genes,shapes)                child2.neural_network.weights = unflatten(child2_genes,shapes)                                offspring.append(child1)                offspring.append(child2)            agents.extend(offspring)            return agents
This is the most complex part of the program. In the crossover process two random parents are chosen from within the selected agents. The nested list of weights are reduced to one long list. A splitting point is randomly picked. This determines how much of each parent’s weights the child will be composed of. This child’s weights is then reformatted and the child is added to the list of agents.
def mutation(agents):            for agent in agents:                if random.uniform(0.0, 1.0) <= 0.1:                    weights = agent.neural_network.weights                    shapes = [a.shape for a in weights]flattened = np.concatenate([a.flatten() for a in weights])                    randint = random.randint(0,len(flattened)-1)                    flattened[randint] = np.random.randn()newarray = []                    indeweights = 0                    for shape in shapes:                        size = np.product(shape)                        newarray.append(flattened[indeweights : indeweights + size].reshape(shape))                        indeweights += size                    agent.neural_network.weights = newarray            return agents
Genetic Algorithms are technically “blind” as they have no insight to how the surface plotted by the loss function looks like. This means it is very easy for the genetic algorithm to get stuck at a certain point. Mutation allows for slight, random, infrequent changes to stir the pot and get the system out of local minimas.
def split_sequences(sequences, n_steps):    X, y = list(), list()    for i in range(len(sequences)):        end_ix = i + n_steps        if end_ix > len(sequences)-1:            break        seq_x, seq_y = sequences[i:end_ix, :], sequences[end_ix, :]        X.append(seq_x)        y.append(seq_y)    return np.array(X), np.array(y)np.random.seed(0)            X,y = split_sequences(np.random.randn(1000,1),5)X = np.reshape(X,(X.shape[0],X.shape[1]))network = [[5,10,sigmoid],[None,1,sigmoid]]ga = genetic_algorithmagent = ga.execute(100,100,10000,X,network)
To execute the program we need data. For some reason, the SSL certificate would not let me get financial data from yfinance or alphavantage. I have not been able to run this program, so I just used random numpy arrays as data.
If you want to use your own financial data, the concept would be to have an array of closing price data split it into chunks of size-n.
For reproducibility, this program will set its own seed, making the randomness fixed. This is to decrease the randomness, to allow for the tweaking of parameters like population size, epochs and the neural network architecture. The more interesting parameter here is the length of each chunk of data to be fed into the network. Tweaking this parameter will find the optimum period of time in which the program will work.
As I have said in many other articles of my own, this is merely the framework for what can be done with the concept. There are countless things that you can do to improve my program. Here are a few of them:
Record generation of past agents
Record generation of past agents
By recording the generation of past agents, it prevents the program from regenerating agents that have been generated before.
2. Link the program to a paper trading account
Alpaca is the obvious choice to use a paper trading portfolio. It can make trades quickly, allowing for easy analysis of the program’s performance.
3. Create more trade types
This is the hardest of the three. Add different trades such as stop-loss and put and buy trades, allowing for much more nuanced and layered trading strategies, layering different trade types onto each other, to maximize profits.
If you want to see more of my content, click this link.