Trading strategy

This notebook show how grammar-guided genetic programming (G3P) can be used to evolve a trading strategy based on a backtesting library.

References

In [1]:
import alogos as al
import unified_map as um
from backtesting import Backtest, Strategy
from backtesting.lib import crossover

from backtesting.test import SMA, GOOG
<frozen importlib._bootstrap>:219: RuntimeWarning: scipy._lib.messagestream.MessageStream size changed, may indicate binary incompatibility. Expected 56 from C header, got 64 from PyObject
/home/r/programs/dev/miniconda/envs/main/lib/python3.8/site-packages/backtesting/_plotting.py:50: UserWarning:

Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support (e.g. PyCharm, Spyder IDE). Reset with `backtesting.set_bokeh_output(notebook=False)`.

Loading BokehJS ...

Definition of search space and goal

1) Grammar

This grammar defines the search space: a Python program that implements a trading strategy which can be backtested

In [2]:
import alogos as al

ebnf_text = """
PROGRAM = L00 NL L01 NL L02 NL L03 NL L04 NL L05 NL L06 NL L07 NL L08 NL L09 NL L10 NL L11 NL L12 NL L13 NL L14 NL L15 NL L16 NL L17 NL L18 NL L19 NL L20 NL L21
L00 = "class EvoStrat(Strategy):"
L01 = "    n1 = " NUMBER
L02 = "    n2 = " NUMBER
L03 = "    n3 = " NUMBER
L04 = "    n4 = " NUMBER
L05 = ""
L06 = "    def init(self):"
L07 = "        close = self.data.Close"
L08 = "        self.sma1 = self.I(SMA, close, self.n1)"
L09 = "        self.sma2 = self.I(SMA, close, self.n2)"
L10 = "        self.sma3 = self.I(SMA, close, self.n3)"
L11 = "        self.sma4 = self.I(SMA, close, self.n4)"
L12 = ""
L13 = "    def next(self):"
L14 = "        if " CONDITION ":"
L15 = "            " ACTION
L16 = "        if " CONDITION ":"
L17 = "            " ACTION
L18 = "        if " CONDITION ":"
L19 = "            " ACTION
L20 = "        if " CONDITION ":"
L21 = "            " ACTION

NL = "\n"

CONDITION = "crossover(" VAR ", " VAR ")"
VAR = "self.sma1" | "self.sma2" | "self.sma3" | "self.sma4"

ACTION = "self.buy()" | "self.sell()"

NUMBER = NONZERO_DIGIT DIGIT | DIGIT
NONZERO_DIGIT = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
DIGIT = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
"""

grammar = al.Grammar(ebnf_text=ebnf_text)

2) Objective function

The objective function gets a candidate solution (=a string of the grammar's language) and returns a fitness value for it. This is done by 1) executing the string as Python program, which creates a class that implements a trading strategy, and 2) backtest that strategy on chosen data.

In [3]:
def string_to_strategy(string):
    var = dict()
    exec(string, None, var)
    return var['EvoStrat']


def strategy_to_performance(strat):
    bt = Backtest(GOOG, strat, cash=10000, commission=.002, exclusive_orders=True)
    output = bt.run()
    # return output['Return [%]']
    # return output['SQN']
    # return output['Calmar Ratio']
    # return output['Sharpe Ratio']
    return output['Sortino Ratio']
    

def objective_function(string):
    strat = string_to_strategy(string)
    ret = strategy_to_performance(strat)
    return ret

objective_function(grammar.generate_string())
Out[3]:
0.0

Generation of a random solution

Check if grammar and objective function work as intended.

In [4]:
random_string = grammar.generate_string()
print(random_string)
class EvoStrat(Strategy):
    n1 = 17
    n2 = 6
    n3 = 12
    n4 = 5

    def init(self):
        close = self.data.Close
        self.sma1 = self.I(SMA, close, self.n1)
        self.sma2 = self.I(SMA, close, self.n2)
        self.sma3 = self.I(SMA, close, self.n3)
        self.sma4 = self.I(SMA, close, self.n4)

    def next(self):
        if crossover(self.sma4, self.sma1):
            self.sell()
        if crossover(self.sma4, self.sma3):
            self.buy()
        if crossover(self.sma4, self.sma2):
            self.sell()
        if crossover(self.sma3, self.sma3):
            self.sell()
In [5]:
objective_function(random_string)
Out[5]:
0.0

Search for an optimal solution

Evolutionary optimization with random variation and non-random selection is used to find increasingly better candidate solutions.

1) Parameterization

