Introducción

Un aspecto importante en la vida es la capacidad de poder administrar nuestro dinero. Básicamente se trata de poder generar ahorros e invertir parte de nuestras ganancias.

Cuando logramos gastar menos de lo que ganamos y nos queda un sobrante, muchas veces nos podemos preguntar que hacemos. Una alternativa es invertir, pero comienzan a aparecer otras interrogantes como, donde lo invertimos, en qué activos y qué porcentaje de nuestro dinero le asignamos a cada uno de estos activos. En esta última interrogante me quiero enfocar y en brindar una solución.

Todos los activos tienen un riesgo asociado. Por ejemplo, las acciones están asociadas a un alto riesgo o volatilidad, pero hay acciones y acciones, como acciones de crecimiento, (Tesla o Meta) que son más volatiles y apuntan a un crecimiento rápido, o las acciones de dividendos que son mucho más estables (McDonald’s o Coca Cola). Hay otros activos que tienen un riesgo menor a las acciones, como los bonos, pero de nuevo, hay bonos y bonos, como los bonos del tesoro de Estados Unidos, High Yield o los bonos corporativos, donde cada uno de ellos tiene asociado un riesgo distinto.

Además de poner atención al riesgo a la hora de invertir, debemos de poner atención a la diversificación. Es importante no poner todos los huevos en una misma canasta, es decir, debemos distribuir nuestro dinero en distintos activos con distintos riesgos e industrias. Por ejemplo, si ponemos todos nuestro dinero en acciones de la industria tecnológica no estamos diversificando de la mejor manera, porque si ocurre una noticia negativa para este sector todas nuestras acciones van a caer, pero si tengo acciones en tecnología, pero también en retail, puede que unas bajen, pero otras suban. De eso se trata la diversificación: protegerse ante subidas y bajadas del mercado, pudiendo tener mayor control sobre nuestro riesgo.

Modelos de portafolios eficientes

Ante la inquietud de como podemos administrar nuestro riesgo de la mejor manera famosos economistas han dedicado sus estudios a resolver esta inquietud. Desde ahi han aparecido modelos de portafolios de inversión como el modelo de Black-Litterman o el modelo de Markowitz.

Este proyecto trata de automatizar el proceso de maximizar nuestra rentabilidad dado un riesgo asociado a nuestro portafolio de inversión. Para ello utilizaremos como referencia el modelo de Markowitz, que si bien tiene sus desventajas, puede ser de gran ayuda a la hora de decidir como vamos a distribuir nuestros activos de inversión para maximizar nuestro retorno dado un riesgo, o minimizar nuestro riesgo dado un retorno.

Implementación

Si bien, el modelo se puede implementar en varias herramientas de manejo de datos, como en Excel con Solver, quise realizarlo en Python para utilizar la librería yfinance que se conecta a las APIs de Yahoo Finance para extraer información. Los pasos para construir este modelo son:

  • Descargar los precios históricos de los activos financieros que nos interesan en un horizonte temporal de preferencia.
  • Calcular los rendimientos históricos de los activos seleccionados y anualizamos el retorno multiplicando por 252 (se restan los fines de semana y feriados)
  • Calculamos la matriz de covarianza para poder medir el riesgo del portafolio. Representa la relación entre las volatilidades de los activos.
  • Definimos pesos aleatorios para cada activo dentro del portafolio. Por ejemplo, si tengo Pfizer, Tesla y Home Depot, podría poner un tercio para cada uno.
  • Establecemos la restricción de este portafolio y es que los pesos deben sumar 1 (100%) y cada activo tendrá un peso que indica su proporción en el portafolio.
  • Utilizando los pesos calculamos el rendimiento esperado y la desviación estándar (riesgo) del portafolio.
  • Calculamos el ratio de Sharpe del portafolio, que mide la rentabilidad de una inversión ajustada al riesgo.
  • Utilizamos un algoritmo de optimización para encontrar los pesos que minimicen la volatilidad o que maximicen el ratio de Sharpe.

De manera alternativa a los pasos anteriores, podemos también simular varios portafolios con pesos aleatorios hasta que nos encontremos con la frontera eficiente que corresponde al conjunto de carteras que ofrecen el mayor rendimiento posible para un nivel de riesgo determinado.

frontera eficiente

