Optimización de Portafolios en Python: Sharpe Ratio y Mínima Varianza.

Nicolás Besser
11 min readApr 22, 2021

Una cartera o portafolio es una selección de activos. La optimización de portafolios consiste básicamente en lograr la mejor distribución de esos activos dentro del set de activos disponibles.

En este post voy a mostrarles cómo armar un portafolio que maximice los objetivos de cada inversionista, ya sea tener un portafolio de mínimo riesgo o maximizar nuestra retorno dado un cierto riesgo.

¿Qué es la Frontera Eficiente?

De acuerdo a la teoría de portafolios Markowitz se le denomina la frontera eficiente a aquella que dado un mismo nivel de volatilidad busca maximizar el retorno esperado.

Frontera Eficiente

Como se puede ver las combinaciones de pesos de los activos en mi portafolio que se encuentran en la línea roja se encuentran en lo que se denomina la frontera eficiente. Todos los portafolios que se encuentran dentro de la frontera, por el mismo nivel de riesgo pueden aumentar el retorno esperado si cambian los pesos del portafolio.

Además, en la imagen se puede ver además el Sharpe Ratio del portafolio. Este ratio nos indica el exceso de retorno que tiene un activo por sobre la tasa de libre de riesgo ajustado por la volatilidad del mismo. Es decir, si tengo dos activos y los comparo con la misma tasa libre de riesgo, el que tenga un mayor Sharpe Ratio; por el mismo nivel de riesgo, me entrega una mayor retorno.

Ahora bien, lo difícil acá es seleccionar los activos con los que voy a armar mi portafolio para obtener un buen Sharpe Ratio no la parte de programación.

¿Que es un buen Sharpe Ratio?

De acuerdo al famoso sitio web Investopedia en el que he basado todos mis estudios de finanzas (broma solo ha sido un complemento), describen como un Sharpe Ratio aceptable para un portafolio uno que entregue un valor sobre 1.0, sobre 2.0 es muy bueno, por último, sobre 3.0 es excelente.

Mucho Bla Bla Poca Acción

Vamos a Python. Lo primero es lo primero, importar las librerías pertinentes.

import pandas as pd
import numpy as np
from datetime import datetime
import matplotlib.pyplot as plt
import statistics
import seaborn as sns
#pip install pandas_datareader –––-> Para instalar el paquete pandas_datereader.
from pandas_datareader import data
from pulp import *

Ahora que tenemos los datos a trabajar, podemos elegir los activos que queremos incorporar a nuestro portafolio. A modo de ejemplo, voy a tomar las empresas tecnológicas de mayor capitalización bursátil de Estados Unidos. Estas en su mayoría se encuentran listadas en el NASDAQ: índice que incorpora en general acciones del rubro tecnológico. Por esta razón, utilizaremos a este índice como Benchmark o portafolio de mercado.

Obteniendo los datos

Primero definimos una lista con los nombres que los activos tienen donde se encuentran listados. El caso de Apple, este aparece listado en el NASDAQ bajo el nombre de AAPL. Por otro lado, nuestro índice Benchmark, el NASDAQ, tiene el nombre de ^IXIC.

activos = ["AAPL", "MSFT","AMZN","GOOGL","FB","TSLA","V","^IXIC"]

Ahora que se tiene la lista de los activos, debemos definir la fecha desde la que queremos obtener los datos. Para esto, se obtendrán los datos a 5 años plazo lo que parece un tiempo razonable.

fechaInicio = "2016-04-20"
hoy = datetime.today().strftime('%Y-%m-%d')

Con esta información estamos listos para obtener los datos pero antes se debe crear un Dataframe vacío para guardar la información. Esto se realiza de la siguiente forma:

df_precios = pd.DataFrame()

Luego, se utiliza la siguiente función que automáticamente nos entrega los datos almacenados en Yahoo Finance desde la fecha inicial que se definió hasta el día de hoy. Específicamente, nos entrega por cada acción la columna “Adj Close”.