In [6]:
ea = al.EvolutionaryAlgorithm(
    grammar, objective_function, 'max', max_generations=100,
    population_size=100, offspring_size=100, evaluator=um.univariate.parallel.futures, verbose=True)

2) Run

In [7]:
best_ind = ea.run()
Progress         Generations      Evaluations      Runtime (sec)    Best fitness    
..... .....      10               701              25.5             1.863265974213518
..... .....      20               1136             43.4             1.863265974213518
..... .....      30               1548             54.1             2.198948103421615
..... .....      40               2022             66.8             2.386670757620224
..... .....      50               2445             78.7             2.5992968515801067
..... .....      60               2860             89.9             2.6090265723899377
..... .....      70               3276             101.8            2.644879919440584
..... .....      80               3594             110.5            2.644879919440584
..... .....      90               3923             119.4            2.644879919440584
..... .....      100              4255             128.4            2.691465019430955


Finished         100              4255             129.5            2.691465019430955

3) Result

In [8]:
string = best_ind.phenotype
print(string)
class EvoStrat(Strategy):
    n1 = 19
    n2 = 22
    n3 = 7
    n4 = 4

    def init(self):
        close = self.data.Close
        self.sma1 = self.I(SMA, close, self.n1)
        self.sma2 = self.I(SMA, close, self.n2)
        self.sma3 = self.I(SMA, close, self.n3)
        self.sma4 = self.I(SMA, close, self.n4)

    def next(self):
        if crossover(self.sma2, self.sma4):
            self.sell()
        if crossover(self.sma1, self.sma2):
            self.buy()
        if crossover(self.sma1, self.sma4):
            self.buy()
        if crossover(self.sma3, self.sma2):
            self.buy()
In [9]:
objective_function(string)
Out[9]:
2.691465019430955
In [10]:
strat = string_to_strategy(string)
bt = Backtest(GOOG, strat, cash=10000, commission=.002, exclusive_orders=True)
output = bt.run()
bt.plot()
Out[10]:
Row(
id = '1492', …)
In [11]:
output
Out[11]:
Start                     2004-08-19 00:00:00
End                       2013-03-01 00:00:00
Duration                   3116 days 00:00:00
Exposure Time [%]                   98.882682
Equity Final [$]                 360927.10024
Equity Peak [$]                  365017.70024
Return [%]                        3509.271002
Buy & Hold Return [%]              703.458242
Return (Ann.) [%]                    52.30495
Volatility (Ann.) [%]               49.428176
Sharpe Ratio                         1.058201
Sortino Ratio                        2.691465
Calmar Ratio                         1.907394
Max. Drawdown [%]                   -27.42221
Avg. Drawdown [%]                   -4.317802
Max. Drawdown Duration      268 days 00:00:00
Avg. Drawdown Duration       26 days 00:00:00
# Trades                                  193
Win Rate [%]                         54.92228
Best Trade [%]                      49.573968
Worst Trade [%]                    -13.846246
Avg. Trade [%]                       1.884103
Max. Trade Duration          87 days 00:00:00
Avg. Trade Duration          16 days 00:00:00
Profit Factor                        2.761378
Expectancy [%]                       2.157223
SQN                                  3.379335
_strategy                            EvoStrat
_equity_curve                             ...
_trades                        Size  Entry...
dtype: object
In [12]:
output['Return [%]'] / output['Buy & Hold Return [%]']
Out[12]:
4.988598886177177