QuantBrasil

Analisando Diferentes Parâmetros de Saída para a Estratégia de IFR2

Andressa Quintanilha
Por Andressa Quintanilha
28 janeiro, 2021
Compartilhar:

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

[*********************100%***********************] 1 of 1 completed

Close
Date
2015-01-0212.545500
2015-01-0512.196700
2015-01-0612.337200
2015-01-0712.547100
2015-01-0812.562000
......
2020-12-2143.950001
2020-12-2243.160000
2020-12-2343.720001
2020-12-2843.970001
2020-12-2944.130001

1487 rows × 1 columns

Calculando o IFR para 2 períodos

Utilizando a função rsi(), calcularemos o IFR para 2 períodos e adicionaremos uma nova coluna ao dataframe.

Se você ainda não está familiarizado com o indicador ou com o código a seguir, não deixe de ler o primeiro artigo da série de backtests do 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(data=df, column="Close")
df.head()
CloseIFR2
Date
2015-01-0212.5455NaN
2015-01-0512.1967NaN
2015-01-0612.337228.714517
2015-01-0712.547161.632398
2015-01-0812.562063.993026

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.
def strategy_points(data, rsi_parameter_entry, rsi_parameter_exit):
 
 # Define exact buy price
 data["Buy Price"] = np.where(data["IFR2"] <= rsi_parameter_entry, data["Close"], np.nan)
 
 # Define exact sell price
 data["Sell Price"] = np.where(data["IFR2"] >= rsi_parameter_exit, data["Close"], np.nan)
 
 return data

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

As três funções a seguir foram elaboradas no último artigo. Para uma explicação mais detalhada, não deixe de ler o post Analisando Diferentes Parâmetros de Entrada para a Estratégia de IFR2.

Simulação das operações

A função backtest_algorithm retorna duas listas:

  • all_profits, que compreende o lucro de cada operação;
  • 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

Cálculo do Drawdown

A função abaixo é explicada no post Entenda o Drawdown e Calcule essa Medida de Volatilidade para Qualquer Ativo.

def get_drawdown(data, column = "Adj 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

Cálculo das Estatísticas

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

parameters = [50, 60, 70, 80]

statistics = {}

for parameter in parameters:
  df = strategy_points(data=df, rsi_parameter_entry=30, rsi_parameter_exit=parameter)
  all_profits, total_capital = backtest_algorithm(df)
  statistics[parameter] = strategy_test(all_profits, total_capital)

statistics
{50: {'num_operations': 175, 'gains': 122, 'pct_gains': 70.0, 'losses': 53, 'pct_losses': 30.0, 'total_profit': 11989.375114440918, 'pct_profit': 119.8937511444092, 'drawdown': 16.131150979882893}, 60: {'num_operations': 155, 'gains': 112, 'pct_gains': 72.0, 'losses': 43, 'pct_losses': 28.0, 'total_profit': 10482.694911956787, 'pct_profit': 104.82694911956787, 'drawdown': 14.474284150892295}, 70: {'num_operations': 138, 'gains': 100, 'pct_gains': 72.0, 'losses': 38, 'pct_losses': 28.0, 'total_profit': 12148.157978057861, 'pct_profit': 121.48157978057861, 'drawdown': 11.42124524302571}, 80: {'num_operations': 113, 'gains': 81, 'pct_gains': 72.0, 'losses': 32, 'pct_losses': 28.0, 'total_profit': 14945.658779144287, 'pct_profit': 149.45658779144287, 'drawdown': 10.25379130317439}}

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.

statistics_df = pd.DataFrame.from_dict(statistics, orient='index')
statistics_df = statistics_df.round(2)
statistics_df
num_operationsgainspct_gainslossespct_lossestotal_profitpct_profitdrawdown
5017512270.05330.011989.38119.8916.13
6015511272.04328.010482.69104.8314.47
7013810072.03828.012148.16121.4811.42
801138172.03228.014945.66149.4610.25

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)

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_operationsgainspct_gainslossespct_lossestotal_profitpct_profitdrawdown
50320412863.07637.08335.27040583.35270411.330522
518513070.05530.011354.376030113.54376018.007040
718012670.05430.011780.224609117.80224616.622854
917512270.05330.011490.674591114.90674616.901341
60319611760.07940.05376.55181953.76551819.410560
517012171.04929.08422.57356684.22573617.154408
716412073.04427.011865.434170118.65434213.788915
915711473.04327.011368.824673113.68824718.928227
70319210856.08444.07035.38351170.35383514.775776
516210867.05433.010244.797516102.44797513.321478
715311173.04227.015221.747208152.21747212.526994
914710773.04027.013230.715847132.30715812.501646
80319110756.08444.08527.07386085.27073915.154275
515810265.05635.013309.181976133.09182011.361103
71409769.04331.015596.088219155.96088219.545613
91319573.03627.015260.956669152.60956715.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_operationsgainspct_gainslossespct_lossestotal_profitpct_profitdrawdown
IFR2 de SaídaStop (dias)
50320412863.07637.08335.2783.3511.33
518513070.05530.011354.38113.5418.01
718012670.05430.011780.22117.8016.62
917512270.05330.011490.67114.9116.90
60319611760.07940.05376.5553.7719.41
517012171.04929.08422.5784.2317.15
716412073.04427.011865.43118.6513.79
915711473.04327.011368.82113.6918.93
70319210856.08444.07035.3870.3514.78
516210867.05433.010244.80102.4513.32
715311173.04227.015221.75152.2212.53
914710773.04027.013230.72132.3112.50
80319110756.08444.08527.0785.2715.15
515810265.05635.013309.18133.0911.36
71409769.04331.015596.09155.9619.55
91319573.03627.015260.96152.6115.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!