Backtest automatizado

Gerard Sánchez - 24 de Enero de 2022 a las 11:13 - Estrategias




Se conoce como Backtesting al proceso o metodología retrospectiva por la cual comprobaremos una serie de hipótesis o suposiciones en el mercado. Simulamos condiciones en el pasado a fin de proyectar a futuro unos posibles resultados que hayan sido contrastados. Nos permite validar una estrategia concreta, fabricar o modificar una en base a unas conclusiones.

Sin embargo esta metodología no está exenta de problemática y dificultades: Tamaño y calidad de la muestra, comisiones, gestión de capital, liquidez, sesgo de supervivencia, overfitting...

En este artículo vamos a detallar una posible estructura de Backtest automatizada en Python para un indicador como el de las medias móviles para todas las compañías del SP500.

Vamos con el código:

import yahoo_fin.stock_info as si
import pandas as pd
import numpy as np
import yfinance as yf

tickers = si.tickers_sp500()
resumen=pd.DataFrame(columns=['Ticker','Mean 25',' %', 'Mean 50',' %','Mean 100', ' %','Mean 200',' %' ])

Creamos una variable tickers con los activos del sp500 y un DataFrame con las columnas especificadas, mediremos la % de cada condición para cuatro medias: 25,50,100 y 200.

capi=float(100000)
cf=0.5
cv=0.0003  # 0.3 por mil
ct=float(0) # Total comisiones por ticker y estrategia  (cf+cv)
tit=int()
resultado=float()

Declaramos una serie de variables que serán necesarias para el Backtest: Un capital, unas comisiones fijas y variables, una cantidad de stocks y un resultado con la rentabilidad.

for i in range(len(tickers)):
    df =yf.download(tickers[i],'2021-01-01', progress=False)['Close']
    resumen.loc[i,'Ticker']= tickers[i]
    """ Calculamos distintas medias """
    mean25 = df.rolling(25).mean()
    mean50 = df.rolling(50).mean()
    mean100 = df.rolling(100).mean()
    mean200 = df.rolling(200).mean()
    x=1 # controlamos columna importe y % para cada estrategia a modo de contador
    p=0

Empezamos definiendo un bucle for que englobará todo el programa, para el rango de tickers, en el que descargamos los precios de cierre de cada activo en cada iteración, calculando sus medias móviles e introduciendo cada ticker en el DataFrame de resumen. 

Creamos variables x,p a modo de contador. En x controlaremos la columna del importe y el % de cada estrategia, y en p el control de la estrategia usando las mismas variables (de forma iterativa se irán comprobando las condiciones media > precio de cierre)

    while p<4:
        cash=capi
        if p==0:
            data = pd.DataFrame(np.where((mean25 < 0.98*df),1,0), index=df.index)
        elif p==1:
            data = pd.DataFrame(np.where((mean50 < 0.97*df),1,0), index=df.index)
        elif p==2:
            data = pd.DataFrame(np.where((mean100 < 0.96* df),1,0), index=df.index)
        else:
            data = pd.DataFrame(np.where((mean200 < 0.95*df),1,0), index=df.index)

Utilizamos numpy para determinar con 1's o 0's si los precios de cierre están por encima o por debajo (con un poco de margen: 0.9x). En cada iteración p irá incrementando (lo veremos más adelante).

        signal = data.diff()
        signal['Titulos']=0
        signal['Coste']=0
        signal['Venta']=0
        signal['Comision']=0

Con diff() tenemos los momentos de cruce exactos (1,-1) y creamos cuatro columnas en signal para realizar nuestro backtest.

        for z in range(len(df)):
            """ Dada la condición de compra calculamos qué cantidad podemos comprar"""
            if signal.iloc[z,0]>0: # señal de compra
                signal.iloc[z,1]= cash//df[z] # numero de acciones
                tit+=signal.iloc[z,1] # acumulamos títulos pq no sabemos cuándo se venderán y vamos a borrar registros a cero
                signal.iloc[z,2]=round(signal.iloc[z,1]*df[z],2) # coste de la compra
                signal.iloc[z,4 ]= round(cf+signal.iloc[z,2]*cv,2)
                ct=ct+cf+signal.iloc[z,2]*cv
                cash+=-signal.iloc[z,2]-signal.iloc[z,4] # mantenemos acumulado cash neto