def datosYahoo(dataframe,nombresActivos,inicio,fin):
for i in nombresActivos:
dataframe[i] = data.DataReader(i,data_source='yahoo',start=inicio , end=fin)["Adj Close"]
return dataframe
df = datosYahoo(df_precios,activos,fechaInicio,hoy)
df
Dataframe generado.

Con el dataframe generado podemos graficar los precios de las acciones en el tiempo.

Una Imagen vale más que 1000 palabras…

plt.figure(figsize=(12.2,4.5)) 
for i in df.columns.values:
plt.plot( df[i], label=i)
plt.title('Precio de las Acciones')
plt.xlabel('Fecha',fontsize=18)
plt.ylabel('Precio en USD',fontsize=18)
plt.legend(df.columns.values, loc='upper left')
plt.savefig('plotprecios.png', dpi=300, bbox_inches='tight')
plt.show()
Gráfico del precio de las acciones en el tiempo.

En el gráfico se puede observar claramente el golpe de la pandemia con una gran caída a inicios del 2020, se puede ver con mucho detalle en el gráfico del NASDAQ (^IXIC).

Veamos el gráfico de las acciones con menor precio, para ver su comportamiento con mayor detalle.

Precio de las acciones en el tiempo.

El que tenía acciones de Tesla antes del 2020 se forró (según la Real Academia Chilena de Lengua significa se hizo rico), felicitaciones!

Lo importante son los retornos no los precios:

Si bien la librería de Python pyportfolioopt permite calcular el portafolio óptimo sin que nosotros hagamos la transformación de precios a retornos, es de todas formas un buen ejercicio. En este caso calcularemos los retornos logarítmicos anuales los que se definen de la siguiente forma:

Retornos Logarítmicos Anuales

Esto se realiza de forma sencilla con el siguiente código en Python.

df = np.log(df).diff()
df = df.dropna()
df
Base de datos de los retornos logarítmicos.

Se eliminan los “missing values” (NA) ya que al realizar la transformación logarítmica se pierde el primer valor, ya que se estaría dividiendo por cero al no contar con información.

¿Retornos Normales?

plt.figure(figsize=(12.2,4.5)) 
for i in df.columns.values:
plt.hist( df[i], label=i, bins = 200)
plt.title('Histograma de los retornos')
plt.xlabel('Fecha',fontsize=18)
plt.ylabel('Precio en USD',fontsize=18)
plt.legend(df.columns.values)
plt.savefig('plotretornosnormales.png', dpi=300, bbox_inches='tight')
plt.show()
Histograma de los Retornos en el Tiempo.

Efectivamente se cumple la Log-Normalidad de los retornos.

CAPM

Uno de los requisitos para poder resolver el problema de optimización que supone encontrar el portafolio de mínima varianza es tener los retornos esperados. Una forma sencilla de hacerlo es con el modelo CAPM (Capital Asset Pricing Model).

¿Que dice este modelo?

Nos dice que el retorno esperado del activo i está dado por la suma de la tasa libre de riesgo más el beta del activo multiplicado por la resta del retorno esperado del mercado menos la tasa libre de riesgo. Un trabalenguas cualquiera.

¿Y de donde chucha saco la tasa libre de riesgo y el retorno de mercado?

Si no sabe lo que significa el chilenismo chucha pero si sabe como calcular la tasa libre de riesgo recomiendo utilizar el tiempo de esta sección en leer sobre su significado.

Bueno volviendo con el tema, de acuerdo a nuestro Wikipedia de finanzas favorito, la tasa libre de riesgo real se puede calcular como la resta entre la TIR (yield) de un bono del tesoro y la inflación a un plazo que calce con el plazo al que deseo invertir. Muy complicado y enredado.

