Backtest da Estratégia de IFR2 Utilizando Médias Móveis Como Filtro

Por Andressa Monteiro
Em 05/02/2021

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
df = yf.download("LREN3.SA", start="2015-01-01", end="2020-12-30").copy()[["Open", "High", "Close"]]
[*********************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)

Para definir os exatos preços de compra e venda, iremos atualizar a função strategy_points(), elaborada no artigo Analisando Diferentes Parâmetros de Entrada para a Estratégia de IFR2. A função receberá 3 argumentos:

  • o dataframe (data);
  • o valor de IFR de entrada (rsi_parameter_entry);
  • 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.

def strategy_points(data, rsi_parameter_entry, variation_column):

  # Define exact buy price
  condition_1 = df["IFR2"] <= rsi_parameter_entry
  condition_2 = df[variation_column] > 0 
  data["Buy Price"] = np.where(condition_1 & condition_2, data["Close"], np.nan)

  # Define exact sell price
  data["Sell Price"] = np.where(
      data["High"] > data['Target'],
      np.where(data['Open'] > data['Target'], data['Open'], data['Target']),
      np.nan)

  return data 

Criando as funções necessárias para o backtest

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

A função get_drawdown() foi elaborada no post Entenda o Drawdown e Calcule essa Medida de Volatilidade para Qualquer Ativo e retorna o drawdown máximo.

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

A função strategy_test retorna estatísticas do backtest:

  • o número total de operações (num_operation);
  • o número de operações que deram lucro e prejuízo (gains e losses);
  • suas respectivas porcentagens (pct_gains e pct_losses);
  • o 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)
    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

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.

periods = [200, 80, 50]

statistics = {}
cap_evolution = {}

for period in periods: 
    variation_column = "Variation " + str(period)
    df = strategy_points(data=df, rsi_parameter_entry=30, variation_column=variation_column)
    all_profits, total_capital = backtest_algorithm(df)
    statistics[period] = strategy_test(all_profits, total_capital)
    key1 = "all_profits_" + str(period)
    key2 = "total_capital_" + str(period)
    cap_evolution[period] = { key1: all_profits, key2: total_capital }
statistics
{200: {'num_operations': 95,
  'gains': 72,
  'pct_gains': 76.0,
  'losses': 23,
  'pct_losses': 24.0,
  'total_profit': 5589.06307220459,
  'pct_profit': 55.8906307220459,
  'drawdown': 8.879767678840295},
 80: {'num_operations': 98,
  'gains': 75,
  'pct_gains': 77.0,
  'losses': 23,
  'pct_losses': 23.0,
  'total_profit': 6151.059913635254,
  'pct_profit': 61.51059913635254,
  'drawdown': 8.501211685719728},
 50: {'num_operations': 100,
  'gains': 78,
  'pct_gains': 78.0,
  'losses': 22,
  'pct_losses': 22.0,
  'total_profit': 6924.6965408325195,
  'pct_profit': 69.2469654083252,
  'drawdown': 9.673291514464546}}

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)).

statistics = pd.DataFrame.from_dict(statistics, orient="index").round(2)
statistics
num_operations gains pct_gains losses pct_losses total_profit pct_profit drawdown
200 95 72 76.0 23 24.0 5589.06 55.89 8.88
80 98 75 77.0 23 23.0 6151.06 61.51 8.50
50 100 78 78.0 22 22.0 6924.70 69.25 9.67

Plotando as informações mais relevantes

A seguir, utilizaremos a função plot_bars para plotar o número de operações, o lucro e o drawdown em gráficos de barra.

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',
      fontdict=dict(fontsize=12)
    )
x = statistics.index.astype(str)
plot_bars(title="Número de Operações", x=x, y=statistics["num_operations"], x_label="Média Móvel")
plot_bars(title="Lucro Total (%)", x=x, y=statistics["pct_profit"], x_label="Média Móvel")
plot_bars(title="Drawdown (%)", x=x, y=statistics["drawdown"], x_label="Média Móvel")

Vamos recapitular os resultados do backtest da estratégia original (sem o filtro de médias móveis), realizado no post Criando o Backtest da Estratégia de IFR2 em Python:

No. de Operações Lucro Total (%) Drawdown Máx. (%)
160 138,54 10,37

Agora levando em consideração a tendência:

Médias Móveis No. de Operações Lucro Total (%) Drawdown Máx. (%)
200 95 55,89 8,88
80 98 61,51 8,5
50 100 69,25 9,67

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).
def profit_per_operation(data, strategy_title): 
  profit = data["pct_profit"] / data["num_operations"]
  df = pd.DataFrame(profit).round(2)
  df.rename(columns = {0: "profit_per_operation"}, inplace=True)
  print(strategy_title, "\n\n", df)
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.

def strategy_points(data, rsi_parameter_entry, rsi_parameter_exit, variation_column):
 
 # Define exact buy price
 condition_1 = df["IFR2"] <= rsi_parameter_entry
 condition_2 = df[variation_column] > 0 
 data["Buy Price"] = np.where(condition_1 & condition_2, data["Close"], np.nan)
 
 # Define exact sell price
 data["Sell Price"] = np.where(data["IFR2"] >= rsi_parameter_exit, data["Close"], np.nan)
 
 return data

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.

backtest = pd.DataFrame.from_dict(backtest, orient="index").round(2)
backtest
num_operations gains pct_gains losses pct_losses total_profit pct_profit drawdown
200 91 69 76.0 22 24.0 7584.85 75.85 9.91
80 89 68 76.0 21 24.0 7937.71 79.38 8.51
50 91 70 77.0 21 23.0 9834.77 98.35 9.49
plot_bars(title="Número de Operações", x=x, y=backtest["num_operations"], x_label="Média Móvel")
plot_bars(title="Lucro Total (%)", x=x, y=backtest["pct_profit"], x_label="Média Móvel")
plot_bars(title="Drawdown (%)", x=x, y=backtest["drawdown"], x_label="Média Móvel")

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:

backtest_with_stop = {}

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 = algorithm_with_stop(df, max_days=7)
  backtest_with_stop[period] = strategy_test(all_profits, total_capital)

backtest_with_stop = pd.DataFrame.from_dict(backtest_with_stop, orient="index").round(2)
backtest_with_stop
num_operations gains pct_gains losses pct_losses total_profit pct_profit drawdown
200 94 71 76.0 23 24.0 6876.24 68.76 12.83
80 94 71 76.0 23 24.0 8053.43 80.53 10.10
50 96 73 76.0 23 24.0 10033.38 100.33 11.31
plot_bars(title="Número de Operações", x=x, y=backtest_with_stop["num_operations"], x_label="Média Móvel")
plot_bars(title="Lucro Total (%)", x=x, y=backtest_with_stop["pct_profit"], x_label="Média Móvel")
plot_bars(title="Drawdown (%)", x=x, y=backtest_with_stop["drawdown"], x_label="Média Móvel")

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.