Trabajamos con otro bucle for dentro del anterior while para, partiendo de señales de compra, delimitar el número de acciones compradas (// para evitar fraccionamientos) ponderadas por las comisiones, mantener el efectivo acumulado.

            if signal.iloc[z,0]<0 : # señal de venta
                signal.iloc[z,3]=round(tit *df[z],2) # importe venta
                signal.iloc[z,4]= round(cf+signal.iloc[z,3]*cv,2)
                ct=ct+cf+signal.iloc[z,2]*cv
                signal.iloc[z,1]=-tit # cuenta títulos
                tit=0
                cash+=signal.iloc[z,3]-signal.iloc[z,4]

A continuación trabajamos con señales de venta (<0): Calculamos el importe multiplicando el stock disponible con su correspondiente precio de cierre, calculamos las comisiones totales ct, el incremento del cash y reescribimos la variable de los títulos a 0.

        signal=signal.fillna(0)
        signal=signal.loc[(signal!=0).any(axis=1)]
        """ Pantallazo de la estrategia"""
        print('Ticker: ',tickers[i], 'Estgia: ',p)
        print(signal)

Borramos los registros sin señal de compra o de venta (de ahí haber guardado tit y cash fuera del DataFrame)

        ct=0
        resultado=round(float(tit*df[-1:] + cash),2) 
        porcentaje=round((resultado/capi-1)*100,2)
        print('Resultado: ',resultado, ' ', porcentaje,' %' )

Reiniciamos la variable de comisiones y calculamos el resultado total (suma de efectivo más acciones a precio de mercado y calculamos el % de rentabilidad para cada iteración (con las cuatro medias móviles distintas por separado).

        """Guardamos en resumen para lista objetivo , manipulamos x e y para lograr encolumnarlos correctamente"""
        resumen.iloc[i,(x)]=resultado
        resumen.iloc[i,(x+1)]=porcentaje
        tit=0
        x+=2
        p+=1

Encolumnamos resultado y porcentaje con iloc[i,(x)] e iloc[i,(x+1)] haciendo referencia al primer bucle for.

    print(resumen) # Pantallazo del avance título a título.

Finalmente obtenemos un DataFrame 505x9 con los resultados de las cuatro estrategias para todo el SP500.

Aquí os dejo el código entero:

import yahoo_fin.stock_info as si
import pandas as pd
import numpy as np
import yfinance as yf

tickers = si.tickers_sp500()
resumen=pd.DataFrame(columns=['Ticker','Mean 25',' %', 'Mean 50',' %','Mean 100', ' %','Mean 200',' %' ])

capi=float(100000)
cf=0.5
cv=0.0003  #0.3 por mil
ct=float(0) # Total comisiones por ticker y estrategia  (cf+cv)
tit=int()
resultado=float()

for i in range(len(tickers)):
    df =yf.download(tickers[i],'2021-01-01', progress=False)['Close']
    resumen.loc[i,'Ticker']= tickers[i]
    """ Calculamos distintas media """
    mean25 = df.rolling(25).mean()
    mean50 = df.rolling(50).mean()
    mean100 = df.rolling(100).mean()
    mean200 = df.rolling(200).mean()
    x=1 # controlamos columna importe y % a modo de contador
    p=0
    while p<4:
        cash=capi
        if p==0:
            data = pd.DataFrame(np.where((mean25 < 0.98*df),1,0), index=df.index)
        elif p==1:
            data = pd.DataFrame(np.where((mean50 < 0.97*df),1,0), index=df.index)
        elif p==2:
            data = pd.DataFrame(np.where((mean100 < 0.96* df),1,0), index=df.index)
        else:
            data = pd.DataFrame(np.where((mean200 < 0.95*df),1,0), index=df.index)

        """ Usamos un solo objeto para las 4 estrategias, se controla mediante el valor de p"""
        signal = data.diff()
        signal['Titulos']=0
        signal['Coste']=0
        signal['Venta']=0
        signal['Comision']=0

        for z in range(len(df)):
            """ Dada la condición de compra calculamos qué cantidad podemos comprar"""
            if signal.iloc[z,0]>0: # señal de compra
                signal.iloc[z,1]= cash//df[z] # numero de acciones
                tit+=signal.iloc[z,1] # acumulamos títulospq no sabemos cuando se venderán y vamos a borrar registros a cero
                signal.iloc[z,2]=round(signal.iloc[z,1]*df[z],2) # coste de la compra
                signal.iloc[z,4 ]= round(cf+signal.iloc[z,2]*cv,2)
                ct=ct+cf+signal.iloc[z,2]*cv
                cash+=-signal.iloc[z,2]-signal.iloc[z,4] # mantenemos acumulado cash neto
            
            if signal.iloc[z,0]<0 : # señal de venta
                signal.iloc[z,3]= round(tit *df[z],2) # importe venta
                signal.iloc[z,4]= round(cf+signal.iloc[z,3]*cv,2)
                ct=ct+cf+signal.iloc[z,2]*cv
                signal.iloc[z,1]=-tit # cuenta títulos
                tit=0
                cash+=signal.iloc[z,3]-signal.iloc[z,4]

        """ Borramos registros sin señal de compra o venta de ahí haber guardado titulos y cash fuera del DataFrame"""    
        signal=signal.fillna(0)
        signal=signal.loc[(signal!=0).any(axis=1)]
        """ Pantallazo de la estrategia, se podrá anular."""
        print('Ticker:',tickers[i],'Estrategia:',p)
        print(signal)
        ct=0
        resultado=round(float(tit*df[-1:] + cash),2) 
        porcentaje=round((resultado/capi-1)*100,2)
        print('Resultado: ',resultado, ' ', porcentaje,' %' )
        """Guardamos en resumen para lista objetivo , manipulamos x e y para lograr encolumnarlos correctamente"""
        resumen.iloc[i,(x)]=resultado
        resumen.iloc[i,(x+1)]=porcentaje
        tit=0
        x+=2
        p+=1
    print(resumen) # Pantallazo del avance título a título.

547 visitas
3    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