Simplificación, como quiero calcular los retornos esperados a un año plazo, voy a tomar la tasa que me entrega el bono del tesoro con Duration a un año como tasa libre de riesgo. En este caso, el bono del tesoro a un año plazo entrega una tasa al día 20/04/2021 (04/20/2021 fecha en gringo) de un 0.07%.

Con respecto al retorno esperado del portafolio de mercado, una opción es asumir el retorno histórico del Benchmark que voy a utilizar. El Benchmark clásico es el S&P 500, pero en este caso como son acciones tecnológicas utilizaremos el NASDAQ.

Muchos supuestos y bla bla vamos a Python

Con esto tenemos todos los datos para utilizar la librería de Python pyportfolioopt y su función que calcula directamente CAPM.

Inputs para la función.

Nos piden los retornos del mercado (en la función corresponde al segundo argumento market_prices) separados de los retornos de nuestros activos (prices).

#Separamos el Benchmark del resto de los activos creando una nueva base de datos.df_activos =  df.loc[:, df.columns != '^IXIC']
df_activos

Ahora lo mismo con el Benchmark

df_benchmark1 =  df.loc[:, df.columns == '^IXIC']

Tamo listos

Como estamos trabajando con los retornos se debe setear returns_data = True.

retornos1 = expected_returns.capm_return(df_activos, market_prices = df_benchmark1, returns_data= True, risk_free_rate=0.07/100, frequency=252)
retornos1

De lo que se obtiene el siguiente resultado:

Retornos Esperados Anuales según el Modelo CAPM

Optimización: por fín a lo que vinimos

Pero antes…

Debemos crear un portafolio arbitrario que después optimizaremos. Para esto, definiremos pesos iguales para todos los activos. Para calcular los pesos iguales de forma sencilla, utilicé la siguiente función que me crea un vector de precios iguales para todos los activos.

def pesosPortafolio(dataframe):
array = []
for i in dataframe.columns:
array.append(1/len(dataframe.columns))
arrayFinal = np.array(array)
return arrayFinal
pesos = pesosPortafolio(df_activos)
pesos

Como resultado se obtuvo el siguiente vector de pesos en el cual a cada activo se le asigna el mismo peso que al resto.

array([0.14285714, 0.14285714, 0.14285714, 0.14285714, 0.14285714,
0.14285714, 0.14285714])

Con esta información podemos calcular la varianza de nuestro portafolio subóptimo.

Varianza del portafolio matricialmente

Donde, w corresponde a los pesos (weights) de los activos y el símbolo de sumatoria corresponde a la matriz de covarianza de los activos que calcularemos a continuación.

df_cov = df_activos.cov()*252
df_cov

Multiplicamos los retornos por 252, ya que en durante el año solo en 252 días se transan valores.

Con esto se tiene toda la información necesaria para calcular la varianza del portafolio. En python el producto punto o producto escalar está representado por el @.

Varianza del Portafolio

#Varianza del Portafolio
varianza_portafolio = pesos.T @ df_cov @pesos
"La varianza del portafolio es:" + " " + str(round(varianza_portafolio*100,1))+"%"
#Resultado:'La varianza del portafolio es: 5.5%'

Luego la desviación estándar o volatilidad del portafolio es simplemente la raíz cuadrada de lo anterior, que se obtiene con la función de numpy sqrt (np.sqrt).

Volatilidad Portafolio

volatilidad_portafolio = np.sqrt(varianza_portafolio)
"La volatilidad del portafolio es:" + " " + str(round(volatilidad_portafolio*100,1))+"%"
#Resultado:
'La volatilidad del portafolio es: 23.5%'

Retorno del Portafolio

retorno_portafolio = np.sum(pesos*retornos1)
'El retorno anual del portafolio es:' + ' ' + str(round(retorno_portafolio*100,3)) + '%'
# Resultado:
'El retorno anual del portafolio es: 18.723%'

Veamos si por el mismo o menor nivel de riesgo podemos obtener un portafolio con mayor retorno, es decir que esté en la frontera eficiente.

