QuantBrasil

Aprenda a Realizar o Backtest da Estratégia do Candle Pavio Utilizando Python

Andressa Quintanilha
Por Andressa Quintanilha
10 janeiro, 2022
Compartilhar:

O padrão do Candle Pavio é definido pelo trader Stormer como um candle cujo corpo (diferença entre abertura e fechamento) apresenta tamanho menor ou igual a 20% de sua amplitude (diferença entre a máxima e a mínima).

Na plataforma do QuantBrasil, disponibilizamos um screening do Candle Pavio, que auxilia traders a identificarem esse padrão se formando nos timeframes diário e semanal.

Além disso, é possível rodar o backtest desse padrão no nosso Simulador de Estratégias. Hoje, entenderemos como esta estratégia funciona e como qualquer um pode realizar sua própria análise do Candle Pavio utilizando Python.

Entendendo a Estratégia do Candle Pavio

A ideia do Candle Pavio é identificar um momento de pré-explosão (daí o nome pavio) que pode ser representado por uma contração de volatilidade dos preços do ativo. Essa contração funciona como uma mola comprimida, que, uma vez que é "solta", seja para baixo ou para cima, poderá "explodir" nessa direção.

Dessa forma, para ativar a estratégia precisamos identificar duas condições:

  1. Uma contração de volatilidade. Representamos essa contração como um candle de corpo pequeno, ou seja, cujo tamanho da diferença entre a abertura e o fechamento tenha até 20% da amplitude total do candle (diferença entre a máxima e a mínima).
  2. Um rompimento dessa contração. O rompimento pode ser para baixo (operação de venda) ou para cima (operação de compra). Representamos esse rompimento como um gap de abertura.

Sendo essas duas condições satisfeitas, a operação é iniciada na abertura do candle e encerrada em seu fechamento. Note que embora isso signifique uma operação de day-trade no gráfico diário, essa estratégia pode ser utilizada no gráfico semanal (nesse caso, a entrada seria na abertura da segunda-feira e a saída no fechamento da sexta-feira).

Na nossa análise, faremos o backtest com os parâmetros mais usuais da estratégia:

  • Timeframe diário;
  • Corpo de até 20% da amplitude;
  • Gap de pelo menos 0,5% em relação ao candle anterior.

Além disso, testaremos duas estratégias: uma sem stop e outra com stop a 2% do preço da abertura. Lembrando que os usuários do QuantBrasil podem customizar esses parâmetros no nosso Simulador de Estratégias.

Agora que já desmembramos a estratégia, vamos à análise!

Importanto as bibliotecas e os dados necessários

Como de costume, vamos importar as bibliotecas a serem utilizadas em nossa análise:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

Em seguida, vamos carregar a nossa base de dados, que estará disponível para download no nosso grupo do Telegram.

Realizaremos o backtest nos últimos 5 anos de negociações de PETR4.

df = pd.read_csv('../data/D1/PETR4-wick-candle.csv', index_col='datetime', parse_dates=['datetime'])
df
openhighlowclose
datetime
2017-01-0615.7215.8615.4515.61
2017-01-0915.4515.5515.2815.28
2017-01-1015.5515.6315.3415.43
2017-01-1115.5915.6315.2215.61
2017-01-1216.0816.1815.8415.84
...............
2021-12-2928.7028.9028.4228.54
2021-12-3028.5528.7028.3928.45
2022-01-0328.5429.2228.5329.09
2022-01-0429.1629.4028.9129.20
2022-01-0529.1929.2728.4128.41

1234 rows × 4 columns

Determinando os Sinais de Entrada

Com a base de dados em formato de dataframe, o primeiro passo para iniciarmos nossa análise é determinar os sinais de entrada. São eles: o candle pavio e a abertura em gap maior ou igual a 0,5%.

Como mencionado, o Candle Pavio (wick_candle) apresenta corpo (body) menor ou igual a 20% (body_pct) de sua amplitude (amplitude). Escreveremos o código da "perspectiva" do candle de entrada, portanto a primeira condição para entrarmos em uma operação é que o candle anterior seja um candle pavio (condition_1).

Em seguida determinaremos o gap, que caracteriza a diferença entre a abertura atual e o fechamento anterior. Nesse caso, essa diferença deve ser maior ou igual a 0,5% (gap_pct). Definimos então, nossa segunda condição de entrada (condition_2).

