Backtest da Estratégia 123 Utilizando Python

Por Andressa Monteiro
Em 22/07/2021

No artigo de hoje realizaremos o backtest de uma estratégia que chamou muito a atenção de quem leu o post Quanto Ganhei com Day Trade e Swing Trade no Primeiro Semestre de 2021: a estratégia 123.

O padrão gráfico 123 é caracterizado por três candles em sequência, onde o segundo apresenta a menor mínima entre eles. Veja pela imagem a seguir, onde um padrão 123 foi formado em BBDC4 no dia 13 de Maio:

Padrão 123

Nesse primeiro backtest realizaremos o sistema operacional clássico para essa estratégia, como demostrado na figura acima, com o seguinte conjunto de regras:

  • Timeframe: diário;
  • Tipo de operação: ponta da compra;
  • Ponto de entrada: padrão 123 formado no Éden dos Traders, a entrada ocorrerá no rompimento da máxima do candle sinal;
  • Alvo: amplitude do 123, isto é, a diferença entre a menor mínima e a maior máxima do conjunto dos 3 candles, projetada a partir do rompimento do candle sinal (terceiro candle);
  • Stop: abaixo da mínima do conjunto de 3 candles.

Motivação

A ideia por trás do 123 é comprar um fundo em uma tendência de alta. Um fundo é caracterizado justamente por uma mínima menor que a dos candles vizinhos. Já a condição do "Éden dos Traders" é o que configura a tendência.

Como estamos em tendência de alta, podemos esperar uma perna de alta de tamanho igual ou maior que a perna de baixa. Daí derivamos a amplitude do 123 como alvo. Por fim, o stop é colocado logo abaixo do fundo, pois uma tendência de alta pressupõe fundos mais altos e topos mais altos.

Agora que já desmembramos a estratégia, vamos à análise!

Importando os dados necessários

Como de praxe, importaremos de uma vez todas as bibliotecas necessárias para o código:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt 

Avaliaremos o desempenho da estratégia nos últimos 5 anos de BBDC4.

Os dados a seguir foram extraídos do MetaTrader e o arquivo estará disponível no nosso grupo do Telegram. Basta baixá-lo em seu computador e, através da função read_csv, parseá-lo em um dataframe.

df = pd.read_csv("../data/D1/BBDC4.csv", index_col='datetime')[["open", "high", "low", "close"]]
df.head()
open high low close
datetime
2016-07-18 00:00:00 13.25 13.60 13.22 13.57
2016-07-19 00:00:00 13.49 13.55 13.38 13.53
2016-07-20 00:00:00 13.54 13.66 13.34 13.61
2016-07-21 00:00:00 13.60 13.66 13.28 13.57
2016-07-22 00:00:00 13.49 13.69 13.39 13.61

Com a base de dados em dataframe, o segundo passo é identificar todos os padrões 123 formados nesses últimos 5 anos.

Definindo o candle sinal

Para um candle sinal, sua mínima deve ser maior que a mínima do candle anterior (condition_1) que, por sua vez, deve ter a mínima menor que a do candle anterior a ele (condition_2).

Lembrando que a função shift do pandas seleciona o valor deslocado pelo número de períodos passado como argumento.

Logo, se ambas condições forem respeitadas, o candle é considerado um candle sinal. Armazenaremos essas informações em uma coluna, que será utilizada mais tarde para definirmos o preço de compra das operações.

condition_1 = df["low"] > df["low"].shift(1)
condition_2 = df["low"].shift(1) < df["low"].shift(2)
df["signal"] = condition_1 & condition_2

df.head(10)
open high low close signal
datetime
2016-07-18 00:00:00 13.25 13.60 13.22 13.57 False
2016-07-19 00:00:00 13.49 13.55 13.38 13.53 False
2016-07-20 00:00:00 13.54 13.66 13.34 13.61 False
2016-07-21 00:00:00 13.60 13.66 13.28 13.57 False
2016-07-22 00:00:00 13.49 13.69 13.39 13.61 True
2016-07-25 00:00:00 13.66 13.71 13.41 13.54 False
2016-07-26 00:00:00 13.54 13.62 13.39 13.39 False
2016-07-27 00:00:00 13.48 13.71 13.43 13.47 True
2016-07-28 00:00:00 13.36 13.40 12.78 13.03 False
2016-07-29 00:00:00 12.87 13.36 12.85 13.29 True

Bem simples, não? Porém, não podemos esquecer que nem todos os padrões 123 identificados gerarão uma entrada, uma vez que, além de ter a máxima do candle sinal rompida, eles precisam estar no Éden dos Traders. Então vamos calculá-lo.

