No âmbito da análise técnica, uma mesma estratégia pode apresentar resultados superiores ao se modificar um simples parâmetro. Por isso se faz tão necessário a realização de backtests, a fim de se alcançar o melhor modelo possível.
No último artigo da série nós testamos a estratégia de IFR2, na qual opera-se na ponta da compra quando o indicador está abaixo ou igual a 30, e vende-se na máxima dos dois dias anteriores. Dando continuidade a esse backtest, iremos analisar possíveis pontos de entrada ao se testar diferentes valores de IFR2 (30, 20, 10 e 5).
Esse artigo envolverá 5 passos:
Calcular o IFR para 2 períodos;
Criar uma função para definir os pontos de entrada e saída;
Criar uma função para o algoritmo que simula as operações;
Calcular o drawdown e a estatística (taxa de acerto/erro, lucro, entre outros);
Realizar o backtest para todos os valores de IFR2.
Para isso, utilizaremos diversas funções que já foram desenvolvidas em posts anteriores. Logo, para garantir que você irá acompanhar cada código escrito aqui, vamos recapitular alguns deles:
Agora que você já revisou todos os códigos, vamos começar!
1. Calcular o IFR para 2 períodos
Antes de qualquer coisa, vamos importar as bibliotecas de interesse e baixar os dados necessários (preço de abertura, máxima, fechamento e fechamento ajustado) de um ativo qualquer nos últimos cinco anos.
# %%capture means we suppress the output
%%capture
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
!pip install yfinance
import yfinance as yf
[*********************100%***********************] 1 of 1 completed
Open
High
Close
Adj Close
Date
2015-01-02
12.4975
12.5752
12.5455
10.508009
2015-01-05
12.4678
12.4678
12.1967
10.215857
2015-01-06
12.1488
12.4579
12.3372
10.333539
2015-01-07
12.3769
12.6612
12.5471
10.509348
2015-01-08
12.4959
12.6446
12.5620
10.521830
Para o cálculo do IFR2 utilizaremos a mesma função simplificada que criamos no último post dessa série e, igualmente, iremos atribuir o indicador calculado a uma nova coluna do nosso dataframe.
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
2. Criar uma função para definir os pontos de entrada e saída
Com o IFR2 calculado, daqui em diante nós criaremos funções para cada passo, a fim de utilizá-las mais para frente para iterar sobre nossa lista de parâmetros (30, 20, 10 e 5). Portanto, o segundo passo é criar uma função que irá definir os pontos de entrada e saída da nossa estratégia.
A função strategy_points irá aceitar dois argumentos: o dataframe e o valor do parâmetro de IFR2, respectivamente. O código, por sua vez, abrange não só a criação das colunas para isolar os possíveis pontos de entrada e saída, como também envolve a lógica para estabelecer os exatos preços de compra e venda, retornando enfim, o nosso dataframe com todas as informações necessárias.
3. Criar uma função para o algoritmo que simula as operações
De maneira semelhante, criaremos uma função para o algoritmo em cima do código desenvolvido anteriormente.
Esta função (backtest_algorithm) também aceitará dois argumentos: o dataframe e o montante de capital alocado para a estratégia (initial_capital), que terá como padrão R$ 10.000. Além disso, a função retornará duas listas, uma com o lucro de cada operação (all_profits) e outra com o capital acumulado após cada operação (total_capital).
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
4. Calcular o drawdown e a estatística
O próximo passo é praticamente Ctrl+C e Ctrl+V!
O post sobre drawdown nos dá a função pronta (get_drawdown). Esta recebe dois argumentos: o dataframe e a coluna com os valores nos quais se deseja saber o drawdown.
Como o drawdown é uma medida importante na hora de se avaliar uma estratégia, nós o incluiremos na nossa função final, junto com todos os outros resultados (taxa de acerto/erro, lucro, entre outros). Dessa forma, iremos fazer algumas modificações no código da função strategy_test.
A parte inicial da função será a mesma: calculará o número total de operações (num_operation), o número de operações que deram lucro/prejuízo (gains e losses), suas respectivas porcentagens (pct_gains e pct_losses), e o lucro total (total_profit). Iremos incluir também o cálculo do lucro, em porcentagem, em relação ao nosso capital inicial (pct_profit).
Em seguida, transformaremos a lista de capital acumulado (total_capital) em um dataframe para então ser utilizado em get_drawdown. Por fim, retornaremos um dicionário com todos os resultados calculados.
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
}
5. Realizar o backtest
Finalmente, o último passo é realizar o backtest. Faremos isso através de um loop, no qual iremos iterar sobre nossa lista de parâmetros (parameters) e rodar cada função criada nos passos anteriores.
Armazenaremos as informações provenientes do loop em dois dicionários diferentes: um com toda a estatística das 4 estratégias (statistics) e outro com o lucro e capital acumulado de cada operação (cap_evolution). Isso nos facilitará na hora de avaliar cada uma delas, uma vez que seremos capazes de plotá-las em gráficos, melhorando assim a visualização dos dados.
Vamos dar uma olhada no dicionário com os resultados da estatística. Todas as informações para avaliarmos nossa estratégia estão nele, mas a visualização ainda não é das melhores.
O pandas possui uma função que nos permite transformar um dicionário diretamente em um dataframe: pd.DataFrame.from_dict(). Esta recebe o dicionário como argumento e nos permite escolher a orientação em relação às chaves do dicionário. Usaremos orient='index' para determinar que as chaves irão corresponder às linhas.
Repare que as duas últimas colunas apresentam muitas casas decimais que acabam poluindo o nosso dataframe. Vamos arrendondar esses números através da função .round() e restringi-los a apenas 2 casas decimais.
A visualização ficou melhor que no formato de dicionário, certo? Pois iremos melhorar ainda mais! Vamos agora plotar as informações mais relevantes (número de operações, % de acerto e o lucro total) em gráficos de barra.
Para isso, criaremos um função a fim de plotar um gráfico de barras para cada um desses resultados. A função plot_bars receberá como argumentos, na ordem:
o título do gráfico;
o valor do eixo x;
o valor do eixo y;
a legenda do eixo x, que será "IFR2" por padrão;
a legenda do eixo y, que será opcional.
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'
)
Para o eixo x, utilizaremos o índice do dataframe em formato de string. Isso é necessário para que tenhamos o valor exato de cada parâmetro para cada barra, e não um range de 30 a 5.
x = statistics_df.index.astype(str)
Agora vamos plotar os gráficos!
plot_bars(title="Número de Operações", x=x, y=statistics_df["num_operations"])
plot_bars(title="Taxa de Acerto (%)", x=x, y=statistics_df["pct_gains"])
plot_bars(title="Lucro Total (%)", x=x, y=statistics_df["pct_profit"])
Podemos embasar ainda mais nossa análise ao visualizar a evolução do capital em cada uma dessas estratégias. Portanto, plotaremos o total_capital para cada parâmetro:
<matplotlib.axes._subplots.AxesSubplot at 0x7f52401aed50>
Conclusão
Apesar de não termos feito nenhum teste de significância, podemos considerar que as estratégias utilizando valores de IFR2 de 30 e 20 tiveram resultados semelhantes, assim como o par 10 e 5. Observe pela tabela abaixo.
IFR2 <=
Lucro (%)
Drawdown máx. (%)
30
138.54
10.37
20
134.82
7.11
10
87.71
5.18
5
58.10
5.35
Resumindo, a estratégia com parâmetro de 30 possui o melhor lucro (138%), mas apresenta o maior drawdown (10.4%). Por outro lado, a estratégia com parâmetro de 5 tem um dos menores drawdowns (5.3%), embora apresente o menor lucro de todos (58%). Esse resultado representa uma das regras básicas do mercado financeiro: quanto maior o risco, maior o potencial de ganho. Dessa forma, a relação lucro/drawdown adequada é questão de estratégia e perfil de cada investidor.
Além disso, nesse backtest não estamos levando em consideração um fator relevante: o ativo. Para um ativo em forte tendência de alta, dificilmente o IFR2 irá atingir valores muito inferiores, como 5. Analogamente, em uma forte tendência de baixa, é possível que valores menores de IFR2 funcionem melhor.
Nos próximos posts, analisaremos os seguintes aspectos:
Backtest do IFR2 para ativos em diferentes tendências;
Estatísticas para diferentes estratégias de stop loss;
Resultados utilizando filtros de seleção, como médias móveis.