Trading Estacional III. Extrayendo Estacionalidades

En esta tercera entrega, ¡llega el momento que todos estabáis esperando! Pasamos a desarrollar toda una batería de código en Python que nos permitirán analizar y extraer fácilmente patrones estacionales de cualquier serie financiera. Dicho esto, ¡comenzamos!

Extrayendo Patrones Estacionales con el Drawdown

Una primera aproximación que podemos adoptar para buscar patrones estacionales es analizar los drawdowns que se producen en el precio. Para ello, podemos crear una tabla de drawdowns mensuales en forma de mapa de calor utilizando el siguiente código:

# Tabla de Drawdowns mensuales
tabladdm = pd.pivot_table(df, values="drawdown", index="Month", columns="Year")
tabladdm.T
pdd=sns.heatmap(tabladdm.T.iloc[0:22,0:12], annot=True, annot_kws={"size": 14})

El resultado obtenido es el siguiente:

01-Tabla Drawdowns

También podemos relativizar el resultado comparando el drawdown promedio mensual con el drawdown más alto del mes:

# DD promedio mensual vs DD más alto:
tabladdm_mean = df.groupby("Month").drawdown.mean()
tabladdm_mean = pd.DataFrame(tabladdm_mean)
tabladdm_mean.columns = ["drawdown_mean"]
tabladdm_min=df.groupby("Month").drawdown.min()
tabladdm_min = pd.DataFrame(tabladdm_min)
tabladdm_min.columns = ["drawdown highwest"]
tabladdm_resumen = pd.concat([tabladdm_mean, tabladdm_min], axis=1)
tabladdm_resumen.T
pddm=sns.heatmap(tabladdm_resumen.T.iloc[0:4,0:12], annot=True, annot_kws={"size": 14})
02-Drawdown Promedio vs Máximo

No obstante, podemos hilar más fino, elaborando un mapa de calor que nos permita analizar el drawdown combinando dos dimensiones: mes del año y día del calendario.

# Tabla de Drawdowns por día del calendario.
tabladdd=pd.pivot_table(df,values="drawdown",index="Day_Calendary", columns="Month", fill_value=0, aggfunc=np.mean, margins=True)
tabladdd.columns = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre", "Prom."]
tabladdd = tabladdd.apply(lambda x: round(x,3))
p2=sns.heatmap(tabladdd.T.iloc[0:13,0:32], annot=True, annot_kws={"size": 12})
tabladdd_mean = df.groupby("Day_Calendary").drawdown.mean()
03-Drawdowns por Dia Calendario

Extrayendo Patrones Estacionales con los Rendimientos

Hasta este momento hemos visto el tema de la estacionalidad desde el punto de vista del drawdown, esto es, de la mayor pérdida producida. Pero también podemos analizarla desde el punto de vista de los rendimientos promedio, usando para ello el siguiente código:

# Tabla de rendimientos promedio mensual.
tablam_acum = pd.pivot_table(df, values="roc1", index="Month", columns="Year", aggfunc=np.sum, margins=True)
tablam = pd.pivot_table(df, values="roc1", index="Month", columns="Year", fill_value=0)
tablam = tablam.apply(lambda x: round(x,3))
tablam.T
p1=sns.heatmap(tablam.T.iloc[0:22,0:12], annot=True, annot_kws={"size": 14})
04-Rendimientos Promedio Mensual

O incluso podemos sacar una tabla que nos resuma los rendimientos promedio por mes y año:

# Tabla Resumen % valores positivos y negativos por mes.
algo=tablam.T.iloc[0:22,0:12]
algo1=pd.DataFrame(algo[algo>0.05].count())
algo2=pd.DataFrame(algo.count())
tablam_pos=pd.DataFrame(algo1/algo2)*100
tablam_resumen = pd.concat([tablam, tablam_pos], axis=1)
tablam_resumen=tablam_resumen.rename(columns={0: "%ValuesPos"})
05-Valores positivos y negativos por mes

Y ya puestos, ¿por qué no sacar una tabla que nos resuma por cada mes del año el rendimiento promedio, el rendimiento mínimo, la desviación típica de los rendimientos y el drawdown promedio?

