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