OBS: se você ainda não sabe o que é o "Éden dos Traders", leia esse artigo.

Calculando o Éden dos Traders

A função utilizada para o cálculo das médias móveis exponenciais é a mesma do artigo mencionado acima (ewm), com a diferença que especificaremos o número mínimo de candles necessários para o cálculo da média (min_periods).

Por conta disso, removeremos todos os valores NaN através da função dropna. Note, então, que os primeiros 80 data points do nosso intervalo serão descartados.

df["mme8"] = df["close"].ewm(span=8, min_periods=8).mean()
df["mme80"] = df["close"].ewm(span=80, min_periods=80).mean()

df["eden"] = (df["mme8"] > df["mme8"].shift(1)) & (df["mme80"] > df["mme80"].shift(1))

df.dropna(inplace=True)

df.head()
open high low close signal mme8 mme80 eden
datetime
2016-11-09 00:00:00 14.97 15.68 14.97 15.21 False 15.411656 14.482858 False
2016-11-10 00:00:00 15.28 15.43 13.79 13.88 False 15.071288 14.465709 False
2016-11-11 00:00:00 13.82 14.22 13.39 13.90 False 14.811002 14.449678 False
2016-11-14 00:00:00 13.82 14.14 13.71 14.00 True 14.630779 14.436980 False
2016-11-16 00:00:00 14.17 14.46 13.94 14.20 False 14.535051 14.430313 False

Definindo o preço de compra

Para definirmos o preço de compra, observaremos os dados da perspectiva do candle de entrada (candle seguinte ao candle sinal).

Dessa forma, nós só entraremos em uma operação caso três condições sejam respeitadas:

  • condition_1: o candle anterior tem que ser um candle sinal;
  • condition_2: o candle sinal tem que estar no éden dos traders;
  • condition_3: sua máxima tem que ser maior do que a máxima do candle sinal.

Além disso, temos que considerar também que o candle pode abrir em gap (acima do ponto de entrada).

Portanto, o preço de compra será armazenado em uma nova coluna buy_price através da função where com a seguinte lógica:

  • se as três condições a cima forem respeitadas;
  • e se o candle de entrada abrir acima da máxima do candle sinal (df["open"] > df["high"].shift(1)), o preço de compra será igual ao preço de abertura (df["open"]), caso contrário o preço de compra será igual à máxima acrescido de 1 centavo (df["high"].shift(1) + tick), caracterizando o rompimento da máxima do candle sinal;
  • caso nenhuma das condições anteriores forem respeitadas, o valor será preenchido por NaN (np.nan)
condition_1 = df["signal"].shift(1) == True
condition_2 = df["eden"].shift(1) == True 
condition_3 = df["high"] > df["high"].shift(1)
tick = 0.01

df["buy_price"] = np.where(
    condition_1 & condition_2 & condition_3, 
    np.where(df["open"] > df["high"].shift(1), df["open"], df["high"].shift(1) + tick),
    np.nan
)

df.tail()
open high low close signal mme8 mme80 eden buy_price
datetime
2021-07-08 00:00:00 24.49 24.77 24.24 24.59 False 25.129472 24.889629 False NaN
2021-07-12 00:00:00 24.72 25.36 24.69 25.21 True 25.147367 24.897539 True NaN
2021-07-13 00:00:00 25.01 25.40 24.81 25.19 False 25.156841 24.904760 True 25.37
2021-07-14 00:00:00 25.50 25.99 25.11 25.30 False 25.188654 24.914519 True NaN
2021-07-15 00:00:00 25.22 25.36 24.70 24.89 False 25.122287 24.913914 False NaN

Em sequência, vamos definir o preço de saída da operação.

Definindo o alvo

O primeiro passo para calcular o alvo é isolar a menor mínima e a maior máxima de uma janela de 3 períodos e calcular a diferença entre eles. Faremos isso combinando a função rolling com max para isolar o valor máximo, armazenando-o a variável max_high, e min para calcular o valor mínimo (min_low).

O nosso alvo (target) portanto, será a diferença entre esses valores (amplitude) projetada a partir do ponto de entrada (entry), ou seja, somado ao rompimento da máxima do candle sinal.

max_high = df["high"].rolling(3).max()
min_low = df["low"].rolling(3).min()

amplitude = (max_high.shift(1) - min_low.shift(1))
entry = df["high"].shift(1)

df["target"] =  amplitude + entry

