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:
Nesse primeiro backtest realizaremos o sistema operacional clássico para essa estratégia, como demostrado na figura acima, com o seguinte conjunto de regras:
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!
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.
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.
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 |
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:
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:
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;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.
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.
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.
O algoritmo a seguir é praticamente o mesmo do backtest da estratégia do Estocástico Lento, com apenas duas exceções:
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
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!
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.
O EV apresenta a seguinte fórmula matemática:
Onde pct_gains
) e 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.
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):
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 |
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.
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:
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!