# Tabla resumen general mensual, promedio retorno-mínimo retorno-stdev-DD
tablam_mean = df.groupby("Month").roc1.mean()*100
tablam_mean = pd.DataFrame(tablam_mean)
tablam_mean.columns = ["mean_roc1"]
tablam_min = df.groupby("Month").roc1.min() #tablam_min = tablam.apply(lambda x: min(x))
tablam_min = pd.DataFrame(tablam_min)
tablam_min.columns = ["min_roc1"]
tablam_std = df.groupby("Month").roc1.std() 
tablam_std = pd.DataFrame(tablam_std)
tablam_std.columns = ["std_roc1"]
tablam_resumen = pd.concat([tablam_mean, tablam_min, tablam_std, tabladdm_mean], axis=1)
tablam_resumen = tablam_resumen.apply(lambda x: round(x,3))
tablam_resumen.T
prm=sns.heatmap(tablam_resumen.T.iloc[0:4,0:12], annot=True, annot_kws={"size": 14})
06-Tabla Resumen Meses

Con toda esta información ya podríamos extraer conclusiones interesantes y útiles para construir estrategias basadas en estacionalidad. Por ejemplo, podemos ver que marzo, abril, julio y noviembre suelen ser meses alcistas en el Vanguard Consumer Staples ETF, aunque marzo y abril tienden a ser volátiles dados sus importantes drawdowns. Asimismo llama la atención como marzo, mayo y octubre suelen ser meses con fuertes rachas bajistas en el ETF.

Veamos ahora un filtrado de rendimientos combinando dos dimensiones: mes del año y día del calendario.

# Tabla de rendimientos diarios
tablad_acum=pd.pivot_table(df,values="roc1",index="Day_Calendary", columns="Month", aggfunc=np.sum,margins=True)
tablad = pd.pivot_table(df, values="roc1", index="Day_Calendary", columns="Month", fill_value=0)
tablad.columns = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"]
tablad = tablad.apply(lambda x: round(x,2))
p2=sns.heatmap(tablad.T.iloc[0:12,0:31], annot=True, annot_kws={"size": 10})
07-Rendimientos por Dias

También podemos sacar una tabla con el rendimiento promedio, el rendimiento mínimo, la desviación típica de los rendimientos y el drawdown promedio por día del calendario:

# Tabla resumen % valores positivos y negativos por día calendario.
algo=tablad.T.iloc[0:22,0:31]
algo1=pd.DataFrame(algo[algo>0.05].count())
algo2=pd.DataFrame(algo.count())
tablad_pos=pd.DataFrame(algo1/algo2)*100
tablad_resumen = pd.concat([tablad, tablad_pos], axis=1)
tablad_resumen=tablad_resumen.rename(columns={0: "%ValuesPos"})
08-Valores positivos y negativos por dia

Hasta el momento hemos trabajado con días del calendario, pero ¿habrá algo interesante en el nº de la semana de cada mes? Analicémoslo:

# Tabla de rendimientos semanales
tablasem_acum = pd.pivot_table(df2, values="roc1", index="W_of_M", columns="Month", aggfunc=np.sum,margins=True)
tablasem = pd.pivot_table(df2, values="roc1", index="W_of_M", columns="Month", fill_value=0)
tablasem.columns = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"]
tablasem = tablasem.apply(lambda x: round(x,2))
psem = sns.heatmap(tablasem.T.iloc[0:12,0:5], annot=True, annot_kws={"size": 14})
09-Rendimientos por Semanas

Resultan especialmente llamativas por su rendimiento promedio, tanto la subida en la última semana de marzo y de noviembre, como las bajadas en la primera semana de febrero, en las dos últimas semanas de junio, y en la segunda semana de octubre.

Combinamos ahora nº de la semana con meses para examinar el rendimiento promedio:

# Tabla resumen % valores positivos y negativos por número de la semana del mes.
algosem = tablasem.T.iloc[0:22,0:5]
algosem1 = pd.DataFrame(algosem[algosem>0.05].count())
algosem2 = pd.DataFrame(algosem.count())
tablasem_pos = pd.DataFrame(algosem1/algosem2)*100
tablasem_resumen = pd.concat([tablasem, tablasem_pos], axis=1)
tablasem_resumen = tablasem_resumen.rename(columns={0: "%ValuesPos"})
10-Valores positivos y negativos por semana del mes

Por último, para obtener más información generamos una tabla con el rendimiento promedio, el rendimiento mínimo, la desviación típica de los rendimientos y el drawdown promedio por número de la semana:

# Tabla resumen general semanal, promedio retorno-mínimo retorno-stdev-DD
tablasem_mean = df2.groupby("W_of_M").roc1.mean()*100
tablasem_mean = pd.DataFrame(tablasem_mean)
tablasem_mean.columns = ["mean_roc1"]
tablasem_min = df2.groupby("W_of_M").roc1.min() #tablam_min = tablam.apply(lambda x: min(x))
tablasem_min = pd.DataFrame(tablasem_min)
tablasem_min.columns = ["min_roc1"]
tablasem_std = df2.groupby("W_of_M").roc1.std() 
tablasem_std = pd.DataFrame(tablasem_std)
tablasem_std.columns = ["std_roc1"]
tablasem_resumen=pd.concat([tablasem_mean, tablasem_min, tablasem_std, tabladdd_mean], axis=1)
tablasem_resumen = tablasem_resumen.apply(lambda x: round(x,2))
tablasem_resumen.T
psemr = sns.heatmap(tablasem_resumen.T.iloc[0:4,0:5], annot=True, annot_kws={"size": 14})
11-Tabla Resumen Semanas

Podemos también repetir el análisis anterior pero en lugar de trabajar con el número de la semana dentro de cada mes, utilizamos el número de semana en el año. A continuación podéis ver el código, similar al de los casos anteriores, así como los resultados obtenidos:

# Tabla de rendimientos por número de la semana del año.
tablasem2_acum = pd.pivot_table(df, values="roc1", index="Week", columns="Year", aggfunc=np.sum,margins=True)
tablasem2=pd.pivot_table(df2,values="roc1",index="Week",columns="Month", fill_value=0)
tablasem2.columns = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"]
tablasem2 = tablasem2.apply(lambda x: round(x,2))

# Tabla resumen % valores positivos y negativos por número de la semana del año.
algosem2 = tablasem2.T.iloc[0:22,0:52]
algosem22 = pd.DataFrame(algosem2[algosem2>0.05].count())
algosem22 = pd.DataFrame(algosem.count())
tablasem2_pos = pd.DataFrame(algosem21/algosem22)*100
tablasem2_resumen = pd.concat([tablasem2, tablasem2_pos], axis=1)
tablasem2_resumen = tablasem2_resumen.rename(columns={0: "%ValuesPos"})

# Tabla resumen general por número de la semana del año, promedio retorno-mínimo retorno-stdev-DD.
tablasem2_mean = df2.groupby("Week").roc1.mean()*100
tablasem2_mean = pd.DataFrame(tablasem2_mean)
tablasem2_mean.columns = ["mean_roc1"]
tablasem2_min = df2.groupby("Week").roc1.min() #tablam_min = tablam.apply(lambda x: min(x))
tablasem2_min = pd.DataFrame(tablasem2_min)
tablasem2_min.columns = ["min_roc1"]
tablasem2_std = df2.groupby("Week").roc1.std() 
tablasem2_std = pd.DataFrame(tablasem2_std)
tablasem2_std.columns = ["std_roc1"]
tablasem2_resumen = pd.concat([tablasem2_mean, tablasem2_min, tablasem2_std, tabladdd_mean], axis=1)
tablasem2_resumen = tablasem2_resumen.apply(lambda x: round(x,2))
tablasem2_resumen.T
12-Resumen Semana del Año

Otra cosa que podemos hacer y que puede resultarnos muy interesante es filtrar valores positivos o negativos superiores o inferiores a una determinada cuantía. Por ejemplo, podemos sacar los rendimientos promedio superiores al +10% o inferiores al -10%, clasificándolos por mes y día del calendario:

# Filtrado de retornos positivos.
tablad_filter_pos = tablad[tablad[0:31] > 0.1] #Retornos positivos de todo el dataframe "tablad_roc1"
count = tablad_filter_pos.count()
print(count)

# Filtrado de retornos negativos
tablad_filter_neg = tablad[tablad[0:31] < -0.1] #Retornos negativos de todo el dataframe "tablad_roc1"
count = tablad_filter_neg.count()
print(count)

Los resultados que se obtienen en forma de tabla son los siguientes:

13-Filtrado Rendimientos Positivos Dia Calendario vs Mes
14-Filtrado Rendimientos Negativos Dia Calendario vs Mes

Las tablas anteriores quedan más estéticas si quitamos los NaN que aparecen:

# Filtramos los retornos positivos eliminando además los NAs
tablad_filter_pos_enero = tablad_filter_pos["Enero"].dropna()
tablad_filter_pos_febrero = tablad_filter_pos["Febrero"].dropna()
tablad_filter_pos_marzo = tablad_filter_pos["Marzo"].dropna()
tablad_filter_pos_abril = tablad_filter_pos["Abril"].dropna() 
tablad_filter_pos_mayo = tablad_filter_pos["Mayo"].dropna() 
tablad_filter_pos_junio = tablad_filter_pos["Junio"].dropna() 
tablad_filter_pos_julio = tablad_filter_pos["Julio"].dropna() 
tablad_filter_pos_agosto = tablad_filter_pos["Agosto"].dropna() 
tablad_filter_pos_septiembre = tablad_filter_pos["Septiembre"].dropna() 
tablad_filter_pos_octubre = tablad_filter_pos["Octubre"].dropna() 
tablad_filter_pos_noviembre = tablad_filter_pos["Noviembre"].dropna() 
tablad_filter_pos_diciembre = tablad_filter_pos["Diciembre"].dropna() 

# Filtramos los retornos negativos eliminando además los NAs
tablad_filter_neg_enero = tablad_filter_neg["Enero"].dropna()
tablad_filter_neg_febrero = tablad_filter_neg["Febrero"].dropna()
tablad_filter_neg_marzo = tablad_filter_neg["Marzo"].dropna()
tablad_filter_neg_abril = tablad_filter_neg["Abril"].dropna() 
tablad_filter_neg_mayo = tablad_filter_neg["Mayo"].dropna() 
tablad_filter_neg_junio = tablad_filter_neg["Junio"].dropna() 
tablad_filter_neg_julio = tablad_filter_neg["Julio"].dropna() 
tablad_filter_neg_agosto = tablad_filter_neg["Agosto"].dropna() 
tablad_filter_neg_septiembre = tablad_filter_neg["Septiembre"].dropna() 
tablad_filter_neg_octubre = tablad_filter_neg["Octubre"].dropna() 
tablad_filter_neg_noviembre = tablad_filter_neg["Noviembre"].dropna() 
tablad_filter_neg_diciembre = tablad_filter_neg["Diciembre"].dropna()

El resultado que obtenemos es el siguiente:

16-Resumen Positivos y Negativos

Por supuesto, nada nos impide graficar los rendimientos promedio de cada mes, la desviación estándar promedio diaria y mostrar un mapa de calor con los rendimientos promedio diarios de cada día del calendario para poder detectar visualmente pautas en el comportamiento estacional del ETF:

# Retornos medios de cada mes, la desviación estándar promedio diario y mapa de calor de los retornos promedio por día del mes.

fig,(ax1, ax2, ax3) = plt.subplots(3, 1, sharex=False, figsize=(24, 14), gridspec_kw = {"height_ratios":[4, 2, 1]})
fig.suptitle(caso_heatmap_principal)      
sns.heatmap(tablad, annot = True, annot_kws={"size": 11}, vmin=0, vmax=0.5, cmap = "Greens", cbar = False, ax=ax1)
ax1.axes.xaxis.set_label_text("")
ax2.set_title("% Retornos medios por mes")
tablam_mean.plot.bar(ax=ax2, color={"blue"})
ax2.axes.xaxis.set_label_text("")
ax3.set_title("Promedio Desviación Estándar diaria")
tablad_std.plot.bar(ax=ax3,color={"orange"})
plt.show()
15-Rendimientos Medios por Dia del Mes

Otra representación visual que nos puede venir muy bien es crear gráficos de línea para cada mes mostrando el rendimiento promedio de cada uno de los días que lo componen:

# Ploteamos retornos promedio diario de cada mes
ax= plt.axes([0.8, 0.8, 0.8, 0.8])
ax.set_xlim(1,31)
ax.xaxis.set_major_locator(plt.MultipleLocator(1.0))
ax.xaxis.set_minor_locator(plt.MultipleLocator(1.0))
ax.yaxis.set_major_locator(plt.MultipleLocator(0.1))
ax.yaxis.set_minor_locator(plt.MultipleLocator(1.0))
tablad.cumsum().plot(figsize=(14,8), title = caso_roc_general, legend ="Meses")
plt.xlabel("Días del Calendario")
plt.ylabel("Puntos Porcentual de Rendimientos")
17-Resumen ROCs Diarios por Mes

Por supuesto, podemos extraer cada curva de forma individual con el siguiente código (basta con cambiar el nombre del mes en la antepenúltima fila):


ax = plt.axes([0.8, 0.8, 0.8, 0.8]);
ax.set_xlim(1,31);
ax.xaxis.set_major_locator(plt.MultipleLocator(1.0))
ax.xaxis.set_minor_locator(plt.MultipleLocator(1.0))
ax.yaxis.set_major_locator(plt.MultipleLocator(0.1))
ax.yaxis.set_minor_locator(plt.MultipleLocator(1.0))
tablad["Enero"].cumsum().plot(figsize=(14,8), title = caso_roc_individual, color = "red", legend ="Enero")
plt.xlabel("Días del Calendario")
plt.ylabel("Puntos Porcentual de Rendimientos")

El resultado de las curvas individuales para cada mes sería el siguiente:

18-ROCs diarios por Mes

Selección de Días en Base al Ratio de Sharpe

Para terminar, podemos dar mayor validez a los resultados estableciendo un criterio para seleccionar los días cuya pauta es realmente significativa. Por ejemplo, podemos dividir los meses en tres decenas y utilizar el ratio de Sharpe para decidir en qué casos el rendimiento promedio es realmente significativo:

# Calculamos Ratio de Sharpe.
# Función para calcular el ratio de Sharpe
def annualised_sharpe(returns, N=252):
    '''
    Calcula el ratio Sharpe de una serie
    '''
    if returns.sum()==0: return 0
    return np.sqrt(N) * (returns.mean() / returns.std())

###########################################################

#Sharpe por meses
meses=[]
res=np.zeros(12)
for n in range(1,13):
    sharpecat=np.where((df.Month==n),1,0)
    sharpe=annualised_sharpe(sharpecat*df.roc1)
    print (" El sharpe del mes ", n, " es del orden de ", sharpe)
    res[n-1]=sharpe
    print 
meses.append(res)
mesenas=pd.DataFrame(meses,columns=["1","2","3","4","5","6","7","8","9","10","11","12"])
mesenas.T
mesenas1=pd.DataFrame(mesenas.T)
#mesenas = pd.DataFrame.transpose(mesenas)
#mesenas.columns = ["ratio sharpe"]
ps=sns.heatmap(mesenas, annot=True, annot_kws={"size": 14})

#Criterio de selección de días
#Vamos a seleccionar los meses por Decenas y ver su comportamiento.
# Primera Decena
meses=[ ]
res=np.zeros(12)
for n in range(1,13):
    	sharpecat=np.where((df.Month==n)&(df.Day_Calendary>0)&(df.Day_Calendary<=10),1,0)
    	sharpe=annualised_sharpe(sharpecat*df.roc1)
    	print ("El sharpe del mes", n, "Es del orden de", sharpe)
    	res[n-1]=sharpe
   	 print 
meses.append(res)

# Segunda Decena
res=np.zeros(12)
for n in range(1,13):
    	sharpecat=np.where((df.Month==n)&(df.Day_Calendary>10)&(df.Day_Calendary<=20),1,0)
    	sharpe=annualised_sharpe(sharpecat*df.roc1)
    	print ("El sharpe del mes", n, "Es del orden de", sharpe)
    	res[n-1]=sharpe
    	print 
meses.append(res)

#  Tercera Decena
res=np.zeros(12)
for n in range(1,13):
    sharpecat=np.where((df.Month==n)&(df.Day_Calendary>20)&(df.Day_Calendary<32),1,0)
    sharpe=annualised_sharpe(sharpecat*df.roc1)
    print ("El sharpe del mes", n, "Es del orden de", sharpe)
    res[n-1]=sharpe
    print 
meses.append(res)

#Resultados por Decenas
decenas=pd.DataFrame(meses,columns=['1','2','3','4','5','6','7','8','9','10','11','12'])
p1=sns.heatmap(decenas, annot=True, annot_kws={"size": 14})

20-Sharpe por Decenas

Del mapa de calor anterior podemos destacar algunos resultados: por ejemplo, la tercera decena de febrero es claramente bajista, mientras que la segunda decena de febrero o la primera decena de julio son fuertemente alcistas.

Construyendo la Estrategia

Tras finalizar esta serie de artículos, ahora querido lector dispone de todas las herramientas para caracterizar y construir estrategias de trading basadas en pautas estacionales. Por ejemplo, es posible crear un setup de swing trading que cumpla los siguientes requisitos:

t-test > 0,05
Mediana >= 0,05% Roc
Media >= 0,01% Roc
Sharpe >= 0,55
Roc_mean >= 0,5% and
Roc_mean > Roc_min and
Diff Roc >= 1,05 “Roc_mean/Roc_min”
Desvest <= Desvest. prom. Diario del activo

Por supuesto, les animo a que jueguen con el código proporcionado y que compartan sus resultados, tanto en el Foro de Python de X-Trader como en el canal de Telegram de VTAlgo.

COMPARTIR EN: