No último artigo da série de backtests da estratégia de IFR2, testamos diferentes parâmetros do indicador como pontos de entrada. Hoje, analisaremos diferentes pontos de saída, dessa vez baseados no valor do IFR2. Dessa forma, o objetivo será vender no fechamento sempre que o IFR2 do ativo atingir os valores de 50, 60, 70 ou 80. Além disso, incluiremos diferentes parâmetros de stop loss no tempo (3, 5, 7 e 9 períodos).
Importando as bibliotecas e baixando os dados necessários
Para não criarmos nenhum tipo de viés na nossa análise, utilizaremos o mesmo ativo (LREN3.SA) e intervalo de tempo (início de 2015 ao fim de 2020) do último backtest.
# %%capture means we suppress the output
%%capture
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
!pip install yfinance
import yfinance as yf
Criando a função que irá definir os pontos de entrada e saída
Nosso ponto de entrada será o parâmetro clássico de IFR2, caracterizado por valores iguais ou menores que 30. Assim:
Ponto de entrada: o preço de compra (Buy Price) será igual ao preço de fechamento (data["Close"]), se o valor de IFR2 for menor ou igual a 30 (data["IFR2"] <= rsi_parameter_entry);
Ponto de saída: o preço de venda (Sell Price) será igual ao preço de fechamento, se o valor de IFR2 for maior ou igual ao valor estabelecido a partir da variável rsi_parameter_exit.
all_profits, que compreende o lucro de cada operação;
e total_capital, com o capital acumulado após cada operação.
import math
# Create a function to round any number to the smallest multiple of 100
def round_down(x):
return int(math.floor(x / 100.0)) * 100
def backtest_algorithm(data, initial_capital=10000):
# List with the total capital after every operation
total_capital = [initial_capital]
# List with profits for every operation. We initialize with 0 so
# both lists have the same size
all_profits = [0]
ongoing = False
for i in range(0,len(data)):
if ongoing == True:
if ~(np.isnan(data['Sell Price'][i])):
# Define exit point and total profit
exit = data['Sell Price'][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
else:
if ~(np.isnan(data['Buy Price'][i])):
entry = data['Buy Price'][i]
shares = round_down(initial_capital / entry)
ongoing = True
return all_profits, total_capital
A função strategy_test retorna os seguintes valores:
número total de operações (num_operation);
número de operações que deram lucro e prejuízo (gains e losses);
suas respectivas porcentagens (pct_gains e pct_losses);
lucro total (total_profit);
e o drawdown máximo (drawdown).
def strategy_test(all_profits, total_capital):
num_operations = (len(all_profits) - 1)
gains = sum(x >= 0 for x in all_profits)
pct_gains = 100 * (gains / num_operations)
losses = num_operations - gains
pct_losses = 100 - pct_gains
total_profit = sum(all_profits)
# The first value entry in total_capital is the initial capital
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")
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
}
Realizando o backtest
Para o backtest, iremos iterar sobre nossa lista de parâmetros (parameters), definir os pontos de entrada e saída, simular as operações e armazenar o resultado num dicionário (statistics).
O próximo passo é transformar esse dicionário em um dataframe. Para isso, utilizaremos a função pd.DataFrame.from_dict(), na qual será determinado que as chaves corresponderão ao índice (orient='index'). Iremos também limitar os valores a duas casas decimais através da função .round(), melhorando assim, a visualização dos dados mais relevantes.
A seguir, utilizando a mesma função do último backtest (plot_bars), plotaremos o lucro total (pct_profit) e o drawdown máximo (drawdown).
def plot_bars(title, x, y, x_label="IFR2", y_label=None):
fig = plt.figure()
plt.gca().spines['right'].set_visible(False)
plt.gca().spines['top'].set_visible(False)
colors = ["paleturquoise", "mediumturquoise", "darkcyan", "darkslategrey"]
plt.bar(x, y, color=colors)
plt.title(title)
plt.xlabel(x_label)
if y_label != None:
plt.ylabel(y_label)
for i, v in enumerate(y):
plt.text(
x=i,
y=v,
s=str(v),
horizontalalignment='center',
verticalalignment='bottom'
)
x = statistics_df.index.astype(str)
plot_bars(title="Lucro Total %", x=x, y=statistics_df["pct_profit"], x_label="IFR2 de Saída")
plot_bars(title="Drawdown Máx. %", x=x, y=statistics_df["drawdown"], x_label="IFR2 de Saída")
Os gráficos acima nos mostram que a estratégia com a saída no IFR2 igual ou maior a 80 teve o maior lucro e o menor drawdown para o ativo em questão (LREN3). Como já discutido em artigos passados, é de extrema importância que o ativo utilizado para qualquer backtest seja levado em consideração durante a análise técnica.
No período analisado, LREN3 esteve em forte tendência de alta, como podemos observar no gráfico abaixo. Vender o papel apenas com o IFR2 acima de 80, naturalmente nos faz carregar o papel por mais tempo. Uma vez que o ativo subiu consideravelmente no período, é de se esperar que essa estratégia tenha os melhores resultados.
A fins de estudo, tente fazer o mesmo backtest em um papel que apresentou forte desvalorização no período (por exemplo, CIEL3.SA). Te garanto que os resultados serão bem diferentes!
df['Close'].plot(title = "Evolução do preço de LREN3 nos últimos 6 anos", linewidth=0.5)
<matplotlib.axes._subplots.AxesSubplot at 0x7fc415898250>
Testando diferentes stops baseados no tempo
Vamos analisar agora como seriam esses resultados caso tívessemos estabelecido um stop baseado no tempo. Isto é, iremos determinar um máximo de dias para uma operação ocorrer, a fim de se expor menos em um único trade. Para embasar ainda mais nossa análise, testaremos em 4 intervalos de tempo diferentes: 3, 5, 7 e 9 dias.
Nós já testamos esse tipo de stop no segundo artigo dessa série. Portanto, iremos apenas inserir o mesmo código dentro de uma função.
Criando a função com stop
A função algorithm_with_stop irá receber apenas três argumentos:
o dataframe (data);
o máximo de dias que a operação pode levar (max_days);
e o capital inicial alocado (initial_capital), que será R$10.000, por padrão.
def algorithm_with_stop(data, max_days, initial_capital=10000):
# List with the total capital after every operation
total_capital = [initial_capital]
# List with profits for every operation. We initialize with 0 so
# both lists have the same size
all_profits = [0]
days_in_operation = 0
ongoing = False
for i in range(0,len(df)):
if ongoing == True:
days_in_operation += 1
# If any of the following conditions are met, the operation will end
if days_in_operation == max_days or ~(np.isnan(df['Sell Price'][i])):
# Define exit point and total profit
exit = np.where(
~(np.isnan(df['Sell Price'][i])),
df['Sell Price'][i],
df['Close'][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]
total_capital += [current_capital + profit]
ongoing = False
else:
if ~(np.isnan(df['Buy Price'][i])):
entry = df['Buy Price'][i]
shares = round_down(initial_capital / entry)
days_in_operation = 0
ongoing = True
return all_profits, total_capital
Nós testaremos cada stop (3, 5, 7 e 9) para cada parâmetro de IFR2 como alvo (50, 60, 70 e 80), obtendo ao final, 16 combinações de estratégias. Como vamos trabalhar com um número pequeno de parâmetros, utilizaremos um nested loop, que nada mais é do que um loop dentro do outro.
No primeiro, iremos iterar sobre nossa lista de parâmetros de IFR2, a fim de rodar a função que nos retorna os preços de compra e venda da estratégia. Com essas informações, passamos para o segundo loop, o qual itera sobre a lista de stops. Neste rodaremos o algortimo e toda a estatística, que será armazenada no dicionário backtest.
stops = [3, 5, 7, 9]
backtest = {}
for parameter in parameters:
df = strategy_points(
data=df,
rsi_parameter_entry=30,
rsi_parameter_exit=parameter
)
backtest[parameter] = {}
for stop in stops:
all_profits, total_capital = algorithm_with_stop(data=df, max_days=stop)
backtest[parameter][stop] = strategy_test(all_profits, total_capital)
O backtest caracteriza um "dicionário aninhado" (nested dictionary), onde:
as chaves do 1º dicionário são os alvos do IFR2;
as chaves do 2º dicionário são o máximo de dias na operação;
a combinação das duas chaves acima retorna um dicionário com os dados estatísticos para a estratégia.
Dessa forma, para transformá-lo em um dataframe teremos que utilizar uma indexação hierárquica, na qual teremos dois níveis de índice: os alvos em primeiro nível, e os stops em segundo.
backtest_df = pd.DataFrame.from_dict(
{(i,j): backtest[i][j] for i in backtest.keys() for j in backtest[i].keys()},
orient='index'
)
backtest_df
num_operations
gains
pct_gains
losses
pct_losses
total_profit
pct_profit
drawdown
50
3
204
128
63.0
76
37.0
8335.270405
83.352704
11.330522
5
185
130
70.0
55
30.0
11354.376030
113.543760
18.007040
7
180
126
70.0
54
30.0
11780.224609
117.802246
16.622854
9
175
122
70.0
53
30.0
11490.674591
114.906746
16.901341
60
3
196
117
60.0
79
40.0
5376.551819
53.765518
19.410560
5
170
121
71.0
49
29.0
8422.573566
84.225736
17.154408
7
164
120
73.0
44
27.0
11865.434170
118.654342
13.788915
9
157
114
73.0
43
27.0
11368.824673
113.688247
18.928227
70
3
192
108
56.0
84
44.0
7035.383511
70.353835
14.775776
5
162
108
67.0
54
33.0
10244.797516
102.447975
13.321478
7
153
111
73.0
42
27.0
15221.747208
152.217472
12.526994
9
147
107
73.0
40
27.0
13230.715847
132.307158
12.501646
80
3
191
107
56.0
84
44.0
8527.073860
85.270739
15.154275
5
158
102
65.0
56
35.0
13309.181976
133.091820
11.361103
7
140
97
69.0
43
31.0
15596.088219
155.960882
19.545613
9
131
95
73.0
36
27.0
15260.956669
152.609567
15.251329
Através da função .set_names(), iremos rotular ambos níveis de index.
backtest_df.index.set_names(['IFR2 de Saída', 'Stop (dias)'], inplace=True)
backtest_df = backtest_df.round(2)
backtest_df
num_operations
gains
pct_gains
losses
pct_losses
total_profit
pct_profit
drawdown
IFR2 de Saída
Stop (dias)
50
3
204
128
63.0
76
37.0
8335.27
83.35
11.33
5
185
130
70.0
55
30.0
11354.38
113.54
18.01
7
180
126
70.0
54
30.0
11780.22
117.80
16.62
9
175
122
70.0
53
30.0
11490.67
114.91
16.90
60
3
196
117
60.0
79
40.0
5376.55
53.77
19.41
5
170
121
71.0
49
29.0
8422.57
84.23
17.15
7
164
120
73.0
44
27.0
11865.43
118.65
13.79
9
157
114
73.0
43
27.0
11368.82
113.69
18.93
70
3
192
108
56.0
84
44.0
7035.38
70.35
14.78
5
162
108
67.0
54
33.0
10244.80
102.45
13.32
7
153
111
73.0
42
27.0
15221.75
152.22
12.53
9
147
107
73.0
40
27.0
13230.72
132.31
12.50
80
3
191
107
56.0
84
44.0
8527.07
85.27
15.15
5
158
102
65.0
56
35.0
13309.18
133.09
11.36
7
140
97
69.0
43
31.0
15596.09
155.96
19.55
9
131
95
73.0
36
27.0
15260.96
152.61
15.25
Enfim, iremos plotar o lucro e o drawdown para cada combinação de estratégia alvo/stop. Para isso, vamos atualizar a função plot_bars para retornar ambos os gráficos (ax1 e ax2) em uma única imagem (figure). Esta terá como argumentos:
o dataframe (data);
o valor de IFR2 do alvo (ifr2_target);
o nome das colunas que se deseja plotar (columns);
o título do gráfico principal (title);
o subtítulo de cada gráfico (subtitle);
e a legenda do eixo x (x_label).
def plot_bars(data, ifr2_target, columns, subtitles, x_label="Stop (dias)"):
figure, (ax1, ax2) = plt.subplots(2, 1, figsize=(6,8))
figure.subplots_adjust(hspace=.3)
colors = ["paleturquoise", "mediumturquoise", "darkcyan", "darkslategrey"]
ax1.bar(x=data[columns[0]][ifr2_target].index.astype(str), height=data[columns[0]][ifr2_target], color=colors)
ax2.bar(x=data[columns[1]][ifr2_target].index.astype(str), height=data[columns[1]][ifr2_target], color=colors)
plt.suptitle("IFR2 >=" + str(ifr2_target), fontweight="bold")
plt.xlabel("Stop (dias)")
ax1.set_title(subtitles[0])
ax1.spines['right'].set_visible(False)
ax1.spines['top'].set_visible(False)
for i, v in enumerate(data[columns[0]][ifr2_target]):
ax1.text(
x=i,
y=v/2,
s=str(v),
horizontalalignment='center',
verticalalignment='center',
fontdict=dict(fontsize=12)
)
ax2.set_title(subtitles[1])
ax2.spines['right'].set_visible(False)
ax2.spines['top'].set_visible(False)
for i, v in enumerate(data[columns[1]][ifr2_target]):
ax2.text(
x=i,
y=v/2,
s=str(v),
horizontalalignment='center',
verticalalignment='center',
fontdict=dict(fontsize=12)
)
for parameter in parameters:
plot_bars(
data=backtest_df,
ifr2_target=parameter,
columns=["pct_profit", "drawdown"],
subtitles=["Lucro Total %", "Drawdown Máx. %"]
)
Conclusão
Observe que a estratégia com o stop de 7 dias apresentou os maiores lucros, sem diferença significativa no drawdown, para todos os valores de IFR2 de saída. Em contrapartida, o stop de 3 dias performou consideravelmente pior, também em todos os valores de IFR2, sem apresentar melhora consistente do drawdown.
Outro ponto importante é em relação à estratégia com IFR2 de 80, que embora tenha apresentado os maiores lucros com stops mais longos (7 e 9 dias), observou um aumento significativo do drawdown. Isso faz sentido se pensarmos que o êxito da estratégia se apoia no tempo, uma vez que o papel precisa evoluir de um ponto do indicador (<= 30) a outro mais distante (>= 80). Portanto, para estratégias onde o ponto de saída é um valor muito alto de IFR2, trabalhar com um stop baseado no tempo pode não ser a melhor opção.
No próximo artigo, nós iremos analisar como a estratégia se comporta se implementarmos filtros baseados em médias móveis. Fique ligado!