Backtest da Estratégia de IFR2 em uma Lista de Ativos Dinâmica

Por Andressa Monteiro
Em 24/02/2021

Em todos os backtests dessa série sobre IFR2 até agora, utilizamos sempre o mesmo ativo (LREN3) no mesmo período de tempo (01/2015 até 12/2020). Embora os resultados tenham sido excelentes, como poderíamos saber, há 6 anos atrás, que LREN3 teria um resultado tão bom?

Por mais que um ativo apresente uma boa performance no backtest, nada pode-se dizer das operações futuras. É perfeitamente possível que a estratégia pare de funcionar em um determinado papel (por exemplo, caso ele entre em uma longa tendência de baixa).

A fim de nos protegermos desse risco, iremos testar a estratégia apenas nos papeis mais fortes do Ibovespa. Ou seja, realizaremos um filtro na lista de ativos.

Baixando os ativos que compõem o Ibovespa

Antes de mais nada, claro, precisamos baixar as bibliotecas de interesse.

# %%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

Para baixar os dados necessários de todos os ativos que compõem a carteira do IBOV, nós precisamos de seus tickers, disponíveis nessa tabela.

Para extrair somente os tickers, utilizaremos a função pd.read_html() do pandas, que converte qualquer tabela html em um dataframe. Selecionaremos o índice desse dataframe, que nada mais é do que o ticker de cada ativo, e adicionaremos o sufixo .SA, necessário para baixar os dados a partir da biblioteca do Yahoo Finance. Faremos isso dentro da função get_ibov_tickers().

def get_ibov_tickers():
    url = "http://bvmf.bmfbovespa.com.br/indices/ResumoCarteiraTeorica.aspx?Indice=IBOV&idioma=pt-br"
    html = pd.read_html(url, decimal=",", thousands=".", index_col="Código")[0][:-1]
    tickers = (html.index + ".SA").to_list()
    return tickers

tickers = get_ibov_tickers()
print(tickers)
['ABEV3.SA', 'AZUL4.SA', 'B3SA3.SA', 'BBAS3.SA', 'BBDC3.SA', 'BBDC4.SA', 'BBSE3.SA', 'BEEF3.SA', 'BPAC11.SA', 'BRAP4.SA', 'BRDT3.SA', 'BRFS3.SA', 'BRKM5.SA', 'BRML3.SA', 'BTOW3.SA', 'CCRO3.SA', 'CIEL3.SA', 'CMIG4.SA', 'COGN3.SA', 'CPFE3.SA', 'CPLE6.SA', 'CRFB3.SA', 'CSAN3.SA', 'CSNA3.SA', 'CVCB3.SA', 'CYRE3.SA', 'ECOR3.SA', 'EGIE3.SA', 'ELET3.SA', 'ELET6.SA', 'EMBR3.SA', 'ENBR3.SA', 'ENEV3.SA', 'ENGI11.SA', 'EQTL3.SA', 'EZTC3.SA', 'FLRY3.SA', 'GGBR4.SA', 'GNDI3.SA', 'GOAU4.SA', 'GOLL4.SA', 'HAPV3.SA', 'HGTX3.SA', 'HYPE3.SA', 'IGTA3.SA', 'IRBR3.SA', 'ITSA4.SA', 'ITUB4.SA', 'JBSS3.SA', 'JHSF3.SA', 'KLBN11.SA', 'LAME4.SA', 'LCAM3.SA', 'LREN3.SA', 'MGLU3.SA', 'MRFG3.SA', 'MRVE3.SA', 'MULT3.SA', 'NTCO3.SA', 'PCAR3.SA', 'PETR3.SA', 'PETR4.SA', 'PRIO3.SA', 'QUAL3.SA', 'RADL3.SA', 'RAIL3.SA', 'RENT3.SA', 'SANB11.SA', 'SBSP3.SA', 'SULA11.SA', 'SUZB3.SA', 'TAEE11.SA', 'TIMS3.SA', 'TOTS3.SA', 'UGPA3.SA', 'USIM5.SA', 'VALE3.SA', 'VIVT3.SA', 'VVAR3.SA', 'WEGE3.SA', 'YDUQ3.SA']