Como efeturaremos tanto operações de compra, como de venda, criaremos uma nova coluna signal a fim de separar os tipos de operações:

  • Caso a abertura seja maior que o fechamento anterior (long_condition), significa que houve um gap de alta e portanto a operação é de compra, ou seja, signal é 1;
  • Caso contrário, o gap foi de baixa (short_condition) e portanto a operação é de venda: signal é -1;

Se as condições para o Candle Pavio não acontecerem, não houve trade nesse dia e portanto signal é 0.

# Establishing the signal candle
# wick candle pattern: the candle body is less than or equal to 20% of the candle's amplitude
amplitude = df['high'] - df['low']
body = abs(df['open'] - df['close'])
body_pct = 0.2
wick_candle = body <= amplitude * body_pct
condition_1 = wick_candle.shift(1) == True

# The gap is the difference between the opening and the previous closing (in percent)
gap = abs(df['open'] - df['close'].shift(1)) / df['close'].shift(1)
gap_pct = 0.005
condition_2 = gap >= gap_pct

# Isolating buying operations (1) from selling (-1) operations according to conditions
long_condition = df['open'] > df['close'].shift(1)
short_condition = df['open'] < df['close'].shift(1)
df['signal'] = np.where(
  condition_1 & condition_2 & long_condition, 1,
  np.where(condition_1 & condition_2 & short_condition, -1, 0)
)

df
openhighlowclosesignal
datetime
2017-01-0615.7215.8615.4515.610
2017-01-0915.4515.5515.2815.280
2017-01-1015.5515.6315.3415.430
2017-01-1115.5915.6315.2215.610
2017-01-1216.0816.1815.8415.841
..................
2021-12-2928.7028.9028.4228.540
2021-12-3028.5528.7028.3928.450
2022-01-0328.5429.2228.5329.090
2022-01-0429.1629.4028.9129.200
2022-01-0529.1929.2728.4128.410

1234 rows × 5 columns

OBS: Caso você tenha tido dificuldade em entender esse código, recomendo a leitura dos artigos mais antigos do nosso blog. Lá, temos posts bem introdutórios sobre pandas e NumPy.

Agora que temos um DataFrame com os sinais de compra e venda, podemos facilmente verificar quantos trades de cada tipo aconteceram no período:

# A trade happened whenever signal was different than 0
total_trades = df[df['signal'] != 0]

# Long trades have signal equal to 1, and short trades have signal equal to -1
long_trades = df[df["signal"] == 1]
short_trades = df[df["signal"] == -1]

print(f"Total de Operações: {len(total_trades)}")
print(f"Total de Operações de Compra: {len(long_trades)}")
print(f"Total de Operações de Venda: {len(short_trades)}")

Total de Operações: 149
Total de Operações de Compra: 87
Total de Operações de Venda: 62

Aplicando os filtros acima, observamos um total de 149 operações baseadas nessa estratégia em 5 anos, das quais 87 foram de compra e 62 de venda. Isso resulta entre 2 a 3 trades por mês em média, o que configura um sinal pouco frequente (pelo menos para este papel).

Calculando a Rentabilidade e Taxa de Acerto

Para podermos calcular a rentabilidade e da taxa de acerto dessa estratégia, vamos separar todos os trades efetuados em um novo DataFrame (trades) e calcular o número de ações que serão compradas a cada operação.

Repare que o preço de entrada (entry) nada mais é do que o preço de abertura e o alvo (target), o preço de fechamento.

Para calcularmos o número de ações (shares), alocaremos uma quantidade inicial de capital (initial_capital) e compraremos o máximo de lotes cheios (múltiplos de 100) que conseguirmos. Sendo assim, toda operação terá uma quantidade fixa de capital disponível.

# Isolating all trades into a dataframe 
trades = df[df["signal"] != 0].copy()

# The entry is always made at the opening
trades['entry'] = trades['open']
trades['target'] = trades['close']

# Create a function to round any number to the smallest multiple of 100
import math
def round_down(x):
    return int(math.floor(x / 100.0)) * 100

# The number of shares is how much it is possible to buy with the allocated initial capital
initial_capital = 100000
trades['shares'] = trades['entry'].apply(lambda price: round_down(initial_capital / price))