Optimización para Mínima Varianza: con venta corta

Seguro muchos de ustedes habrán escuchado el caso de como unos Generación Z organizados en Reddit que utilizando la aplicación de trading Robinhood casi quiebran a uno de los fondos de inversión más grandes de Wall Street: tenían posiciones de venta corta en la acción de GameStop.

Bueno, si no se enteró o no entendió lo que era la venta corta, se lo explico. Es simplemente pedir prestada una cantidad de acciones de una compañía (supongamos 10 acciones a 10 dólares cada una) a una corredora de bolsa (broker). Luego, justo después de comprarlas vendo esas acciones con la esperanza de que esa acción bajará de precio en el futuro, hasta ese momento no hay ganancia ni pérdida. Una vez que la acción baje al precio que yo esperaba, compro las 10 acciones que le debía a la corredora pero ahora como el precio está mucho más bajo, supongamos que ahora el precio es 5; para devolver las 10 acciones solo debo pagar 50 y me quedo con 50 dólares de ganancia del préstamo original, es decir, un retorno del 50%.

Para terminar con el tema de la venta corta, cabe mencionar que a diferencia de una inversión tradicional donde las ganancias son ilimitadas y las pérdidas limitadas, el caso de la venta corta es todo lo contrario, las pérdidas son ilimitadas y las ganancias limitadas.

Ahora, si usted es chileno no se haga ilusiones: en Chile probablemente no se replicará el fenómeno de GameStop, ya que el mercado de venta corta es muy acotado y hay que tener muchas pero muchas lucas (en Chile al billete de 1000 pesos se le denomina luca, tener lucas es tener mucho dinero) para poder participar en el.

Volviendo a Python, si llegaste hasta aquí te darás cuenta de que me gusta el bla bla

Optimizamos el portafolio para mínima varianza (ef.min_volatility) y permitimos la venta corta (weight_bounds = (-1,1)). En la práctica, esto significa que permitimos pesos negativos en nuestro portafolio.

Optimización para portafolio de mínima varianza.
ef = EfficientFrontier(retornos1, df_cov, weight_bounds=(-1,1))
weights = ef.min_volatility()
cleaned_weights = ef.clean_weights()
print(cleaned_weights)
ef.portfolio_performance(verbose=True)

Obtenemos como resultado los siguientes pesos, retorno esperado y volatilidad:

OrderedDict([('AAPL', 0.09437), ('MSFT', -0.04576), ('AMZN', 0.25826), ('GOOGL', 0.22786), ('FB', 0.01725), ('TSLA', -0.02033), ('V', 0.46836)])
Expected annual return: 19.8%
Annual volatility: 23.4%
Sharpe Ratio: 0.76

Donde, justamente en el óptimo se realiza venta corta con las acciones de Microsoft y Tesla. El retorno esperado es de un 19.8% y la volatilidad anual es de un 23.4% y, por último, el Sharpe Ratio es de un 0.76: no es un buen resultado más adelante explicaremos una de las posibles razones.

Optimización para mínima varianza: sin venta corta

Realizamos el mismo procedimiento que la vez anterior, pero esta vez solo aceptamos pesos positivos (weight_bounds=(0,1)).

ef = EfficientFrontier(retornos1, df_cov, weight_bounds=(0,1))
weights = ef.min_volatility()
cleaned_weights = ef.clean_weights()
print(cleaned_weights)
ef.portfolio_performance(verbose=True)

Y obtenemos los siguientes resultados:

OrderedDict([('AAPL', 0.0785), ('MSFT', 0.0), ('AMZN', 0.24254), ('GOOGL', 0.21379), ('FB', 0.01585), ('TSLA', 0.0), ('V', 0.44931)])
Expected annual return: 20.0%
Annual volatility: 23.4%
Sharpe Ratio: 0.77

Los resultados que se obtienen son casi los mismos para ambos casos. Si se fijan, no se invierta en Tesla porque es una acción increíblemente volátil. Sería contraproducente invertir en ella si se desea minimizar la volatilidad.

