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()