Optimización de portfolio#

Primero instalamos vía pip la librería yfinance, que no está disponible en Google Colab, y es una herramienta que permite descargar y manipular datos financieros de Yahoo Finance. Con esta librería se pueden obtener precios históricos de acciones, índices, criptomonedas, bonos, etc. y realizar análisis técnicos, como gráficos, media móvil, volatilidad, entre otros. Además, también se pueden descargar información sobre dividendos y splits de acciones.

!pip install yfinance
Looking in indexes: https://pypi.org/simple, https://us-python.pkg.dev/colab-wheels/public/simple/
Requirement already satisfied: yfinance in /usr/local/lib/python3.8/dist-packages (0.2.9)
Requirement already satisfied: pandas>=1.3.0 in /usr/local/lib/python3.8/dist-packages (from yfinance) (1.3.5)
Requirement already satisfied: lxml>=4.9.1 in /usr/local/lib/python3.8/dist-packages (from yfinance) (4.9.2)
Requirement already satisfied: frozendict>=2.3.4 in /usr/local/lib/python3.8/dist-packages (from yfinance) (2.3.4)
Requirement already satisfied: multitasking>=0.0.7 in /usr/local/lib/python3.8/dist-packages (from yfinance) (0.0.11)
Requirement already satisfied: beautifulsoup4>=4.11.1 in /usr/local/lib/python3.8/dist-packages (from yfinance) (4.11.2)
Requirement already satisfied: appdirs>=1.4.4 in /usr/local/lib/python3.8/dist-packages (from yfinance) (1.4.4)
Requirement already satisfied: requests>=2.26 in /usr/local/lib/python3.8/dist-packages (from yfinance) (2.28.2)
Requirement already satisfied: cryptography>=3.3.2 in /usr/local/lib/python3.8/dist-packages (from yfinance) (39.0.0)
Requirement already satisfied: numpy>=1.16.5 in /usr/local/lib/python3.8/dist-packages (from yfinance) (1.21.6)
Requirement already satisfied: html5lib>=1.1 in /usr/local/lib/python3.8/dist-packages (from yfinance) (1.1)
Requirement already satisfied: pytz>=2022.5 in /usr/local/lib/python3.8/dist-packages (from yfinance) (2022.7)
Requirement already satisfied: soupsieve>1.2 in /usr/local/lib/python3.8/dist-packages (from beautifulsoup4>=4.11.1->yfinance) (2.3.2.post1)
Requirement already satisfied: cffi>=1.12 in /usr/local/lib/python3.8/dist-packages (from cryptography>=3.3.2->yfinance) (1.15.1)
Requirement already satisfied: webencodings in /usr/local/lib/python3.8/dist-packages (from html5lib>=1.1->yfinance) (0.5.1)
Requirement already satisfied: six>=1.9 in /usr/local/lib/python3.8/dist-packages (from html5lib>=1.1->yfinance) (1.15.0)
Requirement already satisfied: python-dateutil>=2.7.3 in /usr/local/lib/python3.8/dist-packages (from pandas>=1.3.0->yfinance) (2.8.2)
Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/lib/python3.8/dist-packages (from requests>=2.26->yfinance) (2.1.1)
Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.8/dist-packages (from requests>=2.26->yfinance) (2022.12.7)
Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.8/dist-packages (from requests>=2.26->yfinance) (2.10)
Requirement already satisfied: urllib3<1.27,>=1.21.1 in /usr/local/lib/python3.8/dist-packages (from requests>=2.26->yfinance) (1.24.3)
Requirement already satisfied: pycparser in /usr/local/lib/python3.8/dist-packages (from cffi>=1.12->cryptography>=3.3.2->yfinance) (2.21)
import datetime
from typing import List

import pandas as pd
import numpy as np
import yfinance as yf
from scipy.optimize import minimize
import matplotlib.pyplot as plt
import matplotlib as mpl

# configure matplotlib
%config InlineBackend.figure_format='retina'
mpl.rcParams["figure.figsize"] = (15, 10)
plt.style.use("seaborn-whitegrid")

symbols = [
    "TSM", 
    "TSLA", 
    "MTN", 
    "IDXX", 
    "FIGS"
]

