Captura de pantalla 2022 09 28 a las 7.16.50

Co-fundador de Pynacle.io. Autor y creador del blog Gsnchez.com y del canal de youtube “GSNCHEZ”, es profesor en diversos centros y escuelas de negocios.
Gerard Sánchez / Pynacle.io

 

  • El trading de pares es una estrategia en la que operamos 2 activos que durante un periodo tienen un comportamiento similar por estar correlacionados. Buscamos arbitrar diferencias abriendo posiciones largas o compradoras sobre un activo y vendedoras o cortas en el otro, esperando una reversión a la media. ¿Cómo podemos crear una estrategia en base a esto?
  • Artículo publicado en Hispatrading 51.

Lo deseable es que el comportamiento del activo sobre el que estamos largos o comprados tenga un movimiento relativamente más alcista que el otro par y al contrario con el par en el que estamos vendidos.

En este artículo vamos a tratar de automatizar señales de compra/venta en una estrategia de pares para el universo de compañías del SP500 basándonos en el coeficiente de correlación y normalizando series de retornos con el ratio zscore.

En primer lugar vamos a necesitar una serie de librerías que nos ayudarán con el proceso de descarga y modelización de los datos, son las siguientes:

import yfinance as yf

import numpy as np

import pandas as pd

import matplotlib.pyplot as plt

import yahoo_fin.stock_info as si

Procedemos a descargar los precios de cierre para todas las compañías del SP500 para un periodo, calculando los retornos o rentabilidades de cada uno de ellos y calculando el coeficiente de correlación sobre los mismos

Esto nos generará una matriz en la que se determina el coeficiente de correlación que comparte cada activo con el resto de compañías del índice para un periodo.

# DESCARGA DEL UNIVERSO DE ACTIVOS + CÁLCULO CORRELACIÓN

tickers = si.tickers_sp500()

data = yf.download(tickers, start=’2018-01-04′, progress=False)[‘Close’]

returns = data.pct_change()[1:].dropna(axis=1)

corr = returns.corr(method=’pearson’)

pastedGraphic.png
Figura 1: Ejemplo de alta correlación entre pares, cuanto más cercana a 1, más similar será el movimiento entre los dos.

Deshacemos la matriz y ordenamos todos los pares generados en orden descendiente por correlación.

upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(np.bool8)).unstack().dropna().sort_values(ascending=False)

pastedGraphic_1.png
Figura 2: Pares generados por correlación descendente de compañías del SP500

En total se nos han generado 121771 parejas posibles, de las cuales vamos a escoger únicamente las que tengan una correlación mayor a 0.8, suficiente como para saber que estas acciones han tenido un comportamiento similar en el pasado. 

En total 422 pares, 844 activos.

A continuación creamos un nuevo dataframe llamado “df1” con los retornos para los 848 activos ordenados por correlación filtrada hasta un mínimo de 0.8.

# NOS QUEDAMOS CON LOS ACTIVOS >0.8 CORRELACIÓN

upper = upper[upper>0.8]

upper = list(upper.index)

tickercurados = list([x for ticker in upper for x in ticker])

df1 = returns.reindex(columns=tickercurados)

Calculamos los retornos acumulativos de cada activo partiendo de 1, manteniendo el orden de las parejas.

# CALCULO LOS RETORNOS ACUMULATIVOS

cr = (1+df1).cumprod()

Normalizamos la serie de retornos por pares (el primero menos el segundo, el tercero menos el cuarto…) y lo juntamos todo en un nuevo dataframe llamado “resultados”. 

Con esto podremos ver si hay diferencias entre los retornos de un activo con el otro de forma agregada.

# RESTO LOS RETORNOS ACUMULATIVOS (EL PRIMERO MENOS EL SEGUNDO)

resultados = pd.DataFrame()

for i in range(0,len(df1.columns),2):

    diferencia = cr.iloc[:,i] – cr.iloc[:,i+1]

    resultados = pd.concat([resultados,diferencia],axis=1)

Generamos el nuevo nombre de cada columna, que será la unión entre el nombre de los tickers de los pares.

# GENERO EL NUEVO NOMBRE TICKER-TICKER DE CADA COLUMNA CON LOS RETORNOS RESTADOS.

l = []

for i in range(0,len(tickercurados),2):

    tickers = «-«.join(tickercurados[i:i+2])

    l.append(tickers)

l = [x for x in l if x]

resultados.columns = l

