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