df.tail()
open high low close signal mme8 mme80 eden buy_price target
datetime
2021-07-08 00:00:00 24.49 24.77 24.24 24.59 False 25.129472 24.889629 False NaN 25.97
2021-07-12 00:00:00 24.72 25.36 24.69 25.21 True 25.147367 24.897539 True NaN 25.51
2021-07-13 00:00:00 25.01 25.40 24.81 25.19 False 25.156841 24.904760 True 25.37 26.48
2021-07-14 00:00:00 25.50 25.99 25.11 25.30 False 25.188654 24.914519 True NaN 26.56
2021-07-15 00:00:00 25.22 25.36 24.70 24.89 False 25.122287 24.913914 False NaN 27.29

Por fim, temos que estabelecer o stop da operação.

Definindo o stop

O stop será definido no rompimento da menor mínimo do conjunto dos 3 candles, que nesse caso representa a mínima do segundo candle do padrão 123.

df["stop"] = df["low"].shift(2) - tick
df.tail()
open high low close signal mme8 mme80 eden buy_price target stop
datetime
2021-07-08 00:00:00 24.49 24.77 24.24 24.59 False 25.129472 24.889629 False NaN 25.97 24.42
2021-07-12 00:00:00 24.72 25.36 24.69 25.21 True 25.147367 24.897539 True NaN 25.51 24.49
2021-07-13 00:00:00 25.01 25.40 24.81 25.19 False 25.156841 24.904760 True 25.37 26.48 24.23
2021-07-14 00:00:00 25.50 25.99 25.11 25.30 False 25.188654 24.914519 True NaN 26.56 24.68
2021-07-15 00:00:00 25.22 25.36 24.70 24.89 False 25.122287 24.913914 False NaN 27.29 24.80


Pronto! Estabelecemos os principais pontos da nossa estratégia: o preço de compra, o alvo e o stop. Com esses três preços definidos, podemos escrever o algoritmo que irá simular as operações.

Definindo o algoritmo para simular as operações

O algoritmo a seguir é praticamente o mesmo do backtest da estratégia do Estocástico Lento, com apenas duas exceções:

  • o stop e o alvo serão iguais ao valor de sua respectiva coluna;
  • ignoraremos operações que deram entrada e bateram no stop no mesmo dia (já que não sabemos o que aconteceu primeiro). Sendo assim, só continuaremos na operação se a mínima do candle de entrada for maior que o stop (df["low"][i] > stop).
import math

# Create a function to round any number to the smalles multiple of 100
def round_down(x):
    return int(math.floor(x / 100.0)) * 100

def backtest_algorithm(
    df,
    capital_exposure,
    initial_capital):

    # List with the total capital after every operation
    total_capital = [initial_capital]

    # List with profits for every operation
    all_profits = [] 

    ongoing = False

    for i in range(0,len(df)):

        if ongoing == True:

            if (df["open"][i] >= target) | (df["open"][i] <= stop): 
                exit = df["open"][i]

                profit = shares * (exit - entry)
                # Append profit to list and create a new entry with the capital
                # after the operation is complete
                all_profits += [profit]
                current_capital = total_capital[-1] # current capital is the last entry in the list
                total_capital += [current_capital + profit]

                ongoing = False

            elif df["low"][i] <= stop: 
                exit = stop

                profit = shares * (exit - entry)
                # Append profit to list and create a new entry with the capital
                # after the operation is complete
                all_profits += [profit]
                current_capital = total_capital[-1] # current capital is the last entry in the list
                total_capital += [current_capital + profit]

                ongoing = False

            elif df["high"][i] >= target: 
                exit = target

                profit = shares * (exit - entry)
                # Append profit to list and create a new entry with the capital
                # after the operation is complete
                all_profits += [profit]
                current_capital = total_capital[-1] # current capital is the last entry in the list
                total_capital += [current_capital + profit]

                ongoing = False

        else:
            if ~(np.isnan(df["buy_price"][i])):
                entry = df["buy_price"][i]
                stop = df["stop"][i]
                
                if df["low"][i] > stop: 
                    ongoing = True
                    risk = entry - stop
                    target = df["target"][i]
                    shares = round_down(capital_exposure / risk)

    return all_profits, total_capital

Calculando a estatística e a curva de capital

Além do algoritmo acima, utilizaremos também as funções get_drawdown, strategy_test e capital_plot para calcular a estatística e plotar a evolução do capital durante o backtest.

Se você não sabe o que é drawdown, temos um artigo específico sobre que vale a leitura.

Todas as funções a seguir foram elaboradas ao longo dos backtests aqui desenvolvidos.

def get_drawdown(data, column = "Close"):
    data["Max"] = data[column].cummax()
    data["Delta"] = data['Max'] - data[column]
    data["Drawdown"] = 100 * (data["Delta"] / data["Max"])
    max_drawdown = data["Drawdown"].max()
    return max_drawdown