Agora que temos a lista de ativos já podemos baixar os dados necessários:

start = "2015-01-01"
end = "2020-12-30"

df = yf.download(tickers=tickers, start=start, end=end).copy()[["Open", "High", "Close"]]
[*********************100%***********************] 81 of 81 completed
df.head()
Open ... Close
ABEV3.SA AZUL4.SA B3SA3.SA BBAS3.SA BBDC3.SA BBDC4.SA BBSE3.SA BEEF3.SA BPAC11.SA BRAP4.SA ... TAEE11.SA TIMS3.SA TOTS3.SA UGPA3.SA USIM5.SA VALE3.SA VIVT3.SA VVAR3.SA WEGE3.SA YDUQ3.SA
Date
2015-01-02 16.139999 NaN 9.81 23.430000 15.969308 16.482443 31.850000 9.744451 NaN 14.06 ... 18.850000 NaN 11.910702 25.330000 4.79 21.280001 37.820000 6.80 11.846153 22.049999
2015-01-05 15.910000 NaN 9.43 22.580000 15.794703 15.989155 30.340000 9.301968 NaN 13.36 ... 18.780001 NaN 11.544731 24.655001 4.50 20.959999 37.070000 6.80 11.926923 20.730000
2015-01-06 15.730000 NaN 9.20 22.230000 16.021217 16.321175 29.450001 8.938149 NaN 13.51 ... 18.809999 NaN 10.822770 24.690001 4.72 21.799999 36.150002 6.80 11.750000 19.520000
2015-01-07 16.340000 NaN 9.40 22.940001 16.460091 17.046877 30.830000 8.800488 NaN 14.11 ... 18.840000 NaN 10.746248 25.360001 5.00 22.600000 37.389999 7.36 11.615384 18.910000
2015-01-08 16.430000 NaN 9.89 23.770000 16.993345 17.549650 30.590000 9.124975 NaN 14.67 ... 19.020000 NaN 10.995774 25.100000 4.75 22.840000 38.910000 7.36 11.811538 19.500000

5 rows × 243 columns


Como podemos observar, nosso dataframe apresenta uma indexação hierárquica (dois níveis), uma vez que estamos baixando o preço de abertura, fechamento e a máxima de mais de um ativo. Para simplificar o acesso a essas colunas, vamos transformar a indexação em apenas um nível.

df.columns = [" ".join(col).strip() for col in df.columns.values]
df.head()
Open ABEV3.SA Open AZUL4.SA Open B3SA3.SA Open BBAS3.SA Open BBDC3.SA Open BBDC4.SA Open BBSE3.SA Open BEEF3.SA Open BPAC11.SA Open BRAP4.SA ... Close TAEE11.SA Close TIMS3.SA Close TOTS3.SA Close UGPA3.SA Close USIM5.SA Close VALE3.SA Close VIVT3.SA Close VVAR3.SA Close WEGE3.SA Close YDUQ3.SA
Date
2015-01-02 16.139999 NaN 9.81 23.430000 15.969308 16.482443 31.850000 9.744451 NaN 14.06 ... 18.850000 NaN 11.910702 25.330000 4.79 21.280001 37.820000 6.80 11.846153 22.049999
2015-01-05 15.910000 NaN 9.43 22.580000 15.794703 15.989155 30.340000 9.301968 NaN 13.36 ... 18.780001 NaN 11.544731 24.655001 4.50 20.959999 37.070000 6.80 11.926923 20.730000
2015-01-06 15.730000 NaN 9.20 22.230000 16.021217 16.321175 29.450001 8.938149 NaN 13.51 ... 18.809999 NaN 10.822770 24.690001 4.72 21.799999 36.150002 6.80 11.750000 19.520000
2015-01-07 16.340000 NaN 9.40 22.940001 16.460091 17.046877 30.830000 8.800488 NaN 14.11 ... 18.840000 NaN 10.746248 25.360001 5.00 22.600000 37.389999 7.36 11.615384 18.910000
2015-01-08 16.430000 NaN 9.89 23.770000 16.993345 17.549650 30.590000 9.124975 NaN 14.67 ... 19.020000 NaN 10.995774 25.100000 4.75 22.840000 38.910000 7.36 11.811538 19.500000