start_date = datetime.date(2022, 1, 1)

Aquí hemos definido algunos imports, configuraciones y variables que utilizaremos en el resto del notebook.

  • datetime se utiliza para trabajar con fechas y horas.

  • scipy.optimize.minimize es una función de la biblioteca Scipy para minimizar una función dada.

La variable symbols contiene una lista de códigos de acciones (símbolos) para varias compañías tecnológicas populares. Finalmente, start_date es un objeto de tipo datetime.date que representa la primera fecha a partir de la cual vamos a traer datos.

def generate_stocks(symbols: List[str], start: datetime.date): 
    stocks = yf.download(
        symbols, 
        start=start, 
        end=datetime.datetime.today(), 
        progress=False
    )['Close']
    return stocks

def get_returns(stocks: pd.DataFrame):
    returns = np.log(stocks / stocks.shift(1)).dropna(how="any")
    return returns

def generate_weights(n: int, equal: bool=None):
    if equal: 
        w = np.ones(n) / n
        return w
    w = np.random.rand(n)
    w_norm = w / w.sum()
    return w_norm

def get_portfolio_return(w: np.array, returns: pd.DataFrame):
    return np.sum(returns.mean()*w)

def get_risk(w: np.array, returns: pd.DataFrame): 
    return np.sqrt(w.T@returns.cov()@w)

Veamos lo que hacen cada una de estas funciones:

  • generate_stocks: esta función toma una lista de símbolos de acciones y una fecha inicial y descarga los datos de cierre de Yahoo Finance para esas acciones. Utiliza el método yf.download() para descargar los datos y el parámetro progress=False para evitar la impresión de mensajes de progreso. La función devuelve solo la columna “Close” de los datos descargados.

  • get_returns: esta función toma un DataFrame de acciones y calcula los retornos diarios utilizando la fórmula logarítmica de retorno. Utiliza el método np.log() para calcular los retornos y el método dropna() para eliminar las filas con valores faltantes. La función devuelve el DataFrame de retornos.

  • generate_weight: esta función genera pesos aleatorios para cada acción, recibe como argumento el número de acciones a considerar. Utiliza la función np.random.rand() para generar los pesos aleatorios y luego normaliza los pesos dividiéndolos entre la suma total de los pesos. La función devuelve el DataFrame de pesos normalizados. Si equal es True, entonces todos los pesos son el mismo.

  • get_portfolio_return: esta función toma un array de pesos y un DataFrame de retornos y calcula el retorno diario esperado. Utiliza el método mean() de Pandas para calcular los retornos medios de cada acción y luego los multiplica por los pesos correspondientes. La función devuelve el retorno diario esperado.

  • get_risk: esta función toma un array de pesos y un DataFrame de retornos y calcula el riesgo asociado a esos pesos. Utiliza el método cov() de Pandas para calcular la matriz de covarianza de los retornos y luego multiplica la matriz de covarianza por los pesos. La función devuelve el riesgo calculado. Si \(w\) es el vector de pesos y \(\Omega = (\sigma)_{ij}\) la matriz de covarianza de retornos, el riesgo se calcula como

\[ \text{riesgo} = \sqrt{w^T \Omega w} = \sqrt{\sum_{i=1}^{n} \sum_{j=1}^n w_iw_j\sigma_{ij}} \]
stocks = generate_stocks(symbols, start_date)
returns = get_returns(stocks)

Podemos utilizar pandas directamente para representar las series, llamando al método plot de la clase pd.DataFrame.

stocks.plot()
<matplotlib.axes._subplots.AxesSubplot at 0x7f519580f070>
../../_images/3b0240f20e14ad6f22000b74679405e59afbe2db04a0507c5e7b0dc561729929.png

Representamos ahora la serie de retornos y los correspondientes histogramas