def strategy_test(all_profits, total_capital):
    gains = sum(x >= 0 for x in all_profits)
    losses = sum(x < 0 for x in all_profits)
    num_operations = gains + losses
    pct_gains = 100 * (gains / num_operations)
    pct_losses = 100 - pct_gains
    total_profit = sum(all_profits)
    pct_profit = (total_profit / total_capital[0]) * 100
    
    # Compute drawdown
    total_capital = pd.DataFrame(data=total_capital, columns=["total_capital"])
    drawdown = get_drawdown(data=total_capital, column="total_capital")

    # Compute profit per operation
    profit_per_operation = pct_profit / num_operations

    return {
        "num_operations": num_operations,
        "gains": gains ,
        "pct_gains": pct_gains.round(),
        "losses": losses,
        "pct_losses": pct_losses.round(), 
        "total_profit": total_profit,
        "pct_profit": pct_profit,
        "drawdown": drawdown,
        "profit_per_operation": profit_per_operation
    }
def capital_plot(total_capital, all_profits):
  all_profits = [0] + all_profits # make sure both lists are the same size
  cap_evolution = pd.DataFrame({'Capital': total_capital, 'Profit': all_profits})
  plt.title("Curva de Capital")
  plt.xlabel("Total Operações")
  cap_evolution['Capital'].plot()

Uma vez que já temos todas as funções definidas, vamos ao que interessa: rodar o backtest!

Realizando o backtest

No backtest a seguir, entraremos sempre com um capital fixo de R$100.000 (initial_capital) e um risco controlado de R$1.000 (capital_exposure).

all_profits, total_capital = backtest_algorithm(
    df=df,
    capital_exposure=1000,
    initial_capital=100000)

Em seguida, basta rodar a estatística e plotar a evolução do capital.

Para uma melhor visualização dos dados, vamos transformar o dicionário statistics em um dataframe através da função from_dict.

statistics = strategy_test(all_profits, total_capital)
statistics = pd.DataFrame.from_dict(statistics, orient='index')
statistics.round(2)
0
num_operations 48.00
gains 26.00
pct_gains 54.00
losses 22.00
pct_losses 46.00
total_profit 3352.00
pct_profit 3.35
drawdown 9.52
profit_per_operation 0.07
capital_plot(total_capital, all_profits)

O gráfico não ficou dos melhores, mas pela estatística conseguimos observar que a estratégia obteve uma boa taxa de acerto (54%) e um drawdown aceitável (9.52%). Entretanto, o lucro por operação não foi nada satisfatório (0.07%).

Vamos calular o valor esperado por operação da nossa estratégia para entender esses números. Essa métrica foi dissecada no artigo mencionado na introdução.

Calculando o valor esperado por operação

O EV apresenta a seguinte fórmula matemática:

EV=Pgain×payoff+Ploss×riskEV = P_{gain} \times payoff + P_{loss} \times risk

Onde PgainP_{gain} é a probabilidade de acerto (pct_gains) e PlossP_{loss} é a probabilidade de erro (pct_losses).

Criaremos uma função expected_value para calculá-lo, que irá receber apenas um argumento (all_profits) e retornar um dataframe com os resutados mais importantes: média de ganho, perda e o valor esperado.

def expected_value(all_profits):
    all_positives = [x for x in all_profits if x >= 0]
    average_gain = sum(all_positives) / len(all_positives)

    all_negatives = [x for x in all_profits if x < 0]
    average_loss = sum(all_negatives) / len(all_negatives)

    num_operations = len(all_profits)
    pct_gains = (len(all_positives) / num_operations)
    pct_losses = 1 - pct_gains
    
    expected_value = (average_gain * pct_gains) + (average_loss * pct_losses)
    
    result = pd.DataFrame.from_dict({
        "average_gain": average_gain,
        "average_loss": average_loss,
        "pct_gains": pct_gains,
        "pct_losses": pct_losses,
        "expected_value": expected_value
    }, orient="index")
    result.columns = ["result"]
    return result
ev = expected_value(all_profits)
ev
result
average_gain 965.269231
average_loss -988.409091
pct_gains 0.541667
pct_losses 0.458333
expected_value 69.833333

Como podemos observar pelos valores acima, a relação de ganho e perda da estratégia é praticamente 1:1. Isto é, quando a operação vai pro lucro, ganhamos em média R$965,27 ao passo que quando a operação vai pro prejuízo, perdemos em média R$988,41.

Ao final, obtivemos um EV positivo mas baixo onde o risco-retorno é insatisfatório.

Sendo assim, tentaremos otimizar esse resultado filtrando ainda mais a nossa entrada: entraremos em um operação de padrão 123 somente se o candle sinal for um inside bar.

Backtest com o candle sinal sendo um inside bar

Um candle é dito inside bar quando está contido no candle anterior. Assim, o candle sinal deve apresentar não só mínima menor do que do candle anterior (condition_1), mas também máxima menor (condition_3). Perceba pela figura a seguir (as linhas em azul sinalizam a máxima e mínima do candle sinal):

Padrão 123 com inside bar

Portanto, adicionaremos a condition_3 ao candle sinal:

condition_1 = df["low"] > df["low"].shift(1)
condition_2 = df["low"].shift(1) < df["low"].shift(2)
condition_3 = df["high"] < df["high"].shift(1)
df["signal"] = condition_1 & condition_2 & condition_3

condition_1 = df["signal"].shift(1) == True
condition_2 = df["high"] > df["high"].shift(1)
condition_3 = df["eden"].shift(1) == True 
df["buy_price"] = np.where(
    condition_1 & condition_2 & condition_3, 
    np.where(df["open"] > df["high"].shift(1), df["open"], df["high"].shift(1) + tick),
    np.nan
)

Em seguida, rodaremos novamente todas as funções para nossa análise: o algoritmo, a estatística, a evolução do capital e o valor esperado.

all_profits, total_capital = backtest_algorithm(
    df=df,
    capital_exposure=1000,
    initial_capital=100000)
statistics = strategy_test(all_profits, total_capital)
statistics = pd.DataFrame.from_dict(statistics, orient='index')
statistics.round(2)
0
num_operations 12.00
gains 8.00
pct_gains 67.00
losses 4.00
pct_losses 33.00
total_profit 4546.00
pct_profit 4.55
drawdown 2.20
profit_per_operation 0.38
capital_plot(total_capital, all_profits)
ev = expected_value(all_profits)
ev
result
average_gain 1216.625000
average_loss -1296.750000
pct_gains 0.666667
pct_losses 0.333333
expected_value 378.833333

Comparando os resultados

A tabela a seguir reproduz as duas estratégias:

123 clássico 123 + inside bar
número de operações 48 12
acerto (%) 54 67
erro (%) 46 33
lucro total (R$) 3352 4546
drawdown (%) 9.52 2.20
lucro por operação (%) 0.07 0.38
média de ganho (R$) 965.27 1216.62
média de prejuízo (R$) -988.41 -1296.75
EV por operação (R$) 69.83 378.83

De maneira satisfatória, conseguimos otimizar tanto a taxa de acerto (de 54% para 67%) como o drawdown (de 9.52% para 2.20%) ao adicionarmos a condição de inside bar na estratégia 123.

Apesar de apresentar um lucro por operação 5 vezes maior que o 123 clássico, a estratégia com inside bar teve um número de operações muito baixo (12 em pouco menos de 5 anos). Assim, para que esse sistema operacional seja estatisticamente relevante, seria necessário aplicá-lo em múltiplos ativos simultaneamente.

Em relação ao valor esperado, tivemos uma melhora significativa. Mesmo a média de prejuízo sendo ligeiramente maior que a de ganho, acertamos 2/3 dos trades, o que configura um excelente risco-retorno.

Conclusão

A estratégia 123 tem fundamento mas precisa de melhorias. Ao adicionar a condição do inside bar, encurtamos o nosso alvo e, portanto, aumentamos as chances da operação ir para o ganho.

Como o alvo da estratégia é a amplitude do conjunto dos 3 candles projetada a partir do candle sinal, ele é diretamente influenciado pelo tamanho das barras. Isto é, se o candle sinal for uma barra longa, o alvo fica distante e as chances de sermos estopados na operação aumenta.

Um ponto a se observar na estratégia 123 é que, em casos de abertura em gap, paga-se mais caro para entrar no trade, o que naturalmente diminui o retorno e o valor esperado por operação.

Dessa forma, há algumas variações que serão exploradas em backtests futuros:

  • medir a amplitude do candle sinal e, caso ele seja uma barra longa, estabelecer o stop na sua mínima;
  • no caso de abertura em gap, estabelecer o ponto de entrada no meio do candle sinal e esperar que o candle de entrada o atinja (caso contrário não entramos na operação);
  • calcular a estatística da quantidade de candle sinais acionados e caso esse número seja alto, definir o ponto de entrada no fechamento do terceiro candle (em vez de esperar o rompimento).

Esse foi o primeiro post de uma série de backtests que faremos em cima da estratégia 123. Para acompanhar os próximos artigos, participe do canal QuantBrasil no Telegram e não se esqueça de se inscrever na nossa newsletter!