5 rows × 243 columns


Observe que há alguns dados faltando (NaN). Isso pode acontecer por falta de dados na base do Yahoo Finance, ou ainda caso um ativo tenha sido incluído ao índice depois do início do intervalo que selecionamos. De qualquer maneira, é importante termos essa informação na hora de fazer nossas análises.

Calculando o IFR2 e os pontos de entrada e saída para cada ativo

As funções rsi() e strategy_points(), foram elaboradas ao longo dessa série de artigos. A partir delas nós calcularemos os valores de IFR2 e os possíveis preços de compra e venda para cada ativo, que serão utilizados mais tarde para simular as operações.

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
def strategy_points(data, rsi_parameter_entry=30):
    data["Target1"] = data["High"].shift(1)
    data["Target2"] = data["High"].shift(2)
    data["Target"] = data[["Target1", "Target2"]].max(axis=1)
  
    # We don't need them anymore
    data.drop(columns=["Target1", "Target2"], inplace=True)

    # 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["High"] > data['Target'],
        np.where(data['Open'] > data['Target'], data['Open'], data['Target']),
        np.nan)
    return data 

Antes de utilizarmos ambas funções para cada ativo, vamos desmembrar df de modo que cada ativo tenha seu próprio dataframe. Dessa forma, evitamos trabalhar com uma tabela de muitas colunas, o que torna nosso código mais simples e limpo.

Faremos isso iterando sobre a lista de ativos baixados (tickers), a fim de selecionar suas respectivas colunas (Open, Close e High) e armazená-las em um dicionário dict_of_df. Dessa forma, a chave será o ticker de cada ativo e o valor correspondente será seu dataframe. Ao final do loop nós podemos então utilizar as funções acima.

dict_of_df = {}

for ticker in tickers:
    # Isolate the columns (open, high, close) of each asset into a dataframe
    # and store it in a dictionary 'dict_of_df' 
    data = df[
        [("Open " + ticker), 
         ("High " + ticker), 
         ("Close " + ticker)]].copy()
    
    # Rename the columns
    data.rename(columns={
        ("Open " + ticker): 'Open', 
        ("High " + ticker): 'High', 
        ("Close " + ticker): 'Close'}, inplace=True)
    
    # Calculate IFR2 and add it to a new column
    data["IFR2"] = rsi(data=data, column="Close")
    
    # Calculate purchase and sale prices
    data = strategy_points(data)

    dict_of_df[ticker] = data

Calculando a variação

Como dito no início desse post, nós realizaremos o backtest da estratégia nos ativos mais fortes do Ibovespa. Classificaremos como mais fortes aqueles que tiveram a maior variação de preço nos últimos x dias.

Com esse intuito, criaremos uma função get_asset, que irá receber três argumentos:

  • o dicionário contendo os dataframes de cada ativo (dict_of_df);
  • o intervalo de tempo (interval), em dias, que se deseja calcular a variação do ativo;
  • e a partir de que dia se deseja calcular tal variação, que será representado por i.

Nessa função, iremos iterar sobre a lista de tickers e calcular a variação percentual (delta) para cada um deles. Como nós observamos valores NaN no dataframe, iremos excluí-los agora — se o delta for um valor numérico (~np.isnan(delta)), nós o armazenaremos no dicionário variation.

Com as variações calculadas, iremos organizá-las em ordem decrescente (reverse=True) utilizando a função sorted(). Feito isso, isolaremos somente os 5 primeiros tickers (top_5_tickers) a fim de selecionar o primeiro que acionar uma entrada (~np.isnan(df["Buy Price"][i])), se houver. A função então irá retornar o ativo escolhido (asset).