returns.plot()
<matplotlib.axes._subplots.AxesSubplot at 0x7f51952b7610>
../../_images/ef111b58582b5d898c6e708a1adca655c3b119ecede9cd7bb4c5d4ebdf28d588.png
returns.hist()
array([[<matplotlib.axes._subplots.AxesSubplot object at 0x7f519523d760>,
        <matplotlib.axes._subplots.AxesSubplot object at 0x7f51951f3910>],
       [<matplotlib.axes._subplots.AxesSubplot object at 0x7f519521fd30>,
        <matplotlib.axes._subplots.AxesSubplot object at 0x7f51951d9160>],
       [<matplotlib.axes._subplots.AxesSubplot object at 0x7f5195185580>,
        <matplotlib.axes._subplots.AxesSubplot object at 0x7f5195132a00>]],
      dtype=object)
../../_images/986c488a853f4a31f6386e6022d515a1a66bb996ce5ac86d9bacada8330110b5.png

Buscamos un vector de pesos óptimo que minimice el riesgo#

def get_optimal_portfolio_weights(returns):
    # The constraints
    cons = (
        {
            "type": "eq", 
            "fun": lambda w: np.sum(w) - 1
        }, 
        {
            "type": "ineq", 
            "fun": lambda w: np.sum(returns.mean()*w) - returns.mean().mean()
        }
    ) 
    
    
    # Every stock can get any weight from 0 to 1
    bounds = tuple((0,1) for x in range(returns.shape[1])) 
    
    # Initialize the weights with an even split
    # In out case each stock will have 10% at the beginning
    guess = [1./returns.shape[1] for x in range(returns.shape[1])]
    
    objective = lambda w: get_risk(w, returns) 

    optimized_results = minimize(objective, guess, method = "SLSQP", bounds=bounds, constraints=cons)

    s = pd.Series(
        optimized_results.x, 
        index=returns.columns
    )

    print("Vector de pesos óptimo")
    print(s)
    print(s.sum())

    return s

Visualizaciones#

def generate_plot(ax, returns, n, w_opt):

    # individual stocks
    individual_returns = returns.mean()
    individual_risks = returns.std()
    
    # random portfolios
    portfolio_returns = []
    portfolio_risks = []
    for _ in range(n):
        w = generate_weights(returns.shape[1])
        portfolio_returns.append(get_portfolio_return(w, returns))
        portfolio_risks.append(get_risk(w, returns))

    # equally distributed portfolio
    w_eq = generate_weights(returns.shape[1], equal=True)
    eq_portfolio_returns = get_portfolio_return(w_eq, returns)
    eq_portfolio_risk = get_risk(w_eq, returns)

    # optimal portfolio
    opt_portfolio_returns = get_portfolio_return(w_opt, returns)
    opt_portfolio_risk = get_risk(w_opt, returns)

    ax.scatter(individual_returns, individual_risks, label="individual stocks", s=50)
    for i, col in enumerate(returns.columns):
        ax.annotate(col, (individual_returns[i], individual_risks[i]))
    ax.scatter(portfolio_returns, portfolio_risks, label="random portfolio", s=100)
    ax.scatter(eq_portfolio_returns, eq_portfolio_risk, label="eq. dist. portfolio", s=100)
    ax.scatter(opt_portfolio_returns, opt_portfolio_risk, label="optimal portfolio", s=200)

    ax.legend()
    ax.set_xlabel("Expected daily return")
    ax.set_ylabel("risk")
    ax.set_title("Return vs risk")

    return ax
fig, ax = plt.subplots()
w_opt = get_optimal_portfolio_weights(returns)
generate_plot(ax, returns, n=50, w_opt=w_opt)
Vector de pesos óptimo
FIGS    2.327376e-18
IDXX    2.109383e-01
MTN     4.760101e-01
TSLA    0.000000e+00
TSM     3.130515e-01
dtype: float64
1.0
<matplotlib.axes._subplots.AxesSubplot at 0x7f51951930d0>
../../_images/2eb21a8509f3e219a600653a4e3036dda91d51675063a3759c89a86f415f9d6d.png

Encapsulando todo en una sola clase#

import numpy as np
import pandas as pd
import yfinance as yf
from scipy.optimize import minimize
from typing import List
import datetime
import matplotlib as mpl
from matplotlib.axes import Axes
import matplotlib.pyplot as plt