Vemos que los puntos azules son varios portafolios aleatorios con distintos pesos, mientras que la linea anaranjada es donde están los portafolios más eficientes, es decir, aquellos que maximizan el retorno dado un riesgo.

Código

Para este proyecto no incluí portafolios aleatorios. Simplemente calculé de inmediato la frontera eficiente y lo muestra en un gráfico. El proyecto también maximiza el íratio de Sharpe y minimiza el riesgo.

Para este proyecto utilizaremos la librería scipy de python que es ampliamente utilizada para problemas de optimización. El código completo es el siguiente:

import datetime
import yfinance as yf
import numpy as np
import pandas as pd
from statsmodels import regression
import scipy.optimize as sc
import matplotlib.pyplot as plt


class Portfolio:
    def __init__(self, my_portfolio:str, years:int, risk_free_rate):
        # No todos estos atributos los utilizaré en todas las funciones. Ver que puedo sacar para una nueva clase y cuales se quedan.
        self.my_portfolio = my_portfolio
        self.years = years
        self.risk_free_rate = risk_free_rate
        self.asset_prices = self.historical_price()
        self.returns = self.asset_prices.pct_change()
        self.cov_matrix = self.returns.cov()
        self.mean_return = self.returns.mean()

    def date_range(self)-> tuple:
        """
        Rango de fechas donde se valoriza un activo desde el comienzo
        hasta el día de hoy. Por defecto, la clase ve un año de rango.
        El resultado es una tupla que contiene la fecha inicial y la fecha de hoy.

        retorna (fecha inicial, fecha de hoy)
        """
        today = datetime.date.today()
        start_date = today - datetime.timedelta(days=365 * self.years)
        return (start_date, today)

    def historical_price(self):
        """
        Precio histórico de un activo o activos.
        El output es un diccionario anidado, donde la clave es el activo y el 
        valor es otro diccionario cuya clave es el día y el valor es el precio.

        retorna {activo: {fecha:precio}}                
        """
        start_date, end_date = self.date_range()
        df_asset = yf.download(self.my_portfolio, start=start_date, end=end_date)
        header_levels = df_asset.columns.nlevels
        df_asset = df_asset[[Close]]
        if header_levels > 1: # dataframe con múltiples niveles
            df_asset = df_asset.droplevel(level=0, axis=1)
        else:
            df_asset = df_asset.rename(columns={Close:self.symbol})
        return df_asset
    
    def portfolio_return(self, weights):
        """ Calculo del retorno anual del portafolio """
        portfolio_return = np.sum(self.mean_return * weights) * 252  # anualizamos
        return portfolio_return

    def portfolio_risk(self, weights):
        """ Calculo del riesgo del portafolio """
        portfolio_risk = np.sqrt(np.dot(weights, np.dot(self.cov_matrix, weights))) * np.sqrt(252)  # anualizamos
        return portfolio_risk
    
    def negative_sharpe_rate(self, weights):
        """ Calculo del sharpe rate negativo. """
        portfolio_return = self.portfolio_return(weights)
        portfolio_risk = self.portfolio_risk(weights)
        negative_sharpe_rate = - (portfolio_return - self.risk_free_rate) / portfolio_risk
        return negative_sharpe_rate

    def optimize(self, objective_function, target_return=None, bound=(0,1)):
        num_assets = len(self.mean_return)
        bounds = tuple(bound for asset in range(num_assets))
        constraints = [{type: eq, fun: lambda x: np.sum(x) - 1}]
        if target_return != None:
            constraints.append({type: eq, fun: lambda x:self.portfolio_return(x) - target_return})
        result = sc.minimize(objective_function, num_assets * [1/num_assets],
                             method=SLSQP, bounds=bounds, constraints=constraints)
        return result
    
    def plot_prices(self):
        plt.plot(self.asset_prices)
        plt.show()

    def calculate_beta(self, benchmark_symbol:str)->dict:
        """
        Calcular las pendientes para cada columna del DataFrame con respecto a la Serie.
        En el eje x está la variación de precios (retornos) del mercado, mientras que el eje y
        está la variación de precios de una empresa.
        """
        self.benchmark = Portfolio(benchmark_symbol, self.years) # CFMITNIPSA.SN puede ser un benchmark alternativo para el IPSA
        share_returns = self.returns[1:] # eliminamos nan
        betas = {}
        y = self.benchmark.returns.values[1:]
        for header, column in share_returns.items():
            x = column.values
            beta = self._beta_linreg(x,y)
            betas[header] = beta
        return pd.Series(betas)
    
    def _beta_linreg(self, x, y)->float:
        """ Calculamos los beta través de una 
        regresión lineal de dos activos financieros

        x: El mercado
        y: una acción
        """
        model = regression.linear_model.OLS(y,x).fit()
        beta = model.params[0]
        return beta

