"Buy the dip" en Python

Gerard Sánchez - 4 de Octubre de 2021 a las 10:45 - Estrategias




Dentro del apasionante mundo de las estrategias en la inversión/trading, ya sean estas de gestión activa o pasiva, de momentum, de reversión a la media... todas ellas ofrecen un gran potencial de personalización y testeo, es ahí donde Python puede realmente ayudarnos.

En esta ocasión me he decantado por trabajar en una estrategia propuesta por Grouchito, conocido de la comunidad financiera y amigo del proyecto. Se trata de un "Buy the dip" a partir de máximos absolutos.

Básicamente la estrategia consiste en ser capaces de comprar en base a un % de caída sobre el máximo absoluto y no repetir la compra hasta que se suceda otro máximo absoluto con ese mismo porcentaje de caída (de momento sin venta a la vista, B&H).

Para el siguiente ejemplo vamos a necesitar las siguientes librerías:

import pandas_datareader.data as wb
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

Inicialmente y a modo de cabecera, vamos a necesitar una variable en relación al porcentaje de caída o sobre el total del valor (en función de cómo hagamos luego el cálculo)  y un DataReader para hacer la descarga de los precios de cierre para el periodo y activo que queramos.

porcen = 95      #(1-porcen) = % de caída.
ticker = 'AAPL'
df = wb.DataReader(ticker,'yahoo','2020-09-01')['Adj Close']

Voy a trabajar con un DataFrame llamado signal en el que voy a tener los precios de cierre, el máximo acumulado, el precio de compra (precio de cierre * porcentaje), una columna para guardar máximos y la señal de compra definitiva cuando se cumpla el porcentaje especificado, os los numero ya que voy a estar utilizando la función loc() en base a su numeración para trabajar.

signal = pd.DataFrame(index = df.index)     
signal['cotdia'] = df                       # 0
signal['maximo_acumulado'] = df.cummax()    # 1
signal['compra']=(df.cummax()*porcen/100)   # 2
signal['maximos']=0                         # 3
signal['signalbuy']=0                       # 4

Necesito dos objetos, en este caso listas, para guardar los máximos relativos que se vayan sucediendo durante el periodo y también con la condición del porcentaje de bajada especificado, de momento las voy a dejar creadas:

listmax=[] 
listcom=[] 

También crearé otro DataFrame a modo de resumen final (3x3) con columnas que usaré más adelante, aunque de momento lo dejaré vacío ya que lo usaré a modo de semáforo para contabilizar máximos y compras.

car = pd.DataFrame(0,index=np.arange(3),columns=['Condición','Info','Precios'])

A partir de un bucle while y un contador k que se irá incrementando (para darle finalización al bucle) vamos a recorrer el número de registros (días) con len().

Siempre que la columna de máximos acumulados sea superior a su respectivo anterior, vamos a utilizar la segunda fila de la primera columna (por ejemplo) a modo de contador, le pondremos 1 en cada recursión y mostraremos con un print el número de registro y máximo relativo en precio para a continuación ir rellenando nuestra lista de los registros donde se producen máximos relativos.

k=1
while k<len(df):
    if signal.iloc[k,1] > signal.iloc[(k-1),1]:
        car.iloc[1,0]=1
        print('Registro:',k,'   **  Nuevo máximo: ', signal.iloc[k,1])
        listmax.extend([k])

Si nuestra segunda fila y primera columna del DataFrame car contiene ese 1 y el precio de cierre es inferior o igual al porcentaje del máximo del valor, le pondremos un 0 y rellenaremos nuestra segunda lista listcom que contendrá los registros a los que se ejecutará finalmente la compra con sus respectivos precios ya que cumplirá las condiciones que buscamos.

Avanzamos dentro de los registros con la suma en asignación en k.

    if car.iloc[1,0]==1 and signal.iloc[k,0] <= signal.iloc[k,2]:
        car.iloc[1,0] = 0
        listcom.extend([k])
        print('\nRegistro:', k,' Compramos a: ',signal.iloc[k,2],'     *\n')
    k+=1

Una vez tenemos nuestras listas a punto, aprovechando el DataFrame que tenemos lo utilizaremos para crear un resumen de nuestras 3 columnas.

En este DataFrame podremos ver el ticker, el máximo absoluto del periodo, el último precio de cierre, el % de caída, la última compra y los importes para cada fila numérica respectivamente.

También he añadido la información de que si en el 'semáforo' tenemos un 0, significa que estamos "pendientes de un máximo" ya que acaba de comprar y "pendientes de comprar" si se ha hecho un máximo. Con esto gestionamos posibles cortes en los periodos a título informativo.