trades
openhighlowclosesignalentrytargetshares
datetime
2017-01-1216.0816.1815.8415.84116.0815.846200
2017-01-2416.0516.3615.9115.97116.0515.976200
2017-01-2616.0816.1215.7415.74116.0815.746200
2017-02-0115.2115.3614.8614.97115.2114.976500
2017-02-1715.6815.8415.5515.56-115.6815.566300
...........................
2021-11-3026.2826.5825.8826.39-126.2826.393800
2021-12-0126.7627.5026.4426.54126.7626.543700
2021-12-0729.3229.6128.8429.40129.3229.403400
2021-12-0929.1429.5328.8329.33-129.1429.333400
2021-12-1729.3629.6828.8228.99-129.3628.993400

149 rows × 8 columns

O resultado (result) de cada operação nada mais é do que a diferença entre a entrada e a saída, multiplicada pelo sinal da operação.

Já o lucro (profit) é o resultado multiplicado pelo número de ações compradas (shares). Podemos ainda calcular o lucro acumulado (acc_profit) a fim de plotarmos a evolução do nosso capital no período (utilizamos a função cumsum para isso).

trades['result'] = trades['signal'] * (trades['target'] - trades['entry'])
trades['profit'] = trades['shares'] * trades['result']
trades['acc_profit'] = trades['profit'].cumsum()

trades
openhighlowclosesignalentrytargetsharesresultprofitacc_profit
datetime
2017-01-1216.0816.1815.8415.84116.0815.846200-0.24-1488.0-1488.0
2017-01-2416.0516.3615.9115.97116.0515.976200-0.08-496.0-1984.0
2017-01-2616.0816.1215.7415.74116.0815.746200-0.34-2108.0-4092.0
2017-02-0115.2115.3614.8614.97115.2114.976500-0.24-1560.0-5652.0
2017-02-1715.6815.8415.5515.56-115.6815.5663000.12756.0-4896.0
....................................
2021-11-3026.2826.5825.8826.39-126.2826.393800-0.11-418.019251.0
2021-12-0126.7627.5026.4426.54126.7626.543700-0.22-814.018437.0
2021-12-0729.3229.6128.8429.40129.3229.4034000.08272.018709.0
2021-12-0929.1429.5328.8329.33-129.1429.333400-0.19-646.018063.0
2021-12-1729.3629.6828.8228.99-129.3628.9934000.371258.019321.0

149 rows × 11 columns

Vamos agora definir uma função statistics para calcular algumas estatísticas importantes para a nossa análise, além de plotar a evolução do nosso lucro.

def statistics(trades):
    shorts = trades[trades['signal'] == -1]
    successful_shorts = len(shorts[shorts['result'] > 0])

    longs = trades[trades['signal'] == 1]
    successful_longs = len(longs[longs['result'] > 0])

    total_profit = sum(trades['profit']) / initial_capital
    longs_profit = sum(longs['profit']) / initial_capital
    shorts_profit = sum(shorts['profit']) / initial_capital

    print(f'Total trades: {len(trades)}')
    print(f'Total longs: {len(longs)}')
    print(f'Total shorts: {len(shorts)}')
    print(f'Successful trades: {successful_longs + successful_shorts}')
    print(f'Successful bullish trades: {successful_longs}')
    print(f'Successful bearish trades: {successful_shorts}')
    print(f'Total Accuracy (%): {(successful_longs + successful_shorts) / len(trades):.2%}')
    print(f'Bullish Accuracy (%): {(successful_longs) / len(longs):.2%}')
    print(f'Bearish Accuracy (%): {(successful_shorts) / len(shorts):.2%}')
    print(f'Total profit: {total_profit:.2%}')
    print(f'Bullish Profit (%): {longs_profit:.2%}')
    print(f'Bearish Profit (%): {shorts_profit:.2%}')
    
    plt.title('Resultado')
    graph = trades['acc_profit'].plot()
    return graph
statistics(trades)

Total trades: 149
Total longs: 87
Total shorts: 62
Successful trades: 76
Successful bullish trades: 41
Successful bearish trades: 35
Total Accuracy (%): 51.01%
Bullish Accuracy (%): 47.13%
Bearish Accuracy (%): 56.45%
Total profit: 19.32%
Bullish Profit (%): -11.72%
Bearish Profit (%): 31.04%

<matplotlib.axes._subplots.AxesSubplot at 0x7fd678e3ea10>

Analisando os dados acima, podemos dizer de uma maneira geral que os resultados não foram dos melhores: a taxa de acerto foi de 51% e o lucro de aproximadamente 19% em 4 anos.

Porém, se olharmos isoladamente para os trades efetuados na ponta da venda, vemos que os resultados foram mais promissores (taxa de acerto de 56% e lucro de 31%) do que aqueles operados na ponta da compra (taxa de acerto de 47% e lucro de -12%).

Além disso, através do gráfico nós podemos observar que a estratégia teve um ótimo desempenho em 2017 e 2018, ficou lateral em 2019, despencou em 2020 e se recuperou novamente em 2021. Essa queda brusca coincide com o período de pandemia da Covid-19, o que expõe as fragilidades de não se colocar nenhum stop.

Vamos tentar suavizar tal queda adicionando um stop calculado a partir de um risco fixo.

Aplicando um stop

A fim de limitar nossa perda quando uma operação falhar, vamos determinar um stop de 2% do nosso capital inicial (capital_exposure), ou seja, cada operação terá um risco máximo de R$2.000. O stop portanto, é determinado pelo preço de entrada multiplicado pelo risco estabelecido.

Para calculá-lo, faremos uma cópia do DataFrame atual (trades_w_stop). Em seguida, vamos diferenciar o stop das operações de compra e venda:

  • No caso de uma compra, o stop fica abaixo do preço de entrada (1capital_exposure){(1 - capital\_exposure)};
  • No caso de uma venda, o stop fica acima de entrada (1+capital_exposure){(1 + capital\_exposure)}.

Portanto, para estabelecer o alvo, deveremos seguir a seguinte lógica:

  • Caso seja uma operação de compra (sinal igual a 1) e a mínima for menor que o stop, significa que a operação foi stopada, logo o alvo será o stop calculado;
  • Caso seja uma operação de venda (sinal igual a -1) e a máxima for maior que o stop, a operação também foi stopada;
  • Se em ambos os casos o stop não for atingido, o alvo será o fechamento do candle.
trades_w_stop = trades[['entry', 'high', 'low', 'close', 'signal']].copy()

capital_exposure = 0.02
trades_w_stop['long_stop'] =  round(trades_w_stop['entry'] * (1 - capital_exposure), 2)
trades_w_stop['short_stop'] =  round(trades_w_stop['entry'] * (1 + capital_exposure), 2)

trades_w_stop['target'] = np.where(
    (trades_w_stop['signal'] == 1) & (trades_w_stop['low'] < trades_w_stop['long_stop']), 
    trades_w_stop['long_stop'],
    np.where(
        (trades_w_stop['signal'] == -1) & (trades_w_stop['high'] > trades_w_stop['short_stop']), 
        trades_w_stop['short_stop'], 
        trades_w_stop['close']
    )
)

trades_w_stop
entryhighlowclosesignallong_stopshort_stoptarget
datetime
2017-01-1216.0816.1815.8415.84115.7616.4015.84
2017-01-2416.0516.3615.9115.97115.7316.3715.97
2017-01-2616.0816.1215.7415.74115.7616.4015.76
2017-02-0115.2115.3614.8614.97114.9115.5114.91
2017-02-1715.6815.8415.5515.56-115.3715.9915.56
...........................
2021-11-3026.2826.5825.8826.39-125.7526.8126.39
2021-12-0126.7627.5026.4426.54126.2227.3026.54
2021-12-0729.3229.6128.8429.40128.7329.9129.40
2021-12-0929.1429.5328.8329.33-128.5629.7229.33
2021-12-1729.3629.6828.8228.99-128.7729.9528.99

149 rows × 8 columns

Em seguida, vamos escrever uma função para calcular a quantidade de ações que negociaremos a cada operação (get_shares). Ela irá receber como argumentos: o capital inicial alocada para acada operação, o risco estabelecido, o preço de entrada e do stop.

Dividiremos essa função em 4 etapas:

  1. Calcular o risco máximo (max_risk), que nós determinamos anteriormente que será de 2%;
  2. Em seguida, calcularemos o risco inerente a cada operação (operation_risk), que nada mais é do que a diferença entre a entrada e o stop;
  3. A partir desses valores conseguimos descobrir quantas ações nós somos capazes de comprar com o capital alocado para cada operação, considerando o risco estabelecido (allowed_shares);
  4. Entretanto, só podemos negociar quantas ações o nosso capital alocado permitir (max_shares). Sendo assim, sempre selecionaremos o mínimo entre a quantidade de ações que podemos adquirir de acordo com nosso risco e a quantidade de ações que podemos adquirir de acordo com nosso capital total.
def get_shares(initial_capital, capital_exposure, entry, stop):

    # Maximum risk is how much of our initial capital we are willing to lose,
    # where capital_exposure is the percentage of it
    max_risk = initial_capital * capital_exposure

    # The operation has an inherent risk: the difference between
    # the price when we enter and the price when we are stopped (operation failed)
    operation_risk = abs(entry - stop)

    # Based on those two values, there is a max number of shares that we are allowed to buy
    allowed_shares = max_risk / operation_risk

    # But we can only buy as many shares as our initial capital allows
    max_shares = initial_capital / entry

    # Because of that, we will always buy the minimum between the number of shares we can buy 
    # according to our risk and the number of shares we can buy according to our capital
    shares = min(allowed_shares, max_shares)

    return round_down(shares)

Como feito anteriormente, criaremos uma nova coluna shares para o número de ações adquiridas, aplicando get_shares ao longo do DataFrame através da função apply do pandas.

Por fim, calcularemos o resultado e o lucro novamente para, em seguida, calcular as estatísticas através da função statistics.


trades_w_stop['shares'] = trades_w_stop.apply(
  lambda x: get_shares(
    initial_capital, 
    capital_exposure, 
    x['entry'], 
    x['long_stop'] if x['signal'] == 1 else  x['short_stop']
  ),
  axis=1
)

trades_w_stop['result'] = trades_w_stop['signal'] * (trades_w_stop['target'] - trades_w_stop['entry'])
trades_w_stop['profit'] = trades_w_stop['shares'] * trades_w_stop['result']
trades_w_stop['acc_profit'] = trades_w_stop['profit'].cumsum()

# Delete unused columns
trades_w_stop.drop(['long_stop', 'short_stop'], axis=1, inplace=True)

trades_w_stop
entryhighlowclosesignaltargetsharesresultprofitacc_profit
datetime
2017-01-1216.0816.1815.8415.84115.846200-0.24-1488.0-1488.0
2017-01-2416.0516.3615.9115.97115.976200-0.08-496.0-1984.0
2017-01-2616.0816.1215.7415.74115.766200-0.32-1984.0-3968.0
2017-02-0115.2115.3614.8614.97114.916500-0.30-1950.0-5918.0
2017-02-1715.6815.8415.5515.56-115.5663000.12756.0-5162.0
.................................
2021-11-3026.2826.5825.8826.39-126.393700-0.11-407.033286.0
2021-12-0126.7627.5026.4426.54126.543700-0.22-814.032472.0
2021-12-0729.3229.6128.8429.40129.4033000.08264.032736.0
2021-12-0929.1429.5328.8329.33-129.333400-0.19-646.032090.0
2021-12-1729.3629.6828.8228.99-128.9933000.371221.033311.0

149 rows × 10 columns

statistics(trades_w_stop)

Total trades: 149
Total longs: 87
Total shorts: 62
Successful trades: 74
Successful bullish trades: 40
Successful bearish trades: 34
Total Accuracy (%): 49.66%
Bullish Accuracy (%): 45.98%
Bearish Accuracy (%): 54.84%
Total profit: 33.31%
Bullish Profit (%): 1.67%
Bearish Profit (%): 31.65%

<matplotlib.axes._subplots.AxesSubplot at 0x7fd678d9add0>

Podemos dizer que, ao aplicar o stop, nossa taxa de acerto manteve-se estável. Entretanto, nosso lucro saltou de 19% para 33%, muito influenciado pelas operações na ponta da compra, que reverteram um prejuízo de -12% para um pequeno lucro de 1%.

Embora o drawdown no 1º semestre de 2020 ainda tenha sido alto, conseguimos uma grande redução de prejuízo no período.

Conclusão

De uma maneira geral, podemos dizer que a estratégia do candle pavio demonstra um melhor desempenho em operações na ponta da venda do que operações de compra. Todavia, analisamos aqui apenas uma combinação de parâmetros que podemos aplicar para essa estratégia.

No Simulador de Estratégias, você pode alterar parâmetros como: timeframeperíodotamanho do corpo do candle paviotamanho do gapquantidade de capital alocado para cada operaçãorisco, além de poder aplicar o Éden dos Traders como filtro.

Qualquer dúvida, basta mandar na caixinha de feedback localizada à sua direita (para membros) ou no nosso grupo do Telegram. Fique ligado porque o ano está só começando e temos muitas novidades por vir!