def get_asset(dict_of_df, interval, i):
    variation = {}
    
    for ticker in tickers:

        # Calculate the variation of a certain time interval 
        # for each asset and for all days (rows) of the dataframe
        df = dict_of_df[ticker]
        current_day = df["Close"][i]
        start_of_interval = df["Close"][i - interval]
        delta = ((current_day - start_of_interval) / start_of_interval) * 100

        # Exclude NaN values
        if ~np.isnan(delta):
            variation[ticker] = delta
  
    # Sort the assets and isolate the top 5
    sorted_variation = dict(
        sorted(variation.items(), key=lambda item: item[1], reverse=True)
    )
    top_5_tickers = list(sorted_variation.keys())[:5]
    
    # Choose the asset that triggered an entry
    asset = None
    for ticker in top_5_tickers:
        if ~np.isnan(df["Buy Price"][i]):
            asset = ticker
            break 
            
    return asset

Criando o algoritmo para simular as operações

A estrutura do código do algortimo a seguir é similar àquela que criamos no segundo artigo dessa série, divergindo em dois pontos principais:

1) Iremos iterar sobre todo o dataframe a partir do intervalo de tempo que desejamos calcular a variação (range(interval, len(df)));

2) Além de estabelecer se estamos com uma operação em andamento (ongoing), teremos que selecionar o dataframe do ativo escolhido (ongoing_df), uma vez que a função get_asset() retorna apenas o ticker do mesmo. Dessa forma:

  • Se não estivermos com uma operação em andamento, ambos ongoing e ongoing_df são Falsos. Portanto, escolhemos um ativo através da função get_asset. Vale ressaltar que nem sempre teremos um ativo escolhido, uma vez que pode acontecer de nenhum papel da lista dos 5 mais fortes dar sinal de entrada. Quando isso acontece, asset será igual a None;
  • Quando tivermos um ativo escolhido (asset != None) ou se estivermos no meio de uma operação, o dataframe data será igual a ongoing_df se estivermos no meio da operação (ongoing for Verdade), caso contrário, a operação estará se inicializando e temos que selecionar o dataframe do ativo escolhido (dict_of_df[asset]).

Além disso, criaremos uma lista backtest_list de tuplas, onde cada tupla irá conter as informações mais relevantes de cada operação: o ativo, o lucro líquido e o lucro percentual. Assim, saberemos qual foi a participação de cada ativo no resultado final da estratégia.

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
# List with the total capital after every operation
initial_capital = 10000
total_capital = [initial_capital]

# List with profits for every operation. We initialize with 0 so 
# both lists have the same size
all_profits = [0]

# Time interval to calculate the variation
interval = 100

ongoing = False
ongoing_df = None

# List of assets that were operated
backtest_list = []

for i in range(interval, len(df)):
    
    # Only look for new asset if there is no ongoing operation
    if ongoing == False:
        asset = get_asset(dict_of_df, interval, i)
    
    # If there is an ongoing operation or there is a signal for an asset, continue operation
    if ongoing == True or asset != None:
        
        data = ongoing_df if ongoing == True else dict_of_df[asset]
        
        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]
                
                
                pct_profit = 100 * (profit / current_capital)
                backtest_list += [(asset, profit, pct_profit)]
                
                ongoing = False
                ongoing_df = None
        else:
            if ~(np.isnan(data['Buy Price'][i])):
                entry = data['Buy Price'][i]
                shares = round_down(initial_capital / entry)
                ongoing = True
                ongoing_df = data
                

Como backtest_list contém os resultados por operação, iremos somar os lucros (líquido e percentual) das operações realizadas no mesmo papel, para visualizarmos os resultados por ativo. Faremos isso através de uma função (statistic_per_asset()), que receberá a lista de tuplas (tuple_list) como argumento e retornará um dataframe com as seguintes informações:

  • o número de operações (num_operations);
  • o número de operações que deram lucro (gains);
  • a porcentagem de operações que deram lucro (pct_gains);
  • o lucro líquido (profit);
  • e o lucro percentual (pct_profit).