Optimización para el Sharpe Ratio: con venta corta.

Para maximizar el Sharpe ratio debemos cambiar el min.volatility por ef.max_sharpe().

ef = EfficientFrontier(retornos1, df_cov, weight_bounds=(-1,1))
weights = ef.max_sharpe()
cleaned_weights = ef.clean_weights()
print(cleaned_weights)
ef.portfolio_performance(verbose=True)

Y se obtienen los siguientes decepcionantes resultados:

OrderedDict([('AAPL', 0.22438), ('MSFT', 0.30354), ('AMZN', 0.06793), ('GOOGL', 0.12507), ('FB', 0.07497), ('TSLA', 0.04913), ('V', 0.15499)])
Expected annual return: 22.0%
Annual volatility: 24.8%
Sharpe Ratio: 0.81

¿Pero como si ahora estamos maximizando el Sharpe Ratio?

Y no ha mejorado mucho nuestro retorno esperado, calma ya viene una posible explicación.

Optimización para el Sharpe Ratio: sin venta corta.

ef = EfficientFrontier(retornos1, df_cov,weight_bounds=(0,1))
weights = ef.max_sharpe()
cleaned_weights = ef.clean_weights()
print(cleaned_weights)
ef.portfolio_performance(verbose=True)

Entregando el siguiente portafolio óptimo

OrderedDict([('AAPL', 0.22438), ('MSFT', 0.30354), ('AMZN', 0.06793), ('GOOGL', 0.12507), ('FB', 0.07497), ('TSLA', 0.04913), ('V', 0.15499)])
Expected annual return: 22.0%
Annual volatility: 24.8%
Sharpe Ratio: 0.81

No se puede comprar en 1.5 acciones de Apple ¿Qué hago?

Afortunadamente existe una rama de la optimización que se llama optimización entera, que justamente nos va a entregar cuantas acciones debo invertir en cada activo. El problema de programación entera se define de la siguiente forma:

Problema de Optimización Entera para mínima Varianza.
from pypfopt.discrete_allocation import DiscreteAllocation, get_latest_prices
latest_prices = get_latest_prices(df_activos)
pesos = cleaned_weights
da = DiscreteAllocation(pesos, latest_prices, total_portfolio_value=10.000)
allocation, leftover = da.lp_portfolio()
print("Cantidad de acciones a comprar:", allocation)
print("Dinero sobrante: ${:.2f}".format(leftover))

De este código se obtiene lo siguiente:

Discrete allocation: {'AAPL': 636, 'MSFT': 342, 'AMZN': 85, 'TSLA': 15, 'V': 81}
Funds remaining: $1.96

Estas son las cantidades de acciones óptimas a invertir en mi portafolio.

Conclusiones y aprendizajes

Una de las posibles razones del porqué el portafolio tiene un mal Sharpe Ratio, y no solo eso, no logra disminuir significativamente la varianza es por el hecho de que los activos están profundamente correlacionados entre si.

Veamos la matriz de correlación de los activos:

correlation_mat = df.corr()
plt.figure(figsize=(12.2,4.5))
sns.heatmap(correlation_mat, annot = True)
plt.title('Matriz de Correlación')
plt.xlabel('Activos',fontsize=18)
plt.ylabel('Activos',fontsize=18)
plt.show()

Se confirma la sospecha de la correlación de los activos, por lo tanto, tener estos activos a en un mismo portafolio no suma.

Diversificar la inversión, i.e, no poner todos los huevos en una misma canasta no significa solo agregar una cantidad infinita de activos, si no que también, en lo posible que estos activos tengan la menor correlación posible entre sí, lo que reduciría sustancialmente el riesgo de la cartera sin sacrificar el retorno esperado.

--

--

Nicolás Besser

Industrial Engineering student at Universidad de Chile. Teacher Asistant in Operations Management and Finance II.