car.iloc[0,0]= ticker
car.iloc[0,1]='MÁXIMO ABSOLUTO'
car.iloc[1,1]='ÚLTIMO PRECIO'
car.iloc[2,0]= str(100-porcen)+'%'
car.iloc[2,1]='ÚLTIMA COMPRA' 
car.iloc[2,2]=round(signal.iloc[t-1,2],3)
car.iloc[1,2]=round(signal.iloc[t-1,0],3)
car.iloc[0,2]=round(signal.iloc[t-1,1],3)
if car.iloc[1,0]==0:
    car.iloc[1,0]='Pdt.Mx.'
else:
    car.iloc[1,0]='Pdt.Cp.'

Sabemos que los registros en listcom se han dado después de un máximo, pues buscamos en listmax el más cercano anterior a nuestro registro.

Por ejemplo en listcom tenemos 85,103 y 260.

En listmax tenemos 81, 97, 98, 99, 100, 212, 214, 216, 217, 239, 240, 250, 253, 254, 255.

El registro de compra 85 vino después del máximo 81, el registro de compra 103 sólo puede venir del 100 y el registro de compra 260 proviene del máximo 255.  Así de simple. 

Creamos una nueva lista listamaxOK con los que nos permitieron comprar. ¿Cómo lo haremos?.

z, k y p serán variables de control para el bucle que vamos a crear.

listmaxOK=[] 

z=int(0)
k=int(0)
p=int(0)

En listcom tenemos los registros de compra. En listmax habrá, como mínimo los mismos registros que en listcom ya que no hay compra sin máximo asociado pero seguramente habrá muchos mas registros. Hacemos un bucle con z para los registros de listcom.

Por cada z, buscamos con otro bucle, p , en listmax, un registro con un valor mayor que el de compra,  al encontrarlo, sabemos que ese no es porque si es mayor hemos comprado antes que el máximo y no puede ser, por lo tanto es el anterior a ese hallado, el p-1.

Lo insertamos en una lista listmaxOK, que recogerá los que buscamos. Con el break tenemos la p con un valor que seguirá para el siguiente registro, z, en listcom.

Le restaremos 1 para que siga a partir del último p-1.

Cuando acabamos el bucle de las z, listcom nos preguntamos si quedaron máximos en listmax, si es así lo sabremos porque p habrá quedado con un valor inferior a len(listmax) entonces vamos a grabarnos en listmaxOk el último.

for z in range(len(listcom)):
    while p < (len(listmax)) :
        if listmax[p] >  listcom[z]:
            mx=int(listmax[p-1])
            listmaxOK.extend([mx])
            break
        p+=1
    p-=1
if p<len(listmax):
    mx=int(listmax[len(listmax)-1])
    listmaxOK.extend([mx])

Mostramos por pantalla los registros de listcom y listmaxOK asociadas al precio:

print("Condición de compra realizada a:")
for z in range(len(listcom)):
    print(listcom[z],round(signal.iloc[listcom[z],2],3))

print("\nMáximos que han tenido una caída del porcentaje buscado:")
for x in range(len(listmaxOK)):
    print(listmaxOK[x],round(signal.iloc[listmaxOK[x],1],3))

Pasamos los 1 de listcom y listmaxOK a columnas máximos y signalbuy:

for i in range(len(listcom)):
    signal.iloc[listcom[i],4] = 1

for i in range(len(listmaxOK)):
    signal.iloc[listmaxOK[i],3]= 1

Finalmente podemos añadir un pequeño backtest a la estrategia, del estilo Buy&Hold (sin ventas) y comprobar la ganancia o pérdida para determinados supuestos, en este caso compras de 10.000 USD:

print('\n'*2)
print('RESULTADO ESTRATEGIA')

inv=int(10000)
patri = pd.DataFrame(0,index=np.arange(len(listcom)),columns=['Acciones','Inversión','Valor último'])
for j in range (len(listcom)):
    patri.iloc[j,1]= int(inv)
    patri.iloc[j,0]= int(inv//signal.iloc[listcom[j],0])
    patri.iloc[j,2]= round(patri.iloc[j,0]*signal.iloc[(len(signal)-1),2],2)
    j+=1

print(patri)
print('\nInv. total:', patri['Inversión'].sum(), '\nValor actual:', patri ['Valor último'].sum())
print('Ganancia / Pérdida:' ,round((patri['Valor último'].sum()-patri['Inversión'].sum()),2) ,'\nRendimiento:', round(((patri['Valor último'].sum()/patri['Inversión'].sum())-1)*100,2), '%')

Y el resto ya consiste en graficar el precio y señalizar esos 1 en el gráfico:

fig = plt.figure(figsize=(20,10))
fig.suptitle('Evolución de la cotización con estrategia "Buy the Dip"\n' + str(100-porcen) +' %' + " de caída\n\n" + ticker)
ax1 = fig.add_subplot( 111, ylabel='Cotización', xlabel="Fechas")

ax1.plot(df[signal['maximos'] == 1],'v', markersize=12, color='r')
ax1.plot(df[signal['signalbuy'] == 1],'^', markersize=12, color='g')

ax1.plot(df, color='k', label=ticker, lw=2)
ax1.legend()
ax1.grid()
plt.yscale('log')
plt.show()
 

Aquí os dejo el script entero:

import pandas_datareader.data as wb
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

porcen = 95      #(1-porcen) = % de caída.
ticker = 'AAPL'
df = wb.DataReader(ticker,'yahoo','2020-09-01')['Adj Close']

signal = pd.DataFrame(index = df.index)     
signal['cotdia'] = df                       # 0
signal['maximo_acumulado'] = df.cummax()    # 1
signal['compra']=(df.cummax()*porcen/100)   # 2
signal['maximos']=0                         # 3
signal['signalbuy']=0                       # 4

listmax=[] 
listcom=[] 

car = pd.DataFrame(0,index=np.arange(3),columns=['Condición','Info','Precios'])
car

k=1
while k<len(df):
    if signal.iloc[k,1] > signal.iloc[(k-1),1]:
        car.iloc[1,0]=1
        print('Registro:',k,'   **  Nuevo máximo: ', signal.iloc[k,1])
        listmax.extend([k])
    if car.iloc[1,0]==1 and signal.iloc[k,0] <= signal.iloc[k,2]:
        car.iloc[1,0] = 0
        listcom.extend([k])
        print('\nRegistro:', k,' Compramos a: ',signal.iloc[k,2],'     *\n')
    k+=1

car.iloc[0,0]= ticker
car.iloc[0,1]='MÁXIMO ABSOLUTO'
car.iloc[1,1]='ÚLTIMO PRECIO'
car.iloc[2,0]= str(100-porcen)+'%'
car.iloc[2,1]='ÚLTIMA COMPRA' 
car.iloc[2,2]= round(signal.iloc[len(df)-1,2],4)
car.iloc[1,2]= round(signal.iloc[len(df)-1,0],4)
car.iloc[0,2]= round(signal.iloc[len(df)-1,1],4)
if car.iloc[1,0]==0:
    car.iloc[1,0]='Pdt.Mx.'
else:
    car.iloc[1,0]='Pdt.Cp.'

print(car,"\n")

listmaxOK=[]

z=int(0)
k=int(0)
p=int(0)

for z in range(len(listcom)):
    while p < (len(listmax)) :
        if listmax[p] >  listcom[z]:
            mx=int(listmax[p-1])
            listmaxOK.extend([mx]) 
            break
        p+=1
    p-=1
if p<len(listmax):
    mx=int(listmax[len(listmax)-1])
    listmaxOK.extend([mx])

print("Condición de compra realizada a:")
for z in range(len(listcom)):
    print(listcom[z],round(signal.iloc[listcom[z],2],3))

print("\nMáximos que han tenido una caída del porcentaje buscado:")
for x in range(len(listmaxOK)):
    print(listmaxOK[x],round(signal.iloc[listmaxOK[x],1],3))

for i in range(len(listcom)):
    signal.iloc[listcom[i],4] = 1

for i in range(len(listmaxOK)):
    signal.iloc[listmaxOK[i],3]= 1 


print('\n'*2)
print('RESULTADO ESTRATEGIA')

inv=int(10000)
patri = pd.DataFrame(0,index=np.arange(len(listcom)),columns=['Acciones','Inversión','Valor último'])
for j in range (len(listcom)):
    patri.iloc[j,1]= int(inv)
    patri.iloc[j,0]= int(inv//signal.iloc[listcom[j],0])
    patri.iloc[j,2]= round(patri.iloc[j,0]*signal.iloc[(len(signal)-1),2],2)
    j+=1

print(patri)
print('\nInv. total:', patri['Inversión'].sum(), '\nValor actual:', patri ['Valor último'].sum())
print('Ganancia / Pérdida:' ,round((patri['Valor último'].sum()-patri['Inversión'].sum()),2) ,'\nRendimiento:', round(((patri['Valor último'].sum()/patri['Inversión'].sum())-1)*100,2), '%')

fig = plt.figure(figsize=(20,10))
fig.suptitle('Evolución de la cotización con estrategia "Buy the Dip"\n' + str(100-porcen) +' %' + " de caída\n\n" + ticker)
ax1 = fig.add_subplot( 111, ylabel='Cotización', xlabel="Fechas")

ax1.plot(df[signal['maximos'] == 1],'v', markersize=12, color='r')
ax1.plot(df[signal['signalbuy'] == 1],'^', markersize=12, color='g')

ax1.plot(df, color='k', label=ticker, lw=2)
ax1.legend()
ax1.grid()

plt.yscale('log')
plt.show()

1739 visitas
5    Login to like
Categorías:
 Estrategias   Estadísticas   Random   Gestión pasiva   Análisis técnico   Modelos   CEO   Mapas mentales   Liberalismo   Python   Growth   Niusleta   Ahorro   Recursos humanos   Inmobiliario   Fiscalidad   Value investing   Dividendos   Contabilidad   Marketing   Riesgo   IF   Cursos   Opciones   Bolsa