O número de operações será calculado através da função .value_counts(), que retorna a contagem de valores únicos de alguma coluna, nesse caso, a coluna de ativos (asset). O número de operações que deram lucro, por sua vez, será calculado a partir de duas funções:

  • .groupby(), que funciona agrupando valores iguais de uma coluna. Nós iremos agrupar por ativo e selecionararemos os lucros correspondentes (df.groupby('asset')['profit']);
  • com os lucros por ativo isolados, iremos selecionar apenas aqueles que foram positivos. Faremos isso através do .apply(), que aplica uma determinada função (lambda) ao longo de um eixo do dataframe, que nesse caso será calcular o número de valores positivos (x[x >= 0].count()).
def statistic_per_asset(tuple_list):
    
    df = pd.DataFrame(tuple_list, columns=['asset', 'profit', 'pct_profit'])
    
    profit = df.groupby('asset')['profit'].sum()

    pct_profit = df.groupby('asset')['pct_profit'].sum()

    num_operations = df['asset'].value_counts()

    gains = df.groupby('asset')['profit'].apply(lambda x: x[x >= 0].count())

    pct_gains = 100 * (gains / num_operations)

    statistics = pd.DataFrame(data={
        'num_operations': num_operations,
        'gains': gains,
        'pct_gains': pct_gains,
        'profit': profit, 
        'pct_profit': pct_profit
    }).round(2)
    
    return statistics
backtest_per_asset = statistic_per_asset(backtest_list)
backtest_per_asset
num_operations gains pct_gains profit pct_profit
BPAC11.SA 2 2 100.00 650.00 4.08
BRKM5.SA 5 3 60.00 275.00 2.47
BTOW3.SA 1 1 100.00 0.00 0.00
CSNA3.SA 2 1 50.00 171.00 1.27
ELET3.SA 4 3 75.00 963.63 6.54
GOLL4.SA 3 1 33.33 -145.00 -1.00
HGTX3.SA 6 3 50.00 -1137.00 -6.89
HYPE3.SA 1 1 100.00 120.00 1.00
JHSF3.SA 2 1 50.00 224.00 1.31
LCAM3.SA 3 2 66.67 239.33 1.55
MGLU3.SA 12 9 75.00 3382.27 26.34
MRFG3.SA 2 2 100.00 638.00 5.68
PETR3.SA 1 1 100.00 539.00 5.39
PRIO3.SA 9 6 66.67 -4458.70 -23.75
QUAL3.SA 2 1 50.00 408.00 2.53
RADL3.SA 3 3 100.00 531.00 4.97
SUZB3.SA 4 3 75.00 708.00 4.46
USIM5.SA 1 0 0.00 -459.00 -3.01
VVAR3.SA 4 4 100.00 1241.00 8.47
WEGE3.SA 3 3 100.00 1220.00 9.15

Calculando a estatítica do backtest final

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

Já a função strategy_test foi desenvolvida e aprimorada ao longo dessa série de backtests.

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):
    num_operations = (len(all_profits) - 1)
    gains = sum(x >= 0 for x in all_profits[1:])
    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
    }
statistics = strategy_test(all_profits, total_capital)
statistics
{'num_operations': 70,
 'gains': 50,
 'pct_gains': 71.0,
 'losses': 20,
 'pct_losses': 29.0,
 'total_profit': 5110.539798438549,
 'pct_profit': 51.10539798438549,
 'drawdown': 28.58944456394613}

Assim como fizemos no último post, vamos calcular o lucro por operação da estratégia, a fim de se fazer uma comparação mais justa em relação aos resultados dos outros backtests dessa série.

Utilizaremos a mesma função do artigo anterior profit_per_operation() abaixo, que recebe um dataframe e o nome da estratégia como argumentos. Portanto, precisamos transformar nosso dicionário em um dataframe.

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) 
statistics = pd.DataFrame(statistics, index=[0]).round(2)

profit_per_operation(
    data=statistics, 
    strategy_title="Estratégia de IFR2 em um dos 5 ativos mais fortes do IBOV nos últimos 100 dias"
)
Estratégia de IFR2 em um dos 5 ativos mais fortes do IBOV nos últimos 100 dias profit_per_operation 0 0.73

O resultado do backtest foi o seguinte:

