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étodoyf.download()
para descargar los datos y el parámetroprogress=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étodonp.log()
para calcular los retornos y el métododropna()
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ónnp.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. Siequal
esTrue
, 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étodomean()
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étodocov()
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
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>

Representamos ahora la serie de retornos y los correspondientes histogramas
returns.plot()
<matplotlib.axes._subplots.AxesSubplot at 0x7f51952b7610>

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)

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>

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>