class OptimalPortfolio:
    
    def __init__(
        self, 
        symbols: List[str], 
        start_date: datetime.date = datetime.date.today() - datetime.timedelta(100),
        end_date: datetime.date = datetime.date.today()
    ):
        self.stocks = self.generate_stocks(symbols, start_date, end_date)
        self.returns = self.get_returns(self.stocks)
        self.w_opt = self.get_optimal_portfolio_weights()

    def generate_stocks(self, symbols: List[str], start_date: datetime.date, end_date: datetime.date): 
        stocks = yf.download(
            symbols, 
            start=start_date, 
            end=end_date, 
            progress=False
        )['Close']
        return stocks

    def get_returns(self, stocks: pd.DataFrame):
        returns = np.log(stocks / stocks.shift(1)).dropna(how="any")
        return returns

    def generate_weights(self, n: int, equal: bool = None):
        if equal: 
            w = np.ones(n) / n
            return w
        w = np.random.rand(n)
        w_norm = w / w.sum()
        return w_norm

    def get_portfolio_return(self, w: np.array, returns: pd.DataFrame):
        return np.sum(returns.mean()*w)

    def get_risk(self, w: np.array, returns: pd.DataFrame): 
        return np.sqrt(w.T@returns.cov()@w)

    def get_optimal_portfolio_weights(self):
        # The constraints
        cons = (
            {
                "type": "eq", 
                "fun": lambda w: np.sum(w) - 1
            }, 
            {
                "type": "ineq", 
                "fun": lambda w: np.sum(self.returns.mean()*w) - self.returns.mean().mean()
            }
        ) 

        # Every stock can get any weight from 0 to 1
        bounds = tuple((0,1) for x in range(self.returns.shape[1])) 

        # Initialize the weights with an even split
        # In out case each stock will have 10% at the beginning
        guess = [1./self.returns.shape[1] for x in range(self.returns.shape[1])]

        objective = lambda w: self.get_risk(w, self.returns) 

        optimized_results = minimize(objective, guess, method = "SLSQP", bounds=bounds, constraints=cons)

        w_opt = np.array(optimized_results.x)

        return w_opt

    def generate_plot(self, ax: Axes, n: int = 50):

        # individual stocks
        individual_returns = self.returns.mean()
        individual_risks = self.returns.std()
        
        # random portfolios
        portfolio_returns = []
        portfolio_risks = []
        for _ in range(n):
            w = self.generate_weights(self.returns.shape[1])
            portfolio_returns.append(self.get_portfolio_return(w, self.returns))
            portfolio_risks.append(self.get_risk(w, self.returns))

        # equally distributed portfolio
        w_eq = self.generate_weights(self.returns.shape[1], equal=True)
        eq_portfolio_returns = self.get_portfolio_return(w_eq, self.returns)
        eq_portfolio_risk = self.get_risk(w_eq, self.returns)

        # optimal portfolio
        opt_portfolio_returns = self.get_portfolio_return(self.w_opt, self.returns)
        opt_portfolio_risk = self.get_risk(self.w_opt, self.returns)

        ax.scatter(individual_returns, individual_risks, label="individual stocks", s=50)
        for i, col in enumerate(self.returns.columns):
            ax.annotate(col, (individual_returns[i], individual_risks[i]))
        ax.scatter(portfolio_returns, portfolio_risks, label="random portfolio", s=100)
        ax.scatter(eq_portfolio_returns, eq_portfolio_risk, label="eq. dist. portfolio", s=100)
        ax.scatter(opt_portfolio_returns, opt_portfolio_risk, label="optimal portfolio", s=200)

        ax.legend()
        ax.set_xlabel("Expected daily return")
        ax.set_ylabel("risk")
        ax.set_title("Return vs risk")

        return ax
optimal_portfolio = OptimalPortfolio(
    [
        "AAPL", 
        "MSFT", 
        "TSLA", 
        "NVDA", 
        "AMZN", 
        "JNJ", 
        "V"
    ]
)
mpl.rcParams["figure.figsize"] = (15, 10)
plt.style.use("seaborn-whitegrid")

fig, ax = plt.subplots()
optimal_portfolio.generate_plot(ax)
<matplotlib.axes._subplots.AxesSubplot at 0x7fae5b77a2b0>
../../_images/6ac0c40adb399757a0c5fa2da87c138d0730be517e58832b9b9395616e295897.png