Número de operações Acerto (%) Lucro Total (%) Lucro/Operação (%) Drawdown Máx. (%)
70 71 51,1 0,73 28,6

Como podemos observar, o drawdown foi elevadíssimo. Vamos tentar melhorar esse resultado estabelecendo um stop baseado no tempo.

Backtest da estratégia com stop baseado no tempo

Iremos unir o código do algoritmo acima com o código elaborado no primeiro backtest dessa série. Nossa lista de tuplas será a backtest_list_with_stop e estabeleceremos um stop de 7 dias (max_days).

# List with the total capital after every operation
initial_capital = 10000
total_capital = [initial_capital]

# List with profits for every operation. We initialize with 0 so 
# both lists have the same size
all_profits = [0]

# Time interval to calculate the variation
interval = 100

ongoing = False
ongoing_df = None

# List of assets that were operated
backtest_list_with_stop = []

days_in_operation = 0

max_days = 7

for i in range(interval, len(df)):
    
    # Only look for new asset if there is no ongoing operation
    if ongoing == False:
        asset = get_asset(dict_of_df, interval, i)

        days_in_operation = 0
    
    # If there is an ongoing operation or there is a signal for an asset, continue operation
    if ongoing == True or asset != None:
        
        data = ongoing_df if ongoing == True else dict_of_df[asset]
        
        if ongoing == True:
            
            days_in_operation += 1
            
            # If any of the following conditions are met, the operation will end
            if ~(np.isnan(data['Sell Price'][i])) or days_in_operation == max_days:

                # Define exit point and total profit
                exit = np.where(
                    ~(np.isnan(data['Sell Price'][i])), 
                    data['Sell Price'][i], 
                    data['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] # current capital is the last entry in the list
                total_capital += [current_capital + profit]
                
                pct_profit = 100 * (profit / current_capital)
                backtest_list_with_stop += [(asset, profit, pct_profit)]
                
                ongoing = False
                ongoing_df = None
        
        else:
            if ~(np.isnan(data['Buy Price'][i])):
                entry = data['Buy Price'][i]
                shares = round_down(initial_capital / entry)
                
                # Operatio has started, initialize count of days until it ends
                days_in_operation = 0
                
                ongoing = True
                ongoing_df = data

Pronto! Agora basta rodar a função statistic_per_asset() para visualizarmos os resultados do backtest por ativo e strategy_test() para o resultado do backtest da estratégia final.

statistic_per_asset(backtest_list_with_stop)
num_operations gains pct_gains profit pct_profit
BPAC11.SA 2 2 100.00 650.00 4.03
BRKM5.SA 5 3 60.00 275.00 2.47
BTOW3.SA 1 1 100.00 0.00 0.00
CSNA3.SA 2 1 50.00 171.00 1.17
ELET3.SA 4 3 75.00 963.63 6.66
GOLL4.SA 3 1 33.33 -145.00 -1.03
HGTX3.SA 7 4 57.14 -1047.00 -6.47
HYPE3.SA 1 1 100.00 120.00 1.00
JHSF3.SA 2 1 50.00 224.00 1.29
LCAM3.SA 3 2 66.67 239.33 1.56
MGLU3.SA 12 9 75.00 3130.88 24.89
MRFG3.SA 2 2 100.00 638.00 5.68
PETR3.SA 1 1 100.00 539.00 5.39
PRIO3.SA 9 6 66.67 -3724.70 -19.35
QUAL3.SA 2 1 50.00 408.00 2.51
RADL3.SA 3 3 100.00 531.00 4.97
SUZB3.SA 4 3 75.00 708.00 4.50
USIM5.SA 1 0 0.00 -621.00 -3.85
VVAR3.SA 4 4 100.00 1241.00 8.04
WEGE3.SA 4 4 100.00 1541.00 10.95
statistics_with_stop = pd.DataFrame(statistics_with_stop, index=[0]).round(2)
statistics_with_stop
num_operations gains pct_gains losses pct_losses total_profit pct_profit drawdown
0 72 52 72.0 20 28.0 5842.15 58.42 26.2
profit_per_operation(
    data=statistics_with_stop, 
    strategy_title="Estratégia de IFR2, com stop de 7 dias, em um dos 5 ativos mais fortes do IBOV nos últimos 100 dias"
)
Estratégia de IFR2, com stop de 7 dias, em um dos 5 ativos mais fortes do IBOV nos últimos 100 dias profit_per_operation 0 0.81

Comparando os resultados

Vamos comparar os resultados acima com os backtests feitos ao longo dessa série, nos quais utilizou-se apenas um ativo (LREN3).

A coluna "Estratégia" diferencia o ponto de saída de cada uma delas. O ponto de entrada foi o mesmo para todas: valores de IFR2 iguais ou menores que 30.

Ativos Estratégia Lucro/Operação (%) Lucro Total (%) Drawdown Máx. (%)
Carteira IBOV sem filtro Máx. 2 dias 0,73 51,1 28,6
Carteira IBOV sem filtro Máx. 2 dias (com stop) 0,81 58,42 26,2

Com a implementação do stop de 7 dias, conseguimos melhorar o lucro por operação em 0,08% e o drawdown em 2,4%. Parece pouco em números absolutos, mas em termos relativos isso corresponde a, aproximadamente, 11% de aumento no lucro por operação e 8,4% de otimização do drawdown.

Ativos Estratégia Lucro/Operação (%) Lucro Total (%) Drawdown Máx. (%)
Carteira IBOV sem filtro Máx. 2 dias 0,73 51,1 28,6
Carteira IBOV sem filtro Máx. 2 dias (com stop) 0,81 58,42 26,2
LREN3 sem filtro Máx. 2 dias 0,86 138,54% 10,37
LREN3 com filtro MMA50 Máx. 2 dias 0,69 69,25 9,67
LREN3 com filtro MMA50 IFR2 >= 70 1.08 98,35 9,49
LREN3 com filtro MMA50 IFR2 >= 70 (com stop) 1.04 100,33 11,31

*MMA50 = média móvel aritmética de 50 períodos

Contudo, em relação às demais estratégias, operar com os 5 mais fortes do IBOV performou melhor apenas que a estratégia utilizando o filtro da MMA50 com saída na máxima dos dois dias anteriores.

Novamente, em termos relativos, a melhora no lucro por operação foi de 17%. No entanto, o drawdown quase triplicou! Ou seja, o aumento do lucro não compensou o aumento do risco.

Apesar do drawdown, a estratégia teve um bom lucro por operação, próximo dos backtests anteriores. Temos que levar em consideração que estamos trabalhando com os 5 ativos mais fortes do IBOV e papeis em tendência de alta dificilmente atingem valores de IFR2 abaixo de 30. Repare que o número de operações foi o menor dentre os demais (72), uma média de 1 operação/mês durante 6 anos.

Conclusão

Há três pontos importantes que devemos considerar ao utilizar essa estratégia:

  • Como nós estamos escolhendo um novo ativo a cada operação (de acordo com a variação e sinal de entrada), nós operamos pouco em cada papel, que poderiam ter bons resultados se fossem operados a longo prazo. Note pelas tabelas criadas a partir da função statistic_per_asset(), que certos ativos foram operados somente uma ou duas vezes. Em outras palavras, nós temos um espaço amostral de operações por ativo muito pequeno, o que impede que a estatística trabalhe a nosso favor;
  • Perceba que, pelos resultados da tabela acima, nós estamos abrindo mão de um lucro melhor e um drawdown menor para diminuir nossas chances de tomar prejuízo (operar em um papel que reverta sua tendência e inicie uma perna de baixa);
  • Por fim, como trabalhamos somente com os ativos mais fortes do Ibovespa, acabamos excluindo papeis que não costumam estar no top 5, mas que performariam bem nessa estratégia de IFR2 (como EQTL3 e LREN3).

Uma alternativa seria mudar a forma de seleção dos ativos — em vez de filtrar os papeis mais fortes, selecionar apenas os que apresentaram melhor resultado do backtest em um janela móvel de tempo. Mas isso é um papo para um próximo post!