A estratégia de IFR2 é um modelo de volatilidade, onde geralmente não se leva em consideração a tendência em que o ativo se encontra. Entretanto, se considerarmos que a estratégia se aplica na ponta da compra, operar um ativo em tendência de alta não melhoraria nossos resultados? Pensando nisso, no post de hoje nós faremos o backtest da estratégia de IFR2 utilizando filtros através das médias móveis.
Médias Móveis
A média móvel representa a média dos preços de fechamento dos últimos candles em um determinado período. Por conta disso, ela é muito utilizada na análise técnica para indicar a tendência do papel.
Nós utilizaremos médias móveis mais longas, uma vez que o ponto de entrada da estratégia se dá quando o indicador está abaixo 30, e um papel sobrevendido dificilmente estará em tendência de alta num período mais curto. As médias móveis serão de dois tipos:
aritméticas de 200 e 50 períodos;
e exponencial de 80 períodos.
O backtest
O backtest que implementaremos terá como ponto de entrada:
o preço de fechamento quando o IFR2 for menor ou igual a 30;
e a média móvel estiver subindo.
Testaremos para dois pontos de saída diferentes:
máxima dos dois dias anteriores;
IFR2 maior ou igual a 70 com e sem stop de 7 dias.
Agora que já demos uma geral no conceito de média móvel e na estratégia que iremos testar, vamos trabalhar!
Importando as bibliotecas e baixando os dados necessários
Como de costume, o primeiro passo é importar as bibliotecas de interesse e baixar os dados necessários.
# %%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
[*********************100%***********************] 1 of 1 completed
Calculando o IFR para 2 períodos
Em seguida, calcularemos o IFR para 2 períodos através da nossa função rsi() e , logo depois, adicionaremos os valores calculados à uma nova coluna df["IFR2"].
def rsi(data, column, window=2):
data = data.copy()
# Establish gains and losses for each day
data["Variation"] = data[column].diff()
data = data[1:]
data["Gain"] = np.where(data["Variation"] > 0, data["Variation"], 0)
data["Loss"] = np.where(data["Variation"] < 0, data["Variation"], 0)
# Calculate simple averages so we can initialize the classic averages
simple_avg_gain = data["Gain"].rolling(window).mean()
simple_avg_loss = data["Loss"].abs().rolling(window).mean()
classic_avg_gain = simple_avg_gain.copy()
classic_avg_loss = simple_avg_loss.copy()
for i in range(window, len(classic_avg_gain)):
classic_avg_gain[i] = (classic_avg_gain[i - 1] * (window - 1) + data["Gain"].iloc[i]) / window
classic_avg_loss[i] = (classic_avg_loss[i - 1] * (window - 1) + data["Loss"].abs().iloc[i]) / window
# Calculate the RSI
RS = classic_avg_gain / classic_avg_loss
RSI = 100 - (100 / (1 + RS))
return RSI
df["IFR2"] = rsi(df, column="Close")
df.head()
Open
High
Close
IFR2
Date
2015-01-02
12.497520
12.575206
12.545454
NaN
2015-01-05
12.467768
12.467768
12.196694
NaN
2015-01-06
12.148760
12.457851
12.337190
28.716173
2015-01-07
12.376859
12.661157
12.547107
61.636362
2015-01-08
12.495867
12.644628
12.561983
63.993226
Calculando as Médias Móveis
Além do IFR, vamos calcular as médias móveis, que também serão utilizadas para estabelecer as regras de operação.
Para o cálculo das médias móveis aritméticas de 200 e 50 períodos, utilizaremos a função rolling(). Já a média móvel exponencial de 80 períodos será calculada através da função ewm(). Em ambos os casos, incluiremos a função mean() ao final, que irá enfim calcular a média do conjunto de valores pré-selecionados.
# arithmetic moving average calculation
df["MMA200"] = df["Close"].rolling(200).mean()
df["MMA50"] = df["Close"].rolling(50).mean()
#e xponential moving average calculation
df['MME80'] = df["Close"].ewm(span=80).mean()
df.tail()
Open
High
Close
IFR2
MMA200
MMA50
MME80
Date
2020-12-21
44.259998
45.040001
43.950001
7.152932
41.28020
43.6696
43.658900
2020-12-22
44.000000
44.389999
43.160000
3.737608
41.23700
43.7346
43.646581
2020-12-23
43.160000
43.950001
43.720001
42.595821
41.20170
43.8248
43.648394
2020-12-28
43.689999
43.990002
43.970001
57.804029
41.19155
43.9070
43.656335
2020-12-29
43.959999
44.310001
44.130001
68.489606
41.16095
44.0066
43.668030
Pronto! Com os valores das médias móveis, vamos calcular a variação de cada uma, pois assim saberemos se ela está subindo (variação > 0) ou não. Utilizaremos a função pct_change(), que calcula, por padrão, a variação percentual da linha imediatamente anterior. Armazenaremos essas informações em novas colunas.
# percentage change calculation
df["Variation 200"] = df["MMA200"].pct_change()
df["Variation 50"] = df["MMA50"].pct_change()
df["Variation 80"] = df["MME80"].pct_change()
# We don't need them anymore
df.drop(columns=["MMA200", "MMA50", "MME80"], inplace=True)
df.tail()
Open
High
Close
IFR2
Variation 200
Variation 50
Variation 80
Date
2020-12-21
44.259998
45.040001
43.950001
7.152932
-0.001282
0.001973
0.000169
2020-12-22
44.000000
44.389999
43.160000
3.737608
-0.001047
0.001488
-0.000282
2020-12-23
43.160000
43.950001
43.720001
42.595821
-0.000856
0.002062
0.000042
2020-12-28
43.689999
43.990002
43.970001
57.804029
-0.000246
0.001876
0.000182
2020-12-29
43.959999
44.310001
44.130001
68.489606
-0.000743
0.002268
0.000268
Definindo os pontos de entrada e saída
Antes de qualquer coisa, vamos isolar os possíveis pontos de saída dessa estratégia (Target), que corresponde à máxima dos dois dias anteriores. O código é o mesmo do primeiro backtest dessa série.
df["Target1"] = df["High"].shift(1)
df["Target2"] = df["High"].shift(2)
df["Target"] = df[["Target1", "Target2"]].max(axis=1)
# We don't need them anymore
df.drop(columns=["Target1", "Target2"], inplace=True)
e a coluna com a variação da média móvel que irá funcionar como filtro (variation_column).
O preço de compra ("Buy Price") será estabelecido a partir de duas condições:
o valor de IFR2 tem que ser menor ou igual a 30 (condition_1);
e a média móvel tem que estar subindo, ou seja, a variação tem que ser positiva (condition_2).
Dessa forma, o preço de compra será o preço de fechamento (data["Close"]), se ambas condições forem respeitadas (condition_1 & condition_2). Caso contrário, a linha será preenchida por np.nan.
O preço de venda ("Sell Price") segue a mesma lógica do artigo citado anteriormente.
A função backtest_algorithm() define as regras de operação utilizando os preços de compra e venda determinados pela função anterior. O código a seguir foi desenvolvido no post Backtest da Estratégia de Máximas e Mínimas. A função retorna duas listas:
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 smalles multiple of 100
def round_down(x):
return int(math.floor(x / 100.0)) * 100
def backtest_algorithm(data, initial_capital=10000):
total_capital = [initial_capital] # list with the total capital after every operation
all_profits = [0] # list with profits for every operation
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
Como foi feito nos últimos dois artigos, o último passo do backtest é rodar todas as funções acima em um loop que, nesse caso, irá iterar sobre a lista de médias móveis (periods).
A estatística será armazenada no dicionário statistics e as listas com o lucro e capital acumulado, no dicionário cap_evolution.
O próximo passo é transformar o dicionário acima em um dataframe através da função pd.DataFrame.from_dict() e arredondar os valores para duas casa decimais (.round(2)).
Em relação às três médias móveis, a aritmética de 50 períodos foi a que obteve o melhor lucro. Comparando-a ao backtest da estratégia original (sem filtro), podemos observar que ocorreram menos operações, uma vez que estamos filtrando o nosso ponto de entrada. Já o lucro caiu para praticamente a metade, sem que houvesse uma melhora relevante do drawdown.
Este resultado pode nos levar a interpretações equivocadas, uma vez que o lucro percentual não leva em conta o número de operações. Sendo assim, para uma anáise mais fiel dos resultados, vamos calcular o percentual do lucro por operação.
Calculando o lucro por operação
Faremos isso através de uma função (profit_per_operation) para facilitar o cálculo dos demais backtests. Esta irá receber apenas 2 argumentos:
o dataframe (data);
e o nome da estratégia em questão (strategy_title).
profit_per_operation(
statistics,
strategy_title="Entrada em IFR2<=30, saída na máx. dos 2 dias anteriores, sem stop:"
)
Entrada em IFR2<=30, saída na máx. dos 2 dias anteriores, sem stop:
profit_per_operation 200 0.59 80 0.63 50 0.69
A estratégia original (sem filtro) apresentou um lucro de 138,54% num universo de 160 operações, o que resulta em um lucro por operação de 0,86% com um drawdown de 10,37%.
Olhando dessa perspectiva, observamos que há uma diferença de aproximadamente 0,2% entre as duas em relação ao lucro por operação. Em termos relativos, essa diferença foi de 20% com uma queda de 6,7% no drawdown. Portanto, nesse backtest a leve melhora do drawdown não compensa a perda de lucratividade.
Estratégia
Lucro/Operação (%)
Drawdown Máx. (%)
Original sem filtro
0,86
10,37
Original com filtro MMA50
0.69
9,67
Vamos agora realizar o mesmo backtest, porém com o ponto de saída baseado em valores de IFR2.
Backtest com ponto de saída em IFR2 >= 70
No último artigo Analisando Diferentes Parâmetros de Saída para a Estratégia de IFR2, chegamos à conclusão que alvos em valores de IFR2 maiores ou iguais a 70 e com um stop de 7 dias é a melhor estratégia para quem deseja utilizar o indicador como ponto de saída conciliado a um stop baseado no tempo. Para fins comparativos, testaremos os filtros de médias móveis nessa mesma estratégia com e sem stop.
Sem stop
Vamos atualizar a função strategy_points juntando a lógica do preço de compra elaborada no backtest acima com a lógica do preço de venda escrita no útlimo artigo.
Com os valores exatos de compra e venda, basta rodar a função no loop iterando sobre a lista de médias móveis. Utilizaremos as mesmas funções de simulação da operação (backtest_algorithm) e cálculo da estatística (strategy_test). Os resultados serão armazenados no dicionário backtest.
backtest = {}
for period in periods:
variation_column = "Variation " + str(period)
df = strategy_points(
data=df,
rsi_parameter_entry=30,
rsi_parameter_exit=70,
variation_column=variation_column
)
all_profits, total_capital = backtest_algorithm(df)
backtest[period] = strategy_test(all_profits, total_capital)
Transformaremos o backtest em um dataframe e, logo em seguida, plotaremos os dados mais relevantes através da função plot_bars.
Novamente, os resultados provenientes do filtro com a média móvel aritmética de 50 períodos foram melhores não só em relação às outras médias móveis, mas também comparando com a estratégia anterior (ponto de saída na máxima dos 2 dias anteriores). Isso faz sentido se pensarmos que um ativo em tendência de alta possui mais chances de atingir valores maiores de IFR.
Finalmente, testaremos a mesma estratégia com um stop de 7 dias.
Com stop
Agora vamos realizar o mesmo backtest, porém estabelecendo um stop baseado no tempo. Para isso, utilizaremos a função algorithm_with_stop a seguir.
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
Os próximos passos são os mesmos realizados acima:
De maneira semelhante, a média móvel aritmética de 50 períodos foi a que performou melhor entre as três tanto com, quanto sem stop... mas nossa análise não para por aí!
Calculando o lucro por operação
profit_per_operation(backtest, strategy_title="Entrada em IFR2 <= 30, saída em IFR2 >= 70, sem stop:")
Entrada em IFR2 <= 30, saída em IFR2 >= 70, sem stop:
profit_per_operation 200 0.83 80 0.89 50 1.08
profit_per_operation(backtest_with_stop, strategy_title="Entrada em IFR2 <= 30, saída em IFR2 >= 70, stop de 7 dias:")
Entrada em IFR2 <= 30, saída em IFR2 >= 70, stop de 7 dias:
profit_per_operation 200 0.73 80 0.86 50 1.05
Mais uma vez, a estratégia utilizando a média móvel aritmética de 50 períodos performou melhor em relação às outras. Por conta disso, analisaremos apenas os resultados dela para os 3 backtests realizados aqui.
Estratégia
Lucro/Operação (%)
Lucro Total (%)
Drawdown Máx. (%)
Máx. 2 dias (sem filtro)
0,86
138,54%
10,37
Máx. 2 dias
0,69
69,25
9,67
IFR2 >= 70
1.08
98,35
9,49
IFR2 >= 70 (com stop)
1.04
100,33
11,31
Apesar de valores semelhantes, a estratégia sem o stop resultou em uma maior porcentagem de lucro/operação e menor drawdown. Uma explicação é que uma vez que o filtro garante que o papel esteja em tendência de alta, há pouca vantagem em se adicionar o stop no tempo.
Conclusão
De acordo com nossas análises, observamos duas estratégias plausíveis para o IFR2:
Sem considerar a tendência: opera-se todas as entradas que o IFR2 menor ou iguais a 30 ativar.
Considerando a tendência: realiza-se uma pré-seleção das entradas através de uma média móvel mais longa.
Agora que nós já testamos a estratégia de IFR2 em diversos cenários e chegamos a essas duas vertentes, o próximo passo é fazer o backtest em diversos ativos. Sendo assim, no próximo post iremos finalizar essa série de artigos analisando como selecionar os melhores ativos para a estratégia.