class EfficientFrontier:
    def __init__(self, my_portfolio, years, risk_free_rate, number_of_portfolios):        
        self.my_portfolio = my_portfolio
        self.years = years
        self.risk_free_rate = risk_free_rate
        self.number_of_portfolios = number_of_portfolios
        self.portfolio = Portfolio(my_portfolio, self.years, self.risk_free_rate)
        self.efficient_risks, self.efficient_returns, self.optimized_weights, \
            self.max_sharpe_rate_opt, self.min_risk_opt = self.efficient_portfolios()
        self.minRisk_return = self.portfolio.portfolio_return(self.min_risk_opt.x)
        self.minRisk_risk = self.portfolio.portfolio_risk(self.min_risk_opt.x)
        self.maxSR_return = self.portfolio.portfolio_return(self.max_sharpe_rate_opt.x)
        self.maxSR_risk = self.portfolio.portfolio_risk(self.max_sharpe_rate_opt.x)

    def efficient_portfolios(self):
        max_sharpe_rate_opt = self.portfolio.optimize(self.portfolio.negative_sharpe_rate)
        min_risk_opt = self.portfolio.optimize(self.portfolio.portfolio_risk)
        target_returns = np.linspace(min_risk_opt.fun, max_sharpe_rate_opt.fun, self.number_of_portfolios)
        efficient_risks, efficient_returns, efficient_weights = [], [], []
        for target in target_returns:
            optimized_portfolio= self.portfolio.optimize(self.portfolio.portfolio_risk, target)
            optimized_weights = optimized_portfolio.x
            optimized_portfolio_risk = self.portfolio.portfolio_risk(optimized_weights)
            optimized_portfolio_return = self.portfolio.portfolio_return(optimized_weights)
            efficient_risks.append(optimized_portfolio_risk)
            efficient_returns.append(optimized_portfolio_return)
            efficient_weights.append({asset_name:weight for asset_name, weight in zip(self.my_portfolio.split(), optimized_weights)})
        return efficient_risks, efficient_returns, efficient_weights, max_sharpe_rate_opt, min_risk_opt
    
    def efficient_portfolio_df(self):
        weights_list = [list(weights.values()) for weights in self.optimized_weights]
        returns = [self.portfolio.portfolio_return(weights) for weights in weights_list]
        risks = [self.portfolio.portfolio_risk(weights) for weights in weights_list]
        df = pd.DataFrame(self.optimized_weights).reset_index()
        df[Retorno] = returns
        df[Riesgo] = risks
        df[Sharpe Rate] = (df[Retorno] - self.portfolio.risk_free_rate) / df[Riesgo]
        df = df.mul(100).round(2).drop_duplicates()
        return df
    
    def efficient_frontier_keypoints_df(self):
        max_sharpe_rate_row = list(self.max_sharpe_rate_opt.x) + [self.maxSR_return, self.maxSR_risk]
        min_risk_row = list(self.min_risk_opt.x) + [self.minRisk_return, self.minRisk_risk]
        columns = self.my_portfolio.split() + [Retorno, Riesgo]
        df = pd.DataFrame([max_sharpe_rate_row, min_risk_row], columns=columns)
        df = df.mul(100).round(2).drop_duplicates()
        df[Optimización] = [Max Sharpe, Min Riesgo]
        return df
    
    def plot_efficient_portfolios(self):
        plt.style.use(ggplot)
        plt.figure(figsize=(10, 6))        
        plt.scatter(x=self.efficient_risks, y=self.efficient_returns, c=orange, s=10, label=Portafolio)        
        self.plot_efficient_frontier(self.efficient_risks, self.efficient_returns, self.minRisk_return)
        self.plot_assets()
        plt.scatter(self.minRisk_risk, self.minRisk_return, color=green, marker=*, s=200, label=Min Riesgo)
        plt.scatter(self.maxSR_risk, self.maxSR_return, color=red, marker=*, s=200, label=Max Sharpe)
        plt.axis(equal)
        plt.title("Frontera Eficiente")
        plt.xlabel("Volatilidad anualizada")
        plt.ylabel("Rendimiento anualizado")
        plt.legend(loc=lower right)
        plt.show()

    def plot_efficient_frontier(self, efficient_risks, efficient_returns, minRisk_return):
        efficient_frontier = [(risk, profit) for risk, profit in zip(efficient_risks, efficient_returns)
                              if profit >= minRisk_return]
        plt.plot(*zip(*efficient_frontier), linestyle=dashed, color=black, label=Frontera Eficiente,
                 linewidth=3.0)
        
    def plot_assets(self):
        asset_prices = self.portfolio.asset_prices
        asset_prices[index] = range(asset_prices.shape[0])
        years_to_days = asset_prices.shape[0] // self.years
        asset_prices = asset_prices[asset_prices[index] % years_to_days == 0]
        returns = asset_prices.pct_change()
        mean_return = returns.mean()
        std_return = returns.std()
        for asset in asset_prices.columns:
            plt.scatter(std_return[asset], mean_return[asset], color=blue)
            plt.annotate(asset, (std_return[asset], mean_return[asset]), textcoords="offset points", xytext=(0,10), ha=center)
        