pastedGraphic_2.png
Figura 3: Nuevo nombre de las columnas con las series de retornos restadas por pares.

Calculamos el ratio estadístico z-score para saber qué tan distantes son los resultados con respecto a sus medias basándonos en desviaciones estándar.

# NORMALIZAMOS LA SERIE (Z_Score): ((X-X.MEAN())/X.STD()

z_score=pd.DataFrame()

for i in range(len(resultados.columns)):

    serie = (resultados.iloc[:,i] – resultados.iloc[:,i].mean())/resultados.iloc[:,i].std()

    z_score = pd.concat([z_score,serie],axis=1)

Dibujamos las series normalizadas de los primeros 8 pares y los últimos 8 para tener una idea del comportamiento que han tenido, tanto para los activos con correlación más cercana a 1 como a 0.8, también dibujamos 2 bandas, una en 2 y otra en -2 para determinar posibles puntos de compra y/o de venta suponiendo una reversión a la media.

# DIBUJO DE LAS PRIMERAS 8 SERIES NORMALIZADAS (Z-SCORE)

t = «Series normalizadas de pares >0.8»

z_score.iloc[:,:8].plot(figsize=(20,8),title=t)

[plt.axhline(y=i, linestyle=’–‘, lw=2, color=’r’) for i in [-2,2]]

plt.show()

pastedGraphic_3.png
Figura 4: Series normalizadas (zscore) de los 8 primeros pares por correlación.
pastedGraphic_4.png
Figura 5: Series normalizadas (zscore) de los 8 últimos pares (los más cercanos a 0.8) por correlación.

A partir de aquí, decidiríamos qué tipo de operativa vamos a ejecutar de forma sistemática. 

Un ejemplo podría ser el de ponernos largos del par (comprar la primera acción y vender la segunda) cuando la normalización cae a -2 y al revés cuando la normalización sube a 2, cerrando la operación cuando la normalización toque -0.5 o 0.5 en el caso contrario.

Hay muchas posibilidades que se pueden backtestear.

Vamos a señalizar todos los cruces en que la normalización toca esos puntos en dos dataframes distintos: signalc y signalv.

# SEÑALIZAMOS CRUCES EXACTOS PARA COMPRA/VENTA

for i in range(len(z_score)):

    signalv = pd.DataFrame(np.where(z_score>2,1,0), index= z_score.index, columns= z_score.columns)

    signalc = pd.DataFrame(np.where(z_score<-2,1,0), index= z_score.index, columns= z_score.columns)

signalv = signalv.diff()

signalc = signalc.diff()

Normalizamos y reordenamos nuestros precios de cierre por columnas de activos aparejados por correlación, igualando el número de filas al dataframe de las señales, para poder graficar los activos con esas señales superpuestas.

data = data.reindex(columns=tickercurados)[1:]

data = data/data.iloc[0]

Ahora mostramos el 5º par a modo de ejemplo (RF-KEY), dibujando encima de los precios de cierre esas señales de compra/venta cuando la normalización de las series tocan 2 y -2.

# DIBUJAMOS UN EJEMPLO DE PARES CON COMPRAS/VENTAS PARA EL PERIODO:

fig = plt.figure(figsize=(20,10))

ax1 = fig.add_subplot(111,title=»Estrategia de Pares» ,ylabel=’Pares’, xlabel=»Fechas»)

ax1.plot(data.iloc[:,8][signalc.iloc[:,8] == -1],’v’, markersize=12, color=’g’)

ax1.plot(data.iloc[:,8][signalv.iloc[:,8] == -1],’^’, markersize=12, color=’r’)

ax1.plot(data.iloc[:,9][signalc.iloc[:,9] == -1],’v’, markersize=12, color=’g’)

ax1.plot(data.iloc[:,9][signalv.iloc[:,9] == -1],’^’, markersize=12, color=’r’)

ax1.plot(data.iloc[:,[8,9]], label=data.iloc[:,[8,9]].columns, lw=2)

ax1.legend()

ax1.grid()

plt.show()

pastedGraphic_5.png
Figura 6: Precios de cierre del 5º par por correlación con posibles señales de compra/venta superpuestas para el periodo.

Podemos realizar otro tipo de análisis de las series utilizando describe(), a continuación se muestra un ejemplo ordenando por valores mínimos de menor a mayor:

# DESCRIPCIÓN DEL DATAFRAME 

z_score.describe().T.head(15).sort_values(by=’min’, ascending=True).drop([‘count’],axis=1)

pastedGraphic_6.png
Figura 7: Análisis estadístico de las series, ordenado por la columna de mínimos, de menor a mayor.

Todo este ejemplo nos lleva a poder partir de la base para realizar la construcción de nuestra estrategia de pares, decidiendo qué señales de compra/venta utilizaremos para operar y con qué gestión de capital.

P.D.: Pongo todo el código para el que quiera utilizarlo directamente:

import yfinance as yf

import numpy as np

import pandas as pd

import matplotlib.pyplot as plt

import yahoo_fin.stock_info as si

# DESCARGA DEL UNIVERSO DE ACTIVOS + CÁLCULO CORRELACIÓN

tickers = si.tickers_sp500()

data = yf.download(tickers, start=’2018-01-04′, progress=False)[‘Close’]

returns = data.pct_change()[1:].dropna(axis=1)

corr = returns.corr(method=’pearson’)

# NOS QUEDAMOS CON LOS ACTIVOS >0.8 CORRELACIÓN

upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(np.bool8)).unstack().dropna().sort_values(ascending=False)

upper = upper[upper>0.8]

upper = list(upper.index)

tickercurados = list([x for ticker in upper for x in ticker])

tickercurados

len(tickercurados)

df1 = returns.reindex(columns=tickercurados)

df1.columns

# CALCULO LOS RETORNOS ACUMULATIVOS

cr = (1+df1).cumprod()

# RESTO LOS RETORNOS ACUMULATIVOS (EL PRIMERO MENOS EL SEGUNDO)

resultados = pd.DataFrame()

for i in range(0,len(df1.columns),2):

    diferencia = cr.iloc[:,i] – cr.iloc[:,i+1]

    resultados = pd.concat([resultados,diferencia],axis=1)

# GENERO EL NUEVO NOMBRE TICKER-TICKER DE CADA COLUMNA CON LOS RETORNOS RESTADOS.

l = []

for i in range(0,len(tickercurados),2):

    tickers = «-«.join(tickercurados[i:i+2])

    l.append(tickers)

resultados.columns = l

# NORMALIZAMOS LA SERIE: ((X-X.MEAN())/X.STD()

z_score=pd.DataFrame()

for i in range(len(resultados.columns)):

    serie = (resultados.iloc[:,i] – resultados.iloc[:,i].mean())/resultados.iloc[:,i].std()

    z_score = pd.concat([z_score,serie],axis=1)

# DIBUJO DE SERIES NORMALIZADAS (Z-SCORE)

t = «Series normalizadas de pares >0.8»

z_score.iloc[:,:8].plot(figsize=(20,8),title=t)

[plt.axhline(y=i, linestyle=’–‘, lw=2, color=’r’) for i in [-2,2]]

plt.show()

# SEÑALIZAMOS CRUCES EXACTOS PARA COMPRA/VENTA

for i in range(len(z_score)):

    signalv = pd.DataFrame(np.where(z_score>2,1,0), index= z_score.index, columns=z_score.columns)

    signalc = pd.DataFrame(np.where(z_score<-2,1,0), index= z_score.index, columns=z_score.columns)

signalv = signalv.diff()

signalc = signalc.diff()

data = data.reindex(columns=tickercurados)[1:]

data = data/data.iloc[0]

# DESCRIPCIÓN DEL DATAFRAME 

z_score.describe().T.head(15).sort_values(by=’min’, ascending=True).drop([‘count’],axis=1)

# DIBUJAMOS UN EJEMPLO DE PARES (EL 5º PAR) CON COMPRAS/VENTAS PARA EL PERIODO:

fig = plt.figure(figsize=(20,10))

ax1 = fig.add_subplot(111,title=»Estrategia de Pares» ,ylabel=’Pares’, xlabel=»Fechas»)

ax1.plot(data.iloc[:,8][signalc.iloc[:,8] == -1],’v’, markersize=12, color=’g’)

ax1.plot(data.iloc[:,8][signalv.iloc[:,8] == -1],’^’, markersize=12, color=’r’)

ax1.plot(data.iloc[:,9][signalc.iloc[:,9] == -1],’v’, markersize=12, color=’g’)

ax1.plot(data.iloc[:,9][signalv.iloc[:,9] == -1],’^’, markersize=12, color=’r’)

ax1.plot(data.iloc[:,[8,9]], label=data.iloc[:,[8,9]].columns, lw=2)

ax1.legend()

ax1.grid()

plt.show()