El modo de uso de este código es simple: debemos escoger los activos que queremos analizar, buscamos sus nemotécnicos y los instanciamos en la clase EfficientFrontier separando cada nemotécnico con un espacio. A su vez, elegimos el horizonte temporal al que queremos realizar el análisis.

Por ejemplo, imaginemos que queremos calcular la frontera eficiente de tres acciones chilenas que pertenecen a distintas industrias: Colbun, Aguas Andinas y AFP Habitat.

Buscamos sus nemotécnicos en Yahoo Finance y tenemos lo siguiente:

  • Nemotécnico de Colbun: COLBUN.SN
  • Nemotécnico de Aguas Andinas: AGUAS-A.SN
  • Nemotécnico de AFP Habitat: HABITAT.SN

Ahora, supongamos que quiero hacer el análisis de estas 3 acciones en un plazo de 5 años.

Ya con esos datos soy capaz de calcular la frontera eficiente. En código se ve así:

if __name__ == __main__:
    my_portfolio = COLBUN.SN AGUAS-A.SN HABITAT.SN
    shares_count = my_portfolio.count( ) + 1
    weights = np.array([1/shares_count] * shares_count)
    years = 5
    risk_free_rate = 0
    efficient_frontier = EfficientFrontier(my_portfolio, years, risk_free_rate, 100)
    print(efficient_frontier.efficient_frontier_keypoints_df())
    efficient_frontier.plot_efficient_portfolios()
        

Definimos una tasa libre de riesgo de cero, los pesos de cada activo los dividimos en tres partes iguales como valores iniciales e instanciamos todo en la clase EfficientFrontier para mostrar la frontera eficiente. Finalmente imprimimos una tabla donde definimos los pesos para cada activo cuando minimizamos riesgo y cuando maximizamos el ratio de Sharpe. Además, generamos el gráfico de la frontera eficiente.

gráfico de frontera eficiente

La estrella verde del gráfico indica el portafolio con el mínimo riesgo posible y se encuentra al comienzo de la frontera eficiente. La estrella roja, en cambio, indica el portafolio con el máximo ratio de Sharpe posible. Vemos también el retorno y riesgo asociado a cada acción durante 5 años, marcadas con puntos azules.

Los pesos para cada acción cuando minimizamos el riesgo y maximizamos el ratio de Sharpe es el siguiente:

COLBUN.SN AGUAS-A.SN HABITAT.SN Retorno Riesgo Optimización
0.00 52.18 47.82 5.30 27.64 Max Sharpe
30.74 21.40 47.86 3.65 24.24 Min Riesgo

En un escenario donde queremos maximizar el ratio Sharpe no invertimos nada en Colbun, si no que nos distribuimos entre Aguas Andinas y AFP Habitat, pero si estamos en un escenario donde queremos minimizar el riesgo, si lo incluimos.

De la misma forma puedes intentarlo con cualquier activo, sin importar si es una acción, bono, ETF, criptomoneda, etc. Lo genial de esto es que puedes mezclar muchos tipos de activos en tu portafolio y siempre llegar a la distribución más optima.