diff --git a/openbb_terminal/portfolio/portfolio_optimization/optimizer_model.py b/openbb_terminal/portfolio/portfolio_optimization/optimizer_model.py index c81668b9bd9..f41d216545f 100644 --- a/openbb_terminal/portfolio/portfolio_optimization/optimizer_model.py +++ b/openbb_terminal/portfolio/portfolio_optimization/optimizer_model.py @@ -9,11 +9,15 @@ from typing import List, Tuple import numpy as np import pandas as pd import yfinance as yf -import riskfolio as rp from openbb_terminal.decorators import log_start_end from openbb_terminal.portfolio.portfolio_optimization import yahoo_finance_model from openbb_terminal.rich_config import console +from openbb_terminal.portfolio.portfolio_optimization.riskfolio import ( + Portfolio, + HCPortfolio, + AuxFunctions, +) logger = logging.getLogger(__name__) @@ -330,7 +334,7 @@ def get_mean_risk_portfolio( risk_free_rate = risk_free_rate / time_factor[freq.upper()] # Building the portfolio object - port = rp.Portfolio(returns=stock_returns, alpha=alpha) + port = Portfolio.Portfolio(returns=stock_returns, alpha=alpha) # Estimate input parameters: port.assets_stats(method_mu=mean, method_cov=covariance, d=d_ewma) @@ -462,7 +466,7 @@ def get_max_diversification_portfolio( ) # Building the portfolio object - port = rp.Portfolio(returns=stock_returns) + port = Portfolio.Portfolio(returns=stock_returns) # Estimate input parameters: port.assets_stats(method_mu="hist", method_cov=covariance, d=d_ewma) @@ -572,11 +576,11 @@ def get_max_decorrelation_portfolio( ) # Building the portfolio object - port = rp.Portfolio(returns=stock_returns) + port = Portfolio.Portfolio(returns=stock_returns) # Estimate input parameters: port.assets_stats(method_mu="hist", method_cov=covariance, d=d_ewma) - port.cov = rp.cov2corr(port.cov) + port.cov = AuxFunctions.cov2corr(port.cov) # Budget constraints port.upperlng = value @@ -726,7 +730,7 @@ def get_risk_parity_portfolio( risk_free_rate = risk_free_rate / time_factor[freq.upper()] # Building the portfolio object - port = rp.Portfolio(returns=stock_returns, alpha=alpha) + port = Portfolio.Portfolio(returns=stock_returns, alpha=alpha) # Calculating optimal portfolio port.assets_stats(method_mu=mean, method_cov=covariance, d=d_ewma) @@ -872,7 +876,7 @@ def get_rel_risk_parity_portfolio( ) # Building the portfolio object - port = rp.Portfolio(returns=stock_returns) + port = Portfolio.Portfolio(returns=stock_returns) # Calculating optimal portfolio port.assets_stats(method_mu=mean, method_cov=covariance, d=d_ewma) @@ -1129,7 +1133,7 @@ def get_hcp_portfolio( risk_free_rate = risk_free_rate / time_factor[freq.upper()] # Building the portfolio object - port = rp.HCPortfolio( + port = HCPortfolio.HCPortfolio( returns=stock_returns, alpha=alpha, a_sim=a_sim, diff --git a/openbb_terminal/portfolio/portfolio_optimization/optimizer_view.py b/openbb_terminal/portfolio/portfolio_optimization/optimizer_view.py index bd0e5aa2256..7804f6785c7 100644 --- a/openbb_terminal/portfolio/portfolio_optimization/optimizer_view.py +++ b/openbb_terminal/portfolio/portfolio_optimization/optimizer_view.py @@ -12,7 +12,6 @@ from datetime import date import matplotlib.pyplot as plt import numpy as np import pandas as pd -import riskfolio as rp from matplotlib.lines import Line2D from matplotlib.gridspec import GridSpec @@ -26,6 +25,12 @@ from openbb_terminal.portfolio.portfolio_optimization import ( optimizer_model, yahoo_finance_model, ) +from openbb_terminal.portfolio.portfolio_optimization.riskfolio import ( + Portfolio, + PlotFunctions, + RiskFunctions, +) + from openbb_terminal.rich_config import console from openbb_terminal.config_terminal import theme @@ -105,6 +110,7 @@ time_factor = { "M": 12.0, } + @log_start_end(log=logger) def d_period(period: str, start: str, end: str): """ @@ -149,6 +155,7 @@ def d_period(period: str, start: str, end: str): return p + @log_start_end(log=logger) def portfolio_performance( weights: dict, @@ -236,7 +243,7 @@ def portfolio_performance( print(f"Sharpe ratio: {sharpe:.4f}") if risk_measure != "MV": - risk = rp.Sharpe_Risk( + risk = RiskFunctions.Sharpe_Risk( weights, cov=stock_returns.cov(), returns=stock_returns, @@ -285,6 +292,7 @@ def portfolio_performance( "Return / " + risk_names[risk_measure.lower()] + f" ratio: {sharpe_2:.4f}" ) + @log_start_end(log=logger) def display_weights(weights: dict, market_neutral: bool = False): """ @@ -320,6 +328,7 @@ def display_weights(weights: dict, market_neutral: bool = False): header = "Value ($)" if tot_value > 1.01 else "Value (%)" print_rich_table(weight_df, headers=[header], show_index=True, title="Weights") + @log_start_end(log=logger) def display_equal_weight( stocks: List[str], @@ -425,6 +434,7 @@ def display_equal_weight( console.print("") return weights + @log_start_end(log=logger) def display_property_weighting( stocks: List[str], @@ -533,6 +543,7 @@ def display_property_weighting( console.print("") return weights + @log_start_end(log=logger) def display_mean_risk( stocks: List[str], @@ -719,6 +730,7 @@ def display_mean_risk( console.print("") return weights + @log_start_end(log=logger) def display_max_sharpe( stocks: List[str], @@ -860,6 +872,7 @@ def display_max_sharpe( ) return weights + @log_start_end(log=logger) def display_min_risk( stocks: List[str], @@ -1001,6 +1014,7 @@ def display_min_risk( ) return weights + @log_start_end(log=logger) def display_max_util( stocks: List[str], @@ -1147,6 +1161,7 @@ def display_max_util( ) return weights + @log_start_end(log=logger) def display_max_ret( stocks: List[str], @@ -1288,6 +1303,7 @@ def display_max_ret( ) return weights + @log_start_end(log=logger) def display_max_div( stocks: List[str], @@ -1400,6 +1416,7 @@ def display_max_div( console.print("") return weights + @log_start_end(log=logger) def display_max_decorr( stocks: List[str], @@ -1513,6 +1530,7 @@ def display_max_decorr( console.print("") return weights + @log_start_end(log=logger) def display_ef( stocks: List[str], @@ -1616,7 +1634,7 @@ def display_ef( risk_free_rate = risk_free_rate / time_factor[freq.upper()] # Building the portfolio object - port = rp.Portfolio(returns=stock_returns, alpha=alpha) + port = Portfolio.Portfolio(returns=stock_returns, alpha=alpha) # Estimate input parameters: port.assets_stats(method_mu="hist", method_cov="hist") @@ -1662,7 +1680,7 @@ def display_ef( for i in range(frontier.shape[1]): w = np.array(frontier.iloc[:, i], ndmin=2).T - risk = rp.Sharpe_Risk( + risk = RiskFunctions.Sharpe_Risk( w, cov=cov, returns=stock_returns, @@ -1687,7 +1705,7 @@ def display_ef( ax = external_axes[0] frontier = pd.concat([frontier, random_weights], axis=1) - ax = rp.plot_frontier( + ax = PlotFunctions.plot_frontier( w_frontier=frontier, mu=mu, cov=cov, @@ -1708,7 +1726,7 @@ def display_ef( # Add risk free line if tangency: ret_sharpe = (mu @ weights).to_numpy().item() * time_factor[freq.upper()] - risk_sharpe = rp.Sharpe_Risk( + risk_sharpe = RiskFunctions.Sharpe_Risk( weights, cov=cov, returns=stock_returns, @@ -1745,6 +1763,7 @@ def display_ef( if external_axes is None: theme.visualize_output(force_tight_layout=False) + @log_start_end(log=logger) def display_risk_parity( stocks: List[str], @@ -1898,6 +1917,7 @@ def display_risk_parity( console.print("") return weights + @log_start_end(log=logger) def display_rel_risk_parity( stocks: List[str], @@ -2038,6 +2058,7 @@ def display_rel_risk_parity( console.print("") return weights + @log_start_end(log=logger) def display_hcp( stocks: List[str], @@ -2311,6 +2332,7 @@ def display_hcp( console.print("") return weights + @log_start_end(log=logger) def display_hrp( stocks: List[str], @@ -2528,6 +2550,7 @@ def display_hrp( ) return weights + @log_start_end(log=logger) def display_herc( stocks: List[str], @@ -2753,6 +2776,7 @@ def display_herc( ) return weights + @log_start_end(log=logger) def display_nco( stocks: List[str], @@ -3092,6 +3116,7 @@ def pie_chart_weights( if external_axes is None: theme.visualize_output() + @log_start_end(log=logger) def additional_plots( weights: Dict, @@ -3196,7 +3221,7 @@ def additional_plots( else: ax = external_axes[0] - ax = rp.plot_hist( + ax = PlotFunctions.plot_hist( stock_returns, w=pd.Series(weights).to_frame(), alpha=alpha, ax=ax ) ax.legend(fontsize="x-small", loc="best") @@ -3215,7 +3240,7 @@ def additional_plots( ax = external_axes[0] nav = stock_returns.cumsum() - ax = rp.plot_drawdown( + ax = PlotFunctions.plot_drawdown( nav=nav, w=pd.Series(weights).to_frame(), alpha=alpha, ax=ax ) @@ -3239,7 +3264,7 @@ def additional_plots( else: ax = external_axes[0] - ax = rp.plot_risk_con( + ax = PlotFunctions.plot_risk_con( w=pd.Series(weights).to_frame(), cov=stock_returns.cov(), returns=stock_returns, @@ -3267,7 +3292,7 @@ def additional_plots( else: ax = external_axes[0] - ax = rp.plot_clusters( + ax = PlotFunctions.plot_clusters( returns=stock_returns, codependence="pearson", linkage="ward", diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/AuxFunctions.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/AuxFunctions.py new file mode 100644 index 00000000000..4fc4f1ed0dc --- /dev/null +++ b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/AuxFunctions.py @@ -0,0 +1,1258 @@ +"""""" # +""" +Copyright (c) 2020-2022, Dany Cajas +All rights reserved. +This work is licensed under BSD 3-Clause "New" or "Revised" License. +License available at https://github.com/dcajasn/Riskfolio-Lib/blob/master/LICENSE.txt +""" + +import numpy as np +import pandas as pd +import matplotlib as mpl +import matplotlib.pyplot as plt +import scipy.stats as st +import scipy.cluster.hierarchy as hr +from scipy import linalg as LA +from statsmodels.stats.correlation_tools import cov_nearest +from scipy.sparse import csr_matrix +from scipy.spatial.distance import pdist, squareform +from scipy.optimize import minimize +from sklearn.metrics import mutual_info_score +from sklearn.neighbors import KernelDensity +from astropy.stats import knuth_bin_width, freedman_bin_width, scott_bin_width + + +############################################################################### +# Aditional Matrix Functions +############################################################################### + + +def is_pos_def(cov, threshold=1e-8): + r""" + Indicate if a matrix is positive (semi)definite. + + Parameters + ---------- + cov : ndarray + Covariance matrix of shape (n_features, n_features), where + n_features is the number of features. + + Returns + ------- + value : bool + True if matrix is positive (semi)definite. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + cov_ = np.array(cov, ndmin=2) + w, V = LA.eigh(cov_, lower=True, check_finite=True) + value = np.all(w >= threshold) + + return value + + +def cov2corr(cov): + r""" + Generate a correlation matrix from a covariance matrix cov. + + Parameters + ---------- + cov : ndarray + Covariance matrix of shape n_features x n_features, where + n_features is the number of features. + + Returns + ------- + corr : ndarray + A correlation matrix. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + + flag = False + if isinstance(cov, pd.DataFrame): + cols = cov.columns.tolist() + flag = True + + cov1 = np.array(cov, ndmin=2) + std = np.sqrt(np.diag(cov1)) + corr = np.clip(cov1 / np.outer(std, std), a_min=-1.0, a_max=1.0) + + if flag: + corr = pd.DataFrame(corr, index=cols, columns=cols) + + return corr + + +def corr2cov(corr, std): + r""" + Generate a covariance matrix from a correlation matrix corr and a standard + deviation vector std. + + Parameters + ---------- + corr : ndarray + Assets correlation matrix of shape n_features x n_features, where + n_features is the number of features. + std : 1darray + Assets standard deviation vector of size n_features, where + n_features is the number of features. + + + Returns + ------- + cov : ndarray + A covariance matrix. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + + flag = False + if isinstance(corr, pd.DataFrame): + cols = corr.columns.tolist() + flag = True + + cov = corr * np.outer(std, std) + + if flag: + cov = pd.DataFrame(cov, index=cols, columns=cols) + + return cov + + +def cov_fix(cov, method="clipped", **kwargs): + r""" + Fix a covariance matrix to a positive definite matrix. + + Parameters + ---------- + cov : ndarray + Covariance matrix of shape n_features x n_features, where + n_features is the number of features. + method : str + The default value is 'clipped', see more in `cov_nearest `_. + **kwargs + Other parameters from `cov_nearest `_. + + Returns + ------- + cov_ : bool + A positive definite covariance matrix. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + flag = False + if isinstance(cov, pd.DataFrame): + cols = cov.columns.tolist() + flag = True + + cov_ = np.array(cov, ndmin=2) + cov_ = cov_nearest(cov_, method=method, **kwargs) + cov_ = np.array(cov_, ndmin=2) + + if flag: + cov_ = pd.DataFrame(cov_, index=cols, columns=cols) + + return cov_ + + +def cov_returns(cov, seed=0): + r""" + Generate a matrix of returns that have a covariance matrix cov. + + Parameters + ---------- + cov : ndarray + Covariance matrix of shape n_features x n_features, where + n_features is the number of features. + + Returns + ------- + a : ndarray + A matrix of returns that have a covariance matrix cov. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + + rs = np.random.RandomState(seed) + n = len(cov) + a = np.array(rs.randn(n + 10, n), ndmin=2) + + for i in range(0, 5): + cov_ = np.cov(a.T) + L = np.array(np.linalg.cholesky(cov_), ndmin=2) + a = a @ np.linalg.inv(L).T + cov_ = np.cov(a.T) + desv_ = np.sqrt(np.array(np.diag(cov_), ndmin=2)) + a = (np.array(a) - np.mean(a, axis=0)) / np.array(desv_) + + L1 = np.array(np.linalg.cholesky(cov), ndmin=2) + a = a @ L1.T + + return a + + +def commutation_matrix(cov): + r""" + Generate the commutation matrix of the covariance matrix cov. + + Parameters + ---------- + cov : ndarray + Covariance matrix of shape n_features x n_features, where + n_features is the number of features. + + Returns + ------- + K : ndarray + The commutation matrix of the covariance matrix cov. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + (m, n) = cov.shape + row = np.arange(m * n) + col = row.reshape((m, n), order="F").ravel() + data = np.ones(m * n, dtype=np.int8) + K = csr_matrix((data, (row, col)), shape=(m * n, m * n)) + K = K.toarray() + + return K + + +############################################################################### +# Aditional Codependence Functions +############################################################################### + + +def dcorr(X, Y): + r""" + Calculate the distance correlation between two variables :cite:`d-Szekely`. + + Parameters + ---------- + X : 1d-array + Returns series, must have of shape n_sample x 1. + Y : 1d-array + Returns series, must have of shape n_sample x 1. + + Returns + ------- + value : float + The distance correlation between variables X and Y. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + + X = np.atleast_1d(X) + Y = np.atleast_1d(Y) + + if np.prod(X.shape) == len(X): + X = X[:, None] + if np.prod(Y.shape) == len(Y): + Y = Y[:, None] + + X = np.atleast_2d(X) + Y = np.atleast_2d(Y) + n = X.shape[0] + + if Y.shape[0] != X.shape[0]: + raise ValueError("Number of samples must match") + + a = squareform(pdist(X)) + b = squareform(pdist(Y)) + A = a - a.mean(axis=0)[None, :] - a.mean(axis=1)[:, None] + a.mean() + B = b - b.mean(axis=0)[None, :] - b.mean(axis=1)[:, None] + b.mean() + + dcov2_xy = (A * B).sum() / float(n * n) + dcov2_xx = (A * A).sum() / float(n * n) + dcov2_yy = (B * B).sum() / float(n * n) + value = np.sqrt(dcov2_xy) / np.sqrt(np.sqrt(dcov2_xx) * np.sqrt(dcov2_yy)) + + return value + + +def dcorr_matrix(X): + r""" + Calculate the distance correlation matrix of n variables. + + Parameters + ---------- + X : ndarray + Returns series of shape n_sample x n_features. + + Returns + ------- + corr : ndarray + The distance correlation matrix of shape n_features x n_features. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + flag = False + if isinstance(X, pd.DataFrame): + cols = X.columns.tolist() + X1 = X.to_numpy() + flag = True + else: + X1 = X.copy() + + n = X1.shape[1] + corr = np.ones((n, n)) + indices = np.triu_indices(n, 1) + + for i, j in zip(indices[0], indices[1]): + corr[i, j] = dcorr(X1[:, i], X1[:, j]) + corr[j, i] = corr[i, j] + + if flag: + corr = pd.DataFrame(corr, index=cols, columns=cols) + else: + corr = pd.DataFrame(corr) + + return corr + + +def numBins(n_samples, corr=None): + r""" + Calculate the optimal number of bins for discretization of mutual + information and variation of information. + + Parameters + ---------- + n_samples : integer + Number of samples. + + corr : float, optional + Correlation coefficient of variables. The default value is None. + + Returns + ------- + bins : int + The optimal number of bins. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + # univariate case + if corr is None: + z = ( + 8 + 324 * n_samples + 12 * (36 * n_samples + 729 * n_samples**2) ** 0.5 + ) ** (1 / 3) + b = np.round(z / 6 + 2 / (3 * z) + 1 / 3) + # bivariate case + else: + b = np.round( + 2**-0.5 * (1 + (1 + 24 * n_samples / (1 - corr**2)) ** 0.5) ** 0.5 + ) + + bins = np.int32(b) + + return bins + + +def mutual_info_matrix(X, bins_info="KN", normalize=True): + r""" + Calculate the mutual information matrix of n variables. + + Parameters + ---------- + X : ndarray + Returns series of shape n_sample x n_features. + bins_info: int or str + Number of bins used to calculate mutual information. The default + value is 'KN'. Posible values are: + + - 'KN': Knuth's choice method. See more in `knuth_bin_width `_. + - 'FD': Freedman–Diaconis' choice method. See more in `freedman_bin_width `_. + - 'SC': Scotts' choice method. See more in `scott_bin_width `_. + - 'HGR': Hacine-Gharbi and Ravier' choice method. + - int: integer value choice by user. + + normalize: bool + If normalize variation of information. The default value is True. + + Returns + ------- + corr : ndarray + The mutual information matrix of shape n_features x n_features. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + flag = False + if isinstance(X, pd.DataFrame): + cols = X.columns.tolist() + X1 = X.to_numpy() + flag = True + else: + X1 = X.copy() + + m = X1.shape[0] + n = X1.shape[1] + mat = np.zeros((n, n)) + indices = np.triu_indices(n) + + for i, j in zip(indices[0], indices[1]): + if bins_info == "KN": + k1 = (np.max(X1[:, i]) - np.min(X1[:, i])) / knuth_bin_width(X1[:, i]) + bins = np.int32(np.round(k1)) + if i != j: + k2 = (np.max(X1[:, j]) - np.min(X1[:, j])) / knuth_bin_width(X1[:, j]) + bins = np.int32(np.round(np.maximum(k1, k2))) + elif bins_info == "FD": + k1 = (np.max(X1[:, i]) - np.min(X1[:, i])) / freedman_bin_width(X1[:, i]) + bins = np.int32(np.round(k1)) + if i != j: + k2 = (np.max(X1[:, j]) - np.min(X1[:, j])) / freedman_bin_width( + X1[:, j] + ) + bins = np.int32(np.round(np.maximum(k1, k2))) + elif bins_info == "SC": + k1 = (np.max(X1[:, i]) - np.min(X1[:, i])) / scott_bin_width(X1[:, i]) + bins = np.int32(np.round(k1)) + if i != j: + k2 = (np.max(X1[:, j]) - np.min(X1[:, j])) / scott_bin_width(X1[:, j]) + bins = np.int32(np.round(np.maximum(k1, k2))) + elif bins_info == "HGR": + corr = np.corrcoef(X1[:, i], X1[:, j])[0, 1] + if corr == 1: + bins = numBins(m, None) + else: + bins = numBins(m, corr) + elif isinstance(bins_info, np.int32) or isinstance(bins_info, int): + bins = bins_info + + cXY = np.histogram2d(X1[:, i], X1[:, j], bins)[0] + hX = st.entropy(np.histogram(X1[:, i], bins)[0]) # marginal + hY = st.entropy(np.histogram(X1[:, j], bins)[0]) # marginal + iXY = mutual_info_score(None, None, contingency=cXY) # mutual information + if normalize == True: + iXY = iXY / np.min([hX, hY]) # normalized mutual information + # hXY = hX + hY - iXY # joint + # hX_Y = hXY - hY # conditional + # hY_X = hXY - hX # conditional + + mat[i, j] = iXY + mat[j, i] = mat[i, j] + + mat = np.clip(np.round(mat, 8), a_min=0.0, a_max=np.inf) + + if flag: + mat = pd.DataFrame(mat, index=cols, columns=cols) + else: + mat = pd.DataFrame(mat) + + return mat + + +def var_info_matrix(X, bins_info="KN", normalize=True): + r""" + Calculate the variation of information matrix of n variables. + + Parameters + ---------- + X : ndarray + Returns series of shape n_sample x n_features. + bins_info: int or str + Number of bins used to calculate variation of information. The default + value is 'KN'. Posible values are: + + - 'KN': Knuth's choice method. See more in `knuth_bin_width `_. + - 'FD': Freedman–Diaconis' choice method. See more in `freedman_bin_width `_. + - 'SC': Scotts' choice method. See more in `scott_bin_width `_. + - 'HGR': Hacine-Gharbi and Ravier' choice method. + - int: integer value choice by user. + + normalize: bool + If normalize variation of information. The default value is True. + + Returns + ------- + corr : ndarray + The mutual information matrix of shape n_features x n_features. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + flag = False + if isinstance(X, pd.DataFrame): + cols = X.columns.tolist() + X1 = X.to_numpy() + flag = True + else: + X1 = X.copy() + + m = X1.shape[0] + n = X1.shape[1] + mat = np.zeros((n, n)) + indices = np.triu_indices(n) + + for i, j in zip(indices[0], indices[1]): + if bins_info == "KN": + k1 = (np.max(X1[:, i]) - np.min(X1[:, i])) / knuth_bin_width(X1[:, i]) + bins = np.int32(np.round(k1)) + if i != j: + k2 = (np.max(X1[:, j]) - np.min(X1[:, j])) / knuth_bin_width(X1[:, j]) + bins = np.int32(np.round(np.maximum(k1, k2))) + elif bins_info == "FD": + k1 = (np.max(X1[:, i]) - np.min(X1[:, i])) / freedman_bin_width(X1[:, i]) + bins = np.int32(np.round(k1)) + if i != j: + k2 = (np.max(X1[:, j]) - np.min(X1[:, j])) / freedman_bin_width( + X1[:, j] + ) + bins = np.int32(np.round(np.maximum(k1, k2))) + elif bins_info == "SC": + k1 = (np.max(X1[:, i]) - np.min(X1[:, i])) / scott_bin_width(X1[:, i]) + bins = np.int32(np.round(k1)) + if i != j: + k2 = (np.max(X1[:, j]) - np.min(X1[:, j])) / scott_bin_width(X1[:, j]) + bins = np.int32(np.round(np.maximum(k1, k2))) + elif bins_info == "HGR": + corr = np.corrcoef(X1[:, i], X1[:, j])[0, 1] + if corr == 1: + bins = numBins(m, None) + else: + bins = numBins(m, corr) + elif isinstance(bins_info, np.int32) or isinstance(bins_info, int): + bins = bins_info + + cXY = np.histogram2d(X1[:, i], X1[:, j], bins)[0] + hX = st.entropy(np.histogram(X1[:, i], bins)[0]) # marginal + hY = st.entropy(np.histogram(X1[:, j], bins)[0]) # marginal + iXY = mutual_info_score(None, None, contingency=cXY) # mutual information + vXY = hX + hY - 2 * iXY # variation of information + if normalize == True: + hXY = hX + hY - iXY # joint + vXY = vXY / hXY # normalized variation of information + + mat[i, j] = vXY + mat[j, i] = mat[i, j] + + mat = np.clip(np.round(mat, 8), a_min=0.0, a_max=np.inf) + + if flag: + mat = pd.DataFrame(mat, index=cols, columns=cols) + else: + mat = pd.DataFrame(mat) + + return mat + + +def ltdi_matrix(X, alpha=0.05): + r""" + Calculate the lower tail dependence index matrix using the empirical + approach. + + Parameters + ---------- + X : ndarray + Returns series of shape n_sample x n_features. + alpha : float, optional + Significance level for lower tail dependence index. + The default is 0.05. + + Returns + ------- + corr : ndarray + The lower tail dependence index matrix of shape n_features x + n_features. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + + flag = False + if isinstance(X, pd.DataFrame): + cols = X.columns.tolist() + X1 = X.to_numpy() + flag = True + else: + X1 = X.copy() + + m = X1.shape[0] + n = X1.shape[1] + k = np.int(np.ceil(m * alpha)) + mat = np.ones((n, n)) + + if k > 0: + indices = np.triu_indices(n) + + for i, j in zip(indices[0], indices[1]): + u = np.sort(X1[:, i])[k - 1] + v = np.sort(X1[:, j])[k - 1] + ltd = ( + np.sum(np.where(np.logical_and(X1[:, i] <= u, X1[:, j] <= v), 1, 0)) / k + ) + + mat[i, j] = ltd + mat[j, i] = mat[i, j] + + for i in range(0, n): + u = np.sort(X1[:, i])[k - 1] + v = np.sort(X1[:, i])[k - 1] + ltd = ( + np.sum(np.where(np.logical_and(X1[:, i] <= u, X1[:, i] <= v), 1, 0)) / k + ) + + mat[i, i] = ltd + + mat = np.clip(np.round(mat, 8), a_min=1.0e-8, a_max=1) + + if flag: + mat = pd.DataFrame(mat, index=cols, columns=cols) + else: + mat = pd.DataFrame(mat) + + return mat + + +def two_diff_gap_stat(codep, dist, clusters, max_k=10): + r""" + Calculate the optimal number of clusters based on the two difference gap + statistic :cite:`d-twogap`. + + Parameters + ---------- + codep : DataFrame + A codependence matrix. + dist : str, optional + A distance measure based on the codependence matrix. + clusters : str, optional + The hierarchical clustering encoded as a linkage matrix, see `linkage `_ for more details. + max_k : int, optional + Max number of clusters used by the two difference gap statistic + to find the optimal number of clusters. The default is 10. + + Returns + ------- + k : int + The optimal number of clusters based on the two difference gap statistic. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + # cluster levels over from 1 to N-1 clusters + cluster_lvls = pd.DataFrame(hr.cut_tree(clusters), index=codep.columns) + num_k = cluster_lvls.columns # save column with number of clusters + cluster_lvls = cluster_lvls.iloc[:, ::-1] # reverse order to start with 1 cluster + cluster_lvls.columns = num_k # set columns to number of cluster + W_list = [] + + # get within-cluster dissimilarity for each k + for k in range(min(len(cluster_lvls.columns), max_k)): + level = cluster_lvls.iloc[:, k] # get k clusters + D_list = [] # within-cluster distance list + + for i in range(np.max(level.unique()) + 1): + cluster = level.loc[level == i] + # Based on correlation distance + cluster_dist = dist.loc[cluster.index, cluster.index] # get distance + cluster_pdist = squareform(cluster_dist, checks=False) + if cluster_pdist.shape[0] != 0: + D = np.nan_to_num(cluster_pdist.mean()) + D_list.append(D) # append to list + + W_k = np.sum(D_list) + W_list.append(W_k) + + W_list = pd.Series(W_list) + n = codep.shape[0] + limit_k = int(min(max_k, np.sqrt(n))) + gaps = W_list.shift(2) + W_list - 2 * W_list.shift(1) + gaps = gaps[0:limit_k] + if gaps.isna().all(): + k = len(gaps) + else: + k = int(gaps.idxmax() + 2) + + return k + + +############################################################################### +# Denoising Functions Based on Lopez de Prado Book +############################################################################### + + +def fitKDE(obs, bWidth=0.01, kernel="gaussian", x=None): + """ + Fit kernel to a series of obs, and derive the prob of obs x is the array of + values on which the fit KDE will be evaluated. It is the empirical Probability + Density Function (PDF). For more information see chapter 2 of :cite:`d-MLforAM`. + + Parameters + ---------- + obs : ndarray + Observations to fit. Commonly is the diagonal of Eigenvalues. + bWidth : float, optional + The bandwidth of the kernel. The default value is 0.01. + kernel : string, optional + The kernel to use. The default value is 'gaussian'. For more information see: `kernel-density `_. + Posible values are: + + - 'gaussian': gaussian kernel. + - 'tophat': tophat kernel. + - 'epanechnikov': epanechnikov kernel. + - 'exponential': exponential kernel. + - 'linear': linear kernel. + - 'cosine': cosine kernel. + + x : ndarray, optional + It is the array of values on which the fit KDE will be evaluated. + + Returns + ------- + pdf : pd.series + Empirical PDF. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + + if len(obs.shape) == 1: + obs = obs.reshape(-1, 1) + + kde = KernelDensity(kernel=kernel, bandwidth=bWidth).fit(obs) + + if x is None: + x = np.unique(obs).reshape(-1, 1) + + if len(x.shape) == 1: + x = x.reshape(-1, 1) + + logProb = kde.score_samples(x) # log(density) + pdf = pd.Series(np.exp(logProb), index=x.flatten()) + + return pdf + + +def mpPDF(var, q, pts): + r""" + Creates a Marchenko-Pastur Probability Density Function (PDF). For more + information see chapter 2 of :cite:`d-MLforAM`. + + Parameters + ---------- + var : float + Variance. + q : float + T/N where T is the number of rows and N the number of columns + pts : int + Number of points used to construct the PDF. + + Returns + ------- + pdf : pd.series + Marchenko-Pastur PDF. + + Raises + ------ + ValueError when the value cannot be calculated. + + """ + + if isinstance(var, np.ndarray): + if var.shape == (1,): + var = var[0] + + eMin, eMax = var * (1 - (1.0 / q) ** 0.5) ** 2, var * (1 + (1.0 / q) ** 0.5) ** 2 + eVal = np.linspace(eMin, eMax, pts) + pdf = q / (2 * np.pi * var * eVal) * ((eMax - eVal) * (eVal - eMin)) ** 0.5 + pdf = pd.Series(pdf, index=eVal) + + return pdf + + +def errPDFs(var, eVal, q, bWidth=0.01, pts=1000): + r""" + Fit error of Empirical PDF (uses Marchenko-Pastur PDF). For more information + see chapter 2 of :cite:`d-MLforAM`. + + Parameters + ---------- + var : float + Variance. + eVal : ndarray + Eigenvalues to fit. + q : float + T/N where T is the number of rows and N the number of columns. + bWidth : float, optional + The bandwidth of the kernel. The default value is 0.01. + pts : int + Number of points used to construct the PDF. The default value is 1000. + + Returns + ------- + pdf : float + Sum squared error. + + Raises + ------ + ValueError when the value cannot be calculated. + """ + + # Fit error + pdf0 = mpPDF(var, q, pts) # theoretical pdf + pdf1 = fitKDE(eVal, bWidth, x=pdf0.index.values) # empirical pdf + sse = np.sum((pdf1 - pdf0) ** 2) + + return sse + + +def findMaxEval(eVal, q, bWidth=0.01): + r""" + Find max random eVal by fitting Marchenko’s dist (i.e) everything else + larger than this, is a signal eigenvalue. For more information see chapter + 2 of :cite:`d-MLforAM`. + + Parameters + ---------- + eVal : ndarray + Eigenvalues to fit. + q : float + T/N where T is the number of rows and N the number of columns. + bWidth : float, optional + The bandwidth of the kernel. + + Returns + ------- + pdf : tuple (float, float) + First value is the maximum random eigenvalue and second is the variance + attributed to noise (1-result) is one way to measure signal-to-noise. + + Raises + ------ + ValueError when the value cannot be calculated. + """ + + out = minimize( + lambda *x: errPDFs(*x), 0.5, args=(eVal, q, bWidth), bounds=((1e-5, 1 - 1e-5),) + ) + + if out["success"]: + var = out["x"][0] + else: + var = 1 + + eMax = var * (1 + (1.0 / q) ** 0.5) ** 2 + + return eMax, var + + +def getPCA(matrix): + r""" + Gets the Eigenvalues and Eigenvector values from a Hermitian Matrix. + For more information see chapter 2 of :cite:`d-MLforAM`. + + Parameters + ---------- + matrix : ndarray or pd.DataFrame + Correlation matrix. + + Returns + ------- + pdf : tuple (float, float) + First value are the eigenvalues of correlation matrix and second are + the Eigenvectors of correlation matrix. + + Raises + ------ + ValueError when the value cannot be calculated. + """ + + # Get eVal,eVec from a Hermitian matrix + eVal, eVec = np.linalg.eigh(matrix) + indices = eVal.argsort()[::-1] # arguments for sorting eVal desc + eVal, eVec = eVal[indices], eVec[:, indices] + eVal = np.diagflat(eVal) + + return eVal, eVec + + +def denoisedCorr(eVal, eVec, nFacts, kind="fixed"): + r""" + Remove noise from correlation matrix using fixing random eigenvalues and + spectral method. For more information see chapter 2 of :cite:`d-MLforAM`. + + Parameters + ---------- + eVal : 1darray + Eigenvalues. + eVal : ndarray + Eigenvectors. + nFacts : float + The number of factors. + kind : str, optional + The denoise method. The default value is 'fixed'. Posible values are: + + - 'fixed': takes average of eigenvalues above max Marchenko Pastour limit. + - 'spectral': makes zero eigenvalues above max Marchenko Pastour limit. + + Returns + ------- + corr : ndarray + Denoised correlation matrix. + + Raises + ------ + ValueError when the value cannot be calculated. + """ + + eVal_ = np.diag(eVal).copy() + + if kind == "fixed": + eVal_[nFacts:] = eVal_[nFacts:].sum() / float(eVal_.shape[0] - nFacts) + elif kind == "spectral": + eVal_[nFacts:] = 0 + + eVal_ = np.diag(eVal_) + corr = np.dot(eVec, eVal_).dot(eVec.T) + corr = cov2corr(corr) + + return corr + + +def shrinkCorr(eVal, eVec, nFacts, alpha=0): + r""" + Remove noise from correlation using target shrinkage. For more information + see chapter 2 of :cite:`d-MLforAM`. + + Parameters + ---------- + eVal : 1darray + Eigenvalues. + eVal : ndarray + Eigenvectors. + nFacts : float + The number of factors. + alpha : float, optional + Shrinkage factor. + + Returns + ------- + corr : ndarray + Denoised correlation matrix. + + Raises + ------ + ValueError when the value cannot be calculated. + """ + + eVal_L = eVal[:nFacts, :nFacts] + eVec_L = eVec[:, :nFacts] + eVal_R = eVal[nFacts:, nFacts:] + eVec_R = eVec[:, nFacts:] + corr0 = np.dot(eVec_L, eVal_L).dot(eVec_L.T) + corr1 = np.dot(eVec_R, eVal_R).dot(eVec_R.T) + corr2 = corr0 + alpha * corr1 + (1 - alpha) * np.diag(np.diag(corr1)) + + return corr2 + + +def denoiseCov(cov, q, kind="fixed", bWidth=0.01, detone=False, mkt_comp=1, alpha=0): + r""" + Remove noise from cov by fixing random eigenvalues of their correlation + matrix. For more information see chapter 2 of :cite:`d-MLforAM`. + + Parameters + ---------- + cov : ndarray or pd.DataFrame + Covariance matrix of shape n_features x n_features, where + n_features is the number of features. + q : float + T/N where T is the number of rows and N the number of columns. + bWidth : float + The bandwidth of the kernel. + kind : str, optional + The denoise method. The default value is 'fixed'. Posible values are: + + - 'fixed': takes average of eigenvalues above max Marchenko Pastour limit. + - 'spectral': makes zero eigenvalues above max Marchenko Pastour limit. + - 'shrink': uses target shrinkage method. + + detone : bool, optional + If remove the firs mkt_comp of correlation matrix. The detone correlation + matrix is singular, so it cannot be inverted. + mkt_comp : float, optional + Number of first components that will be removed using the detone method. + alpha : float, optional + Shrinkage factor. + + Returns + ------- + cov_ : ndarray or pd.DataFrame + Denoised covariance matrix. + + Raises + ------ + ValueError when the value cannot be calculated. + """ + + flag = False + if isinstance(cov, pd.DataFrame): + cols = cov.columns.tolist() + flag = True + + corr = cov2corr(cov) + std = np.diag(cov) ** 0.5 + eVal, eVec = getPCA(corr) + eMax, var = findMaxEval(np.diag(eVal), q, bWidth) + nFacts = eVal.shape[0] - np.diag(eVal)[::-1].searchsorted(eMax) + + if kind in ["fixed", "spectral"]: + corr = denoisedCorr(eVal, eVec, nFacts, kind=kind) + elif kind == "shrink": + corr = shrinkCorr(eVal, eVec, nFacts, alpha=alpha) + + if detone == True: + eVal_ = eVal[:mkt_comp, :mkt_comp] + eVec_ = eVec[:, :mkt_comp] + corr_ = np.dot(eVec_, eVal_).dot(eVec_.T) + corr = corr - corr_ + + cov_ = corr2cov(corr, std) + + if flag: + cov_ = pd.DataFrame(cov_, index=cols, columns=cols) + + return cov_ + + +############################################################################### +# Other Aditional Functions +############################################################################### + + +def round_values(data, decimals=4, wider=False): + r""" + This function help us to round values to values close or away from zero. + + Parameters + ---------- + data : np.ndarray, pd.Series or pd.DataFrame + Data that are going to be rounded. + decimals : integer + Number of decimals to round. + wider : float + False if round to values close to zero, True if round to values away + from zero. + + Returns + ------- + value : np.ndarray, pd.Series or pd.DataFrame + Data rounded using selected method. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + + if wider == True: + value = np.where( + data >= 0, + np.ceil(data * 10**decimals) / 10**decimals, + np.floor(data * 10**decimals) / 10**decimals, + ) + elif wider == False: + value = np.where( + data >= 0, + np.floor(data * 10**decimals) / 10**decimals, + np.ceil(data * 10**decimals) / 10**decimals, + ) + + if isinstance(data, pd.DataFrame): + value = pd.DataFrame(value, columns=data.columns, index=data.index) + if isinstance(data, pd.Series): + value = pd.Series(value, index=data.index) + + return value + + +def weights_discretizetion( + weights, prices, capital=1000000, w_decimal=6, ascending=False +): + r""" + This function help us to find the number of shares that must be bought or + sold to achieve portfolio weights according the prices of assets and the + invested capital. + + Parameters + ---------- + weights : pd.Series or pd.DataFrame + Vector of weights of size n_assets x 1. + prices : pd.Series or pd.DataFrame + Vector of prices of size n_assets x 1. + capital : float, optional + Capital invested. The default value is 1000000. + w_decimal : int, optional + Number of decimals use to round the portfolio weights. The default + value is 6. + ascending : bool, optional + If True assigns excess capital to assets with lower weights, else, + to assets with higher weights. The default value is False. + + Returns + ------- + n_shares : pd.DataFrame + Number of shares that must be bought or sold to achieve portfolio + weights. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + + if isinstance(weights, pd.Series): + w = weights.to_frame().copy() + elif isinstance(weights, pd.DataFrame): + if weights.shape[0] == 1: + w = weights.T.copy() + elif weights.shape[1] == 1: + w = weights.copy() + pass + else: + raise ValueError("weights must have size n_assets x 1") + else: + raise ValueError("weights must be DataFrame") + + if isinstance(prices, pd.Series): + p = prices.to_frame().copy() + elif isinstance(prices, pd.DataFrame): + if prices.shape[0] == 1: + p = prices.T.copy() + elif prices.shape[1] == 1: + p = prices.copy() + pass + else: + raise ValueError("prices must have size n_assets x 1") + else: + raise ValueError("prices must be DataFrame") + + w.columns = [0] + p.columns = [0] + + total = w.sum().item() + w = round_values(w, decimals=w_decimal, wider=False) + w.loc[w.idxmin().tolist()] = w.loc[w.idxmin().tolist()] + (total - w.sum()).item() + + n_shares = round_values(capital * w / p, decimals=0, wider=False) + + excedent = [capital + 1, capital] + i = 1 + while excedent[i] < excedent[i - 1]: + new_capital = (n_shares.T @ p).iloc[0, 0] + excedent.append(capital - new_capital) + new_shares = round_values(excedent[-1] * w / p, 0) + n_shares += new_shares + i += 1 + + n_shares_1 = capital * w / p + + excedent = capital - (n_shares.T @ p).iloc[0, 0] + i = 1 + + d_shares = np.abs(n_shares_1) - np.abs(n_shares) + d_shares = np.where(d_shares > 0, n_shares_1 - n_shares, 0) + d_shares = round_values(d_shares, decimals=0, wider=True) + d_shares = pd.DataFrame(d_shares, columns=w.columns, index=w.index) + + n_shares_1 = capital * w / p + + excedent = capital - (n_shares.T @ p).iloc[0, 0] + + d_shares = np.abs(n_shares_1) - np.abs(n_shares) + d_shares = np.where(d_shares > 0, n_shares_1 - n_shares, 0) + d_shares = round_values(d_shares, decimals=0, wider=True) + d_shares = pd.DataFrame(d_shares, columns=w.columns, index=w.index) + + order = w.sort_values(by=0, ascending=ascending).index.tolist() + d_list = d_shares[d_shares[0] == 1].index.tolist() + + for i in order: + if i in d_list: + new_shares = round_values(excedent / p.loc[i, 0], 0).item() + if new_shares > 0: + n_shares.loc[i] += new_shares + excedent = capital - (n_shares.T @ p).iloc[0, 0] + + return n_shares + + +def color_list(k): + r""" + This function creates a list of colors. + + Parameters + ---------- + k : int + Number of colors. + + Returns + ------- + colors : list + A list of colors. + """ + + colors = [] + + if k <= 10: + for i in range(10): + colors.append(mpl.colors.rgb2hex(plt.get_cmap("tab10").colors[i])) + elif k <= 20: + for i in range(20): + colors.append(mpl.colors.rgb2hex(plt.get_cmap("tab20").colors[i])) + elif k <= 40: + for i in range(20): + colors.append(mpl.colors.rgb2hex(plt.get_cmap("tab20").colors[i])) + for i in range(20): + colors.append(mpl.colors.rgb2hex(plt.get_cmap("tab20b").colors[i])) + else: + for i in range(20): + colors.append(mpl.colors.rgb2hex(plt.get_cmap("tab20").colors[i])) + for i in range(20): + colors.append(mpl.colors.rgb2hex(plt.get_cmap("tab20b").colors[i])) + for i in range(20): + colors.append(mpl.colors.rgb2hex(plt.get_cmap("tab20c").colors[i])) + if k / 60 > 1: + colors = colors * int(np.ceil(k / 60)) + + return colors diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/ConstraintsFunctions.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/ConstraintsFunctions.py new file mode 100644 index 00000000000..e4c0fcca4ba --- /dev/null +++ b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/ConstraintsFunctions.py @@ -0,0 +1,964 @@ +"""""" # +""" +Copyright (c) 2020-2022, Dany Cajas +All rights reserved. +This work is licensed under BSD 3-Clause "New" or "Revised" License. +License available at https://github.com/dcajasn/Riskfolio-Lib/blob/master/LICENSE.txt +""" + +import numpy as np +import pandas as pd +import scipy.cluster.hierarchy as hr +from scipy.spatial.distance import squareform +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.AuxFunctions as af +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.DBHT as db + + +def assets_constraints(constraints, asset_classes): + r""" + Create the linear constraints matrixes A and B of the constraint + :math:`Aw \geq B`. + + Parameters + ---------- + constraints : DataFrame of shape (n_constraints, n_fields) + Constraints matrix, where n_constraints is the number of constraints + and n_fields is the number of fields of constraints matrix, the fields + are: + + - Disabled: (bool) indicates if the constraint is enable. + - Type: (str) can be: 'Assets', 'Classes', 'All Assets', 'Each asset in a class' and 'All Classes'. + - Set: (str) if Type is 'Classes', 'Each asset in a class' or 'All Classes'specified the name of the asset's classes set. + - Position: (str) the name of the asset or asset class of the constraint. + - Sign: (str) can be '>=' or '<='. + - Weight: (scalar) is the maximum or minimum weight of the absolute constraint. + - Type Relative: (str) can be: 'Assets' or 'Classes'. + - Relative Set: (str) if Type Relative is 'Classes' specified the name of the set of asset classes. + - Relative: (str) the name of the asset or asset class of the relative constraint. + - Factor: (scalar) is the factor of the relative constraint. + + asset_classes : DataFrame of shape (n_assets, n_cols) + Asset's classes matrix, where n_assets is the number of assets and + n_cols is the number of columns of the matrix where the first column + is the asset list and the next columns are the different asset's + classes sets. + + Returns + ------- + A : nd-array + The matrix A of :math:`Aw \geq B`. + + B : nd-array + The matrix B of :math:`Aw \geq B`. + + Raises + ------ + ValueError when the value cannot be calculated. + + Examples + -------- + :: + + import riskfolio as rp + + asset_classes = {'Assets': ['FB', 'GOOGL', 'NTFX', 'BAC', 'WFC', 'TLT', 'SHV'], + 'Class 1': ['Equity', 'Equity', 'Equity', 'Equity', 'Equity', + 'Fixed Income', 'Fixed Income'], + 'Class 2': ['Technology', 'Technology', 'Technology', + 'Financial', 'Financial', 'Treasury', 'Treasury'],} + + asset_classes = pd.DataFrame(asset_classes) + asset_classes = asset_classes.sort_values(by=['Assets']) + + constraints = {'Disabled': [False, False, False, False, False, False, False], + 'Type': ['Classes', 'Classes', 'Assets', 'Assets', 'Classes', + 'All Assets', 'Each asset in a class'], + 'Set': ['Class 1', 'Class 1', '', '', 'Class 2', '', 'Class 1'], + 'Position': ['Equity', 'Fixed Income', 'BAC', 'WFC', 'Financial', + '', 'Equity'], + 'Sign': ['<=', '<=', '<=', '<=', '>=', '>=', '>='], + 'Weight': [0.6, 0.5, 0.1, '', '', 0.02, ''], + 'Type Relative': ['', '', '', 'Assets', 'Classes', '', 'Assets'], + 'Relative Set': ['', '', '', '', 'Class 1', '', ''], + 'Relative': ['', '', '', 'FB', 'Fixed Income', '', 'TLT'], + 'Factor': ['', '', '', 1.2, 0.5, '', 0.4]} + + constraints = pd.DataFrame(constraints) + + + The constraint looks like this: + + .. image:: images/Constraints.png + + It is easier to construct the constraints in excel and then upload to a + dataframe. + + To create the matrixes A and B we use the following command: + + :: + + A, B = rp.assets_constraints(constraints, asset_classes) + + + The matrixes A and B looks like this (all constraints were converted to a linear + constraint): + + .. image:: images/AxB.png + + """ + + if not isinstance(constraints, pd.DataFrame) and not isinstance( + asset_classes, pd.DataFrame + ): + raise ValueError("constraints and asset_classes must be DataFrames") + + if constraints.shape[1] != 10: + raise ValueError("constraints must have ten columns") + + n = len(constraints) + m = len(asset_classes) + data = constraints.fillna("") + data = data.values.tolist() + assetslist = asset_classes.iloc[:, 0].values.tolist() + + A = [] + B = [] + for i in range(0, n): + if data[i][0] == False: + if data[i][1] == "Assets": + item = assetslist.index(data[i][3]) + if data[i][4] == ">=": + d = 1 + elif data[i][4] == "<=": + d = -1 + if data[i][5] != "": + A1 = [0] * m + A1[item] = d + A.append(A1) + B.append([data[i][5] * d]) + else: + A1 = [0] * m + A1[item] = 1 + if data[i][6] == "Assets": + item2 = assetslist.index(data[i][8]) + A2 = [0] * m + A2[item2] = 1 + elif data[i][6] == "Classes": + A2 = np.where( + asset_classes[data[i][7]].values == data[i][8], 1, 0 + ) + A1 = ((np.array(A1) + np.array(A2) * data[i][9] * -1) * d).tolist() + A.append(A1) + B.append([0]) + elif data[i][1] == "All Assets": + item = len(assetslist) + if data[i][4] == ">=": + d = 1 + elif data[i][4] == "<=": + d = -1 + if data[i][5] != "": + A1 = np.identity(item) * d + A1 = A1.tolist() + B1 = np.ones((item, 1)) * d * data[i][5] + for i in range(0, item): + A.append(A1[i]) + B.append(B1.tolist()[0]) + else: + A1 = np.identity(item) + if data[i][6] == "Assets": + item2 = assetslist.index(data[i][8]) + A2 = np.zeros((item, item - 1)) + A2 = np.insert(A2, item2 - 1, 1, axis=1) + elif data[i][6] == "Classes": + A1 = np.identity(item) + A2 = np.where( + asset_classes[data[i][7]].values == data[i][8], 1, 0 + ) + A2 = np.ones((item, item)) * np.array(A2) + A1 = ((np.array(A1) + np.array(A2) * data[i][9] * -1) * d).tolist() + for i in range(0, item): + A.append(A1[i]) + B.append([0]) + elif data[i][1] == "Classes": + if data[i][4] == ">=": + d = 1 + elif data[i][4] == "<=": + d = -1 + if data[i][5] != "": + A1 = np.where(asset_classes[data[i][2]].values == data[i][3], 1, 0) + A1 = np.array(A1) * d + A1 = A1.tolist() + A.append(A1) + B.append([data[i][5] * d]) + else: + A1 = np.where(asset_classes[data[i][2]].values == data[i][3], 1, 0) + if data[i][6] == "Assets": + item2 = assetslist.index(data[i][8]) + A2 = [0] * m + A2[item2] = 1 + elif data[i][6] == "Classes": + A2 = np.where( + asset_classes[data[i][7]].values == data[i][8], 1, 0 + ) + A1 = ((np.array(A1) + np.array(A2) * data[i][9] * -1) * d).tolist() + A.append(A1) + B.append([0]) + elif data[i][1] == "Each asset in a class": + if data[i][4] == ">=": + d = 1 + elif data[i][4] == "<=": + d = -1 + if data[i][5] != "": + A1 = np.where(asset_classes[data[i][2]].values == data[i][3], 1, 0) + l = 0 + for k in A1: + if k == 1: + A3 = [0] * m + A3[l] = 1 * d + A.append(A3) + B.append([data[i][5] * d]) + l = l + 1 + else: + A1 = np.where(asset_classes[data[i][2]].values == data[i][3], 1, 0) + l = 0 + for k in A1: + if k == 1: + A3 = [0] * m + A3[l] = 1 + if data[i][6] == "Assets": + item2 = assetslist.index(data[i][8]) + A2 = [0] * m + A2[item2] = 1 + elif data[i][6] == "Classes": + A2 = np.where( + asset_classes[data[i][7]].values == data[i][8], 1, 0 + ) + A3 = ( + (np.array(A3) + np.array(A2) * data[i][9] * -1) * d + ).tolist() + A.append(A3) + B.append([0]) + l = l + 1 + elif data[i][1] == "All Classes": + if data[i][4] == ">=": + d = 1 + elif data[i][4] == "<=": + d = -1 + if data[i][5] != "": + for k in np.unique(asset_classes[data[i][2]].values): + A1 = np.where(asset_classes[data[i][2]].values == k, 1, 0) * d + A1 = A1.tolist() + A.append(A1) + B.append([data[i][5] * d]) + else: + for k in np.unique(asset_classes[data[i][2]].values): + A1 = np.where(asset_classes[data[i][2]].values == k, 1, 0) + if data[i][6] == "Assets": + item2 = assetslist.index(data[i][8]) + A2 = [0] * m + A2[item2] = 1 + elif data[i][6] == "Classes": + A2 = np.where( + asset_classes[data[i][7]].values == data[i][8], 1, 0 + ) + A3 = ( + (np.array(A1) + np.array(A2) * data[i][9] * -1) * d + ).tolist() + A.append(A3) + B.append([0]) + + A = np.array(A, ndmin=2) + B = np.array(B, ndmin=2) + + return A, B + + +def factors_constraints(constraints, loadings): + r""" + Create the factors constraints matrixes C and D of the constraint + :math:`Cw \geq D`. + + Parameters + ---------- + constraints : DataFrame of shape (n_constraints, n_fields) + Constraints matrix, where n_constraints is the number of constraints + and n_fields is the number of fields of constraints matrix, the fields + are: + + - Disabled: (bool) indicates if the constraint is enable. + - Factor: (str) the name of the factor of the constraint. + - Sign: (str) can be '>=' or '<='. + - Value: (scalar) is the maximum or minimum value of the factor. + + loadings : DataFrame of shape (n_assets, n_features) + The loadings matrix. + + Returns + ------- + C : nd-array + The matrix C of :math:`Cw \geq D`. + + D : nd-array + The matrix D of :math:`Cw \geq D`. + + Raises + ------ + ValueError when the value cannot be calculated. + + Examples + -------- + :: + + loadings = {'const': [0.0004, 0.0002, 0.0000, 0.0006, 0.0001, 0.0003, -0.0003], + 'MTUM': [0.1916, 1.0061, 0.8695, 1.9996, 0.0000, 0.0000, 0.0000], + 'QUAL': [0.0000, 2.0129, 1.4301, 0.0000, 0.0000, 0.0000, 0.0000], + 'SIZE': [0.0000, 0.0000, 0.0000, 0.4717, 0.0000, -0.1857, 0.0000], + 'USMV': [-0.7838, -1.6439, -1.0176, -1.4407, 0.0055, 0.5781, 0.0000], + 'VLUE': [1.4772, -0.7590, -0.4090, 0.0000, -0.0054, -0.4844, 0.9435]} + + loadings = pd.DataFrame(loadings) + + constraints = {'Disabled': [False, False, False], + 'Factor': ['MTUM', 'USMV', 'VLUE'], + 'Sign': ['<=', '<=', '>='], + 'Value': [0.9, -1.2, 0.3], + 'Relative Factor': ['USMV', '', '']} + + constraints = pd.DataFrame(constraints) + + + The constraint looks like this: + + .. image:: images/Constraints2.png + + It is easier to construct the constraints in excel and then upload to a + dataframe. + + To create the matrixes C and D we use the following command: + + :: + + C, D = rp.factors_constraints(constraints, loadings) + + + The matrixes C and D looks like this (all constraints were converted to a linear + constraint): + + .. image:: images/CxD.png + + """ + + if not isinstance(constraints, pd.DataFrame) and not isinstance( + loadings, pd.DataFrame + ): + raise ValueError("constraints and loadings must be DataFrames") + + if constraints.shape[1] != 5: + raise ValueError("constraints must have five columns") + + n = len(constraints) + data = constraints.fillna("") + data = data.values.tolist() + + C = [] + D = [] + for i in range(0, n): + if data[i][0] == False: + if data[i][2] == ">=": + d = 1 + elif data[i][2] == "<=": + d = -1 + C1 = loadings[data[i][1]].values + if data[i][4] != "": + C2 = loadings[data[i][4]].values + C1 = C1 - C2 + C.append(C1 * d) + D.append([data[i][3] * d]) + + C = np.array(C, ndmin=2) + D = np.array(D, ndmin=2) + + return C, D + + +def assets_views(views, asset_classes): + r""" + Create the assets views matrixes P and Q of the views :math:`Pw = Q`. + + Parameters + ---------- + views : DataFrame of shape (n_views, n_fields) + Constraints matrix, where n_views is the number of views + and n_fields is the number of fields of views matrix, the fields + are: + + - Disabled: (bool) indicates if the constraint is enable. + - Type: (str) can be: 'Assets' or 'Classes'. + - Set: (str) if Type is 'Classes' specified the name of the set of asset classes. + - Position: (str) the name of the asset or asset class of the view. + - Sign: (str) can be '>=' or '<='. + - Return: (scalar) is the return of the view. + - Type Relative: (str) can be: 'Assets' or 'Classes'. + - Relative Set: (str) if Type Relative is 'Classes' specified the name of the set of asset classes. + - Relative: (str) the name of the asset or asset class of the relative view. + + asset_classes : DataFrame of shape (n_assets, n_cols) + Asset's classes matrix, where n_assets is the number of assets and + n_cols is the number of columns of the matrix where the first column + is the asset list and the next columns are the different asset's + classes sets. + + Returns + ------- + P : nd-array + The matrix P that shows the relation among assets in each view. + + Q : nd-array + The matrix Q that shows the expected return of each view. + + Raises + ------ + ValueError when the value cannot be calculated. + + Examples + -------- + :: + + asset_classes = {'Assets': ['FB', 'GOOGL', 'NTFX', 'BAC', 'WFC', 'TLT', 'SHV'], + 'Class 1': ['Equity', 'Equity', 'Equity', 'Equity', 'Equity', + 'Fixed Income', 'Fixed Income'], + 'Class 2': ['Technology', 'Technology', 'Technology', + 'Financial', 'Financial', 'Treasury', 'Treasury'],} + + asset_classes = pd.DataFrame(asset_classes) + asset_classes = asset_classes.sort_values(by=['Assets']) + + views = {'Disabled': [False, False, False, False], + 'Type': ['Assets', 'Classes', 'Classes', 'Assets'], + 'Set': ['', 'Class 2','Class 1', ''], + 'Position': ['WFC', 'Financial', 'Equity', 'FB'], + 'Sign': ['<=', '>=', '>=', '>='], + 'Return': [ 0.3, 0.1, 0.05, 0.03 ], + 'Type Relative': [ 'Assets', 'Classes', 'Assets', ''], + 'Relative Set': [ '', 'Class 1', '', ''], + 'Relative': ['FB', 'Fixed Income', 'TLT', '']} + + views = pd.DataFrame(views) + + + The constraint looks like this: + + .. image:: images/Views.png + + It is easier to construct the constraints in excel and then upload to a + dataframe. + + To create the matrixes P and Q we use the following command: + + :: + + P, Q = rp.assets_views(views, asset_classes) + + + The matrixes P and Q looks like this: + + .. image:: images/PxQ.png + + """ + + if not isinstance(views, pd.DataFrame) and not isinstance( + asset_classes, pd.DataFrame + ): + raise ValueError("constraints and asset_classes must be DataFrames") + + if views.shape[1] != 9: + raise ValueError("constraints must have nine columns") + + n = len(views) + m = len(asset_classes) + data = views.fillna("") + data = data.values.tolist() + assetslist = asset_classes.iloc[:, 0].values.tolist() + + P = [] + Q = [] + for i in range(0, n): + valid = False + if data[i][0] == False: + if data[i][1] == "Assets": + item = assetslist.index(data[i][3]) + if data[i][4] == ">=": + d = 1 + elif data[i][4] == "<=": + d = -1 + if data[i][5] != "": + P1 = [0] * m + P1[item] = 1 + if data[i][6] == "Assets" and data[i][8] != "": + item2 = assetslist.index(data[i][8]) + P2 = [0] * m + P2[item2] = 1 + valid = True + elif ( + data[i][6] == "Classes" + and data[i][7] != "" + and data[i][8] != "" + ): + P2 = np.where( + asset_classes[data[i][7]].values == data[i][8], 1, 0 + ) + P2 = P2 / np.sum(P2) + valid = True + elif data[i][6] == "" and data[i][7] == "" and data[i][8] == "": + P2 = [0] * m + valid = True + if valid == True: + P1 = ((np.array(P1) - np.array(P2)) * d).tolist() + P.append(P1) + Q.append([data[i][5] * d]) + elif data[i][1] == "Classes": + if data[i][4] == ">=": + d = 1 + else: + d = -1 + if data[i][5] != "": + P1 = np.where(asset_classes[data[i][2]].values == data[i][3], 1, 0) + P1 = P1 / np.sum(P1) + if data[i][6] == "Assets" and data[i][8] != "": + item2 = assetslist.index(data[i][8]) + P2 = [0] * m + P2[item2] = 1 + valid = True + elif ( + data[i][6] == "Classes" + and data[i][7] != "" + and data[i][8] != "" + ): + P2 = np.where( + asset_classes[data[i][7]].values == data[i][8], 1, 0 + ) + P2 = P2 / np.sum(P2) + valid = True + elif data[i][6] == "" and data[i][7] == "" and data[i][8] == "": + P2 = [0] * m + valid = True + if valid == True: + P1 = ((np.array(P1) - np.array(P2)) * d).tolist() + P.append(P1) + Q.append([data[i][5] * d]) + + P = np.array(P, ndmin=2) + Q = np.array(Q, ndmin=2) + + for i in range(len(Q)): + if Q[i, 0] < 0: + P[i, :] = -1 * P[i, :] + Q[i, :] = -1 * Q[i, :] + + return P, Q + + +def factors_views(views, loadings, const=True): + r""" + Create the factors constraints matrixes C and D of the constraint + :math:`Cw \geq D`. + + Parameters + ---------- + constraints : DataFrame of shape (n_constraints, n_fields) + Constraints matrix, where n_constraints is the number of constraints + and n_fields is the number of fields of constraints matrix, the fields + are: + + - Disabled: (bool) indicates if the constraint is enable. + - Factor: (str) the name of the factor of the constraint. + - Sign: (str) can be '>=' or '<='. + - Value: (scalar) is the maximum or minimum value of the factor. + + loadings : DataFrame of shape (n_assets, n_features) + The loadings matrix. + + Returns + ------- + P : nd-array + The matrix P that shows the relation among factors in each factor view. + + Q : nd-array + The matrix Q that shows the expected return of each factor view. + + Raises + ------ + ValueError when the value cannot be calculated. + + Examples + -------- + :: + + loadings = {'const': [0.0004, 0.0002, 0.0000, 0.0006, 0.0001, 0.0003, -0.0003], + 'MTUM': [0.1916, 1.0061, 0.8695, 1.9996, 0.0000, 0.0000, 0.0000], + 'QUAL': [0.0000, 2.0129, 1.4301, 0.0000, 0.0000, 0.0000, 0.0000], + 'SIZE': [0.0000, 0.0000, 0.0000, 0.4717, 0.0000, -0.1857, 0.0000], + 'USMV': [-0.7838, -1.6439, -1.0176, -1.4407, 0.0055, 0.5781, 0.0000], + 'VLUE': [1.4772, -0.7590, -0.4090, 0.0000, -0.0054, -0.4844, 0.9435]} + + loadings = pd.DataFrame(loadings) + + factorsviews = {'Disabled': [False, False, False], + 'Factor': ['MTUM', 'USMV', 'VLUE'], + 'Sign': ['<=', '<=', '>='], + 'Value': [0.9, -1.2, 0.3], + 'Relative Factor': ['USMV', '', '']} + + factorsviews = pd.DataFrame(factorsviews) + + + The constraint looks like this: + + .. image:: images/factorsviews.png + + It is easier to construct the constraints in excel and then upload to a + dataframe. + + To create the matrixes P and Q we use the following command: + + :: + + P, Q = rp.factors_views(factorsviews, loadings, const=True) + + + The matrixes P and Q looks like this: + + .. image:: images/P_fxQ_f.png + + """ + + if not isinstance(views, pd.DataFrame) and not isinstance(loadings, pd.DataFrame): + raise ValueError("constraints and loadings must be DataFrames") + + if views.shape[1] != 5: + raise ValueError("constraints must have five columns") + + n = len(views) + data = views.fillna("") + data = data.values.tolist() + factorslist = loadings.columns.tolist() + if const == True: + factorslist = factorslist[1:] + m = len(factorslist) + + P = [] + Q = [] + for i in range(0, n): + if data[i][0] == False: + item = factorslist.index(data[i][1]) + if data[i][2] == ">=": + d = 1 + elif data[i][2] == "<=": + d = -1 + P1 = [0] * m + P1[item] = d + if data[i][4] != "": + item = factorslist.index(data[i][4]) + P1[item] = -d + P.append(P1) + Q.append([data[i][3] * d]) + + P = np.array(P, ndmin=2) + Q = np.array(Q, ndmin=2) + + return P, Q + + +def assets_clusters( + returns, + codependence="pearson", + linkage="ward", + k=None, + max_k=10, + bins_info="KN", + alpha_tail=0.05, + leaf_order=True, +): + r""" + Create asset classes based on hierarchical clustering. + + Parameters + ---------- + returns : DataFrame + Assets returns. + codependence : str, can be {'pearson', 'spearman', 'abs_pearson', 'abs_spearman', 'distance', 'mutual_info' or 'tail'} + The codependence or similarity matrix used to build the distance + metric and clusters. The default is 'pearson'. Posible values are: + + - 'pearson': pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{pearson}_{i,j})}`. + - 'spearman': spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{spearman}_{i,j})}`. + - 'abs_pearson': absolute value pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho^{pearson}_{i,j}|)}`. + - 'abs_spearman': absolute value spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho^{spearman}_{i,j}|)}`. + - 'distance': distance correlation matrix. Distance formula :math:`D_{i,j} = \sqrt{(1-\rho^{distance}_{i,j})}`. + - 'mutual_info': mutual information matrix. Distance used is variation information matrix. + - 'tail': lower tail dependence index matrix. Dissimilarity formula :math:`D_{i,j} = -\log{\lambda_{i,j}}`. + + linkage : string, optional + Linkage method of hierarchical clustering, see `linkage `_ for more details. + The default is 'ward'. Posible values are: + + - 'single'. + - 'complete'. + - 'average'. + - 'weighted'. + - 'centroid'. + - 'median'. + - 'ward'. + - 'DBHT'. Direct Bubble Hierarchical Tree. + + k : int, optional + Number of clusters. This value is took instead of the optimal number + of clusters calculated with the two difference gap statistic. + The default is None. + max_k : int, optional + Max number of clusters used by the two difference gap statistic + to find the optimal number of clusters. The default is 10. + bins_info: int or str + Number of bins used to calculate variation of information. The default + value is 'KN'. Posible values are: + + - 'KN': Knuth's choice method. See more in `knuth_bin_width `_. + - 'FD': Freedman–Diaconis' choice method. See more in `freedman_bin_width `_. + - 'SC': Scotts' choice method. See more in `scott_bin_width `_. + - 'HGR': Hacine-Gharbi and Ravier' choice method. + - int: integer value choice by user. + + alpha_tail : float, optional + Significance level for lower tail dependence index. The default is 0.05. + leaf_order : bool, optional + Indicates if the cluster are ordered so that the distance between + successive leaves is minimal. The default is True. + + Returns + ------- + clusters : DataFrame + A dataframe with asset classes based on hierarchical clustering. + + Raises + ------ + ValueError when the value cannot be calculated. + + Examples + -------- + + :: + + clusters = rp.assets_clusters(returns, codependence='pearson', + linkage='ward', k=None, max_k=10, + alpha_tail=0.05, leaf_order=True) + + + The clusters dataframe looks like this: + + .. image:: images/clusters_df.png + + """ + + if not isinstance(returns, pd.DataFrame): + raise ValueError("returns must be a DataFrame") + + # Calculating codependence matrix and distance metric + if codependence in {"pearson", "spearman"}: + codep = returns.corr(method=codependence) + dist = np.sqrt(np.clip((1 - codep) / 2, a_min=0.0, a_max=1.0)) + elif codependence in {"abs_pearson", "abs_spearman"}: + codep = np.abs(returns.corr(method=codependence[4:])) + dist = np.sqrt(np.clip((1 - codep), a_min=0.0, a_max=1.0)) + elif codependence in {"distance"}: + codep = af.dcorr_matrix(returns).astype(float) + dist = np.sqrt(np.clip((1 - codep), a_min=0.0, a_max=1.0)) + elif codependence in {"mutual_info"}: + codep = af.mutual_info_matrix(returns, bins_info).astype(float) + dist = af.var_info_matrix(returns, bins_info).astype(float) + elif codependence in {"tail"}: + codep = af.ltdi_matrix(returns, alpha_tail).astype(float) + dist = -np.log(codep) + + # Hierarchcial clustering + dist = dist.to_numpy() + dist = pd.DataFrame(dist, columns=codep.columns, index=codep.index) + if linkage == "DBHT": + # different choices for D, S give different outputs! + D = dist.to_numpy() # dissimilatity matrix + if codependence in {"pearson", "spearman"}: + S = (1 - dist**2).to_numpy() + else: + S = codep.copy().to_numpy() # similarity matrix + (_, _, _, _, _, clustering) = db.DBHTs( + D, S, leaf_order=leaf_order + ) # DBHT clustering + else: + p_dist = squareform(dist, checks=False) + clustering = hr.linkage(p_dist, method=linkage, optimal_ordering=leaf_order) + + # Optimal number of clusters + if k is None: + k = af.two_diff_gap_stat(codep, dist, clustering, max_k) + + # Building clusters + clusters_inds = hr.fcluster(clustering, k, criterion="maxclust") + labels = np.array(returns.columns.tolist()) + + clusters = {"Assets": [], "Clusters": []} + + for i, v in enumerate(clusters_inds): + clusters["Assets"].append(labels[i]) + clusters["Clusters"].append("Cluster " + str(v)) + + clusters = pd.DataFrame(clusters) + clusters = clusters.sort_values(by=["Assets"]) + + return clusters + + +def hrp_constraints(constraints, asset_classes): + r""" + Create the upper and lower bounds constraints for hierarchical risk parity + model. + + Parameters + ---------- + constraints : DataFrame of shape (n_constraints, n_fields) + Constraints matrix, where n_constraints is the number of constraints + and n_fields is the number of fields of constraints matrix, the fields + are: + + - Disabled: (bool) indicates if the constraint is enable. + - Type: (str) can be: 'Assets', All Assets' and 'Each asset in a class'. + - Position: (str) the name of the asset or asset class of the constraint. + - Sign: (str) can be '>=' or '<='. + - Weight: (scalar) is the maximum or minimum weight of the absolute constraint. + + asset_classes : DataFrame of shape (n_assets, n_cols) + Asset's classes matrix, where n_assets is the number of assets and + n_cols is the number of columns of the matrix where the first column + is the asset list and the next columns are the different asset's + classes sets. + + Returns + ------- + w_max : pd.Series + The upper bound of hierarchical risk parity weights constraints. + + w_min : pd.Series + The lower bound of hierarchical risk parity weights constraints. + + Raises + ------ + ValueError when the value cannot be calculated. + + Examples + -------- + :: + + asset_classes = {'Assets': ['FB', 'GOOGL', 'NTFX', 'BAC', 'WFC', 'TLT', 'SHV'], + 'Class 1': ['Equity', 'Equity', 'Equity', 'Equity', 'Equity', + 'Fixed Income', 'Fixed Income'], + 'Class 2': ['Technology', 'Technology', 'Technology', + 'Financial', 'Financial', 'Treasury', 'Treasury'],} + + asset_classes = pd.DataFrame(asset_classes) + asset_classes = asset_classes.sort_values(by=['Assets']) + + constraints = {'Disabled': [False, False, False, False, False, False], + 'Type': ['Assets', 'Assets', 'All Assets', 'All Assets', + 'Each asset in a class', 'Each asset in a class'], + 'Set': ['', '', '', '','Class 1', 'Class 2'], + 'Position': ['BAC', 'FB', '', '', 'Equity', 'Treasury'], + 'Sign': ['>=', '<=', '<=', '>=', '<=', '<='], + 'Weight': [0.02, 0.085, 0.09, 0.01, 0.07, 0.06]} + + constraints = pd.DataFrame(constraints) + + The constraint looks like this: + + .. image:: images/HRPConstraints.png + + It is easier to construct the constraints in excel and then upload to a + dataframe. + + To create the pd.Series w_max and w_min we use the following command: + + :: + + w_max, w_min = rp.hrp_constraints(constraints, asset_classes) + + + The pd.Series w_max and w_min looks like this (all constraints were + merged to a single upper bound for each asset): + + .. image:: images/HRP_Bounds.png + + """ + + if not isinstance(constraints, pd.DataFrame) and not isinstance( + asset_classes, pd.DataFrame + ): + raise ValueError("constraints and asset_classes must be DataFrames") + + if constraints.shape[1] != 6: + raise ValueError("constraints must have ten columns") + + n = len(constraints) + data = constraints.fillna("").copy() + assetslist = asset_classes.iloc[:, 0].values.tolist() + + w_max = pd.Series(1, index=assetslist) + w_min = pd.Series(0, index=assetslist) + + for i in range(0, n): + if data.loc[i, "Disabled"] == False: + if data.loc[i, "Type"] == "Assets": + assets = data.loc[i, "Position"] + if data.loc[i, "Sign"] == ">=": + if w_min.loc[assets] <= data.loc[i, "Weight"]: + w_min.loc[assets] = data.loc[i, "Weight"] + elif data.loc[i, "Sign"] == "<=": + if w_max.loc[assets] >= data.loc[i, "Weight"]: + w_max.loc[assets] = data.loc[i, "Weight"] + elif data.loc[i, "Type"] == "All Assets": + if data.loc[i, "Sign"] == ">=": + if w_min[w_min <= data.loc[i, "Weight"]].shape[0] != 0: + w_min[w_min <= data.loc[i, "Weight"]] = data.loc[i, "Weight"] + elif data.loc[i, "Sign"] == "<=": + if w_max[w_max >= data.loc[i, "Weight"]].shape[0] != 0: + w_max[w_max >= data.loc[i, "Weight"]] = data.loc[i, "Weight"] + elif data.loc[i, "Type"] == "Each asset in a class": + label_0 = asset_classes.columns.tolist()[0] + label_1 = data.loc[i, "Set"] + label_2 = data.loc[i, "Position"] + assets = asset_classes[[label_0, label_1]][ + asset_classes[label_1] == label_2 + ] + assets = assets["Assets"].tolist() + if data.loc[i, "Sign"] == ">=": + if ( + w_min.loc[assets][ + w_min.loc[assets] <= data.loc[i, "Weight"] + ].shape[0] + != 0 + ): + w_min.loc[assets] = np.where( + w_min.loc[assets] <= data.loc[i, "Weight"], + data.loc[i, "Weight"], + w_min.loc[assets], + ) + elif data.loc[i, "Sign"] == "<=": + if ( + w_max.loc[assets][ + w_max.loc[assets] >= data.loc[i, "Weight"] + ].shape[0] + != 0 + ): + w_max.loc[assets] = np.where( + w_max.loc[assets] >= data.loc[i, "Weight"], + data.loc[i, "Weight"], + w_max.loc[assets], + ) + + return w_max, w_min diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/DBHT.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/DBHT.py new file mode 100644 index 00000000000..10b78e8feef --- /dev/null +++ b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/DBHT.py @@ -0,0 +1,1068 @@ +"""""" # +""" +Copyright (c) 2020-2022, Dany Cajas +All rights reserved. +This work is licensed under BSD 3-Clause "New" or "Revised" License. +License available at https://github.com/dcajasn/Riskfolio-Lib/blob/master/LICENSE.txt + +This work is based on the code of Tomaso Aste available at +https://www.mathworks.com/matlabcentral/fileexchange/46750-dbht +""" + +import numpy as np +import scipy.sparse as sp +from scipy.spatial.distance import squareform +from scipy.cluster.hierarchy import from_mlab_linkage, optimal_leaf_ordering + + +def DBHTs(D, S, leaf_order=True): + r""" + Perform Direct Bubble Hierarchical Tree (DBHT) clustering, a deterministic + technique which only requires a similarity matrix S, and related + dissimilarity matrix D. For more information see "Hierarchical information + clustering by means of topologically embedded graphs." :cite:`d-Song`. + This version makes extensive use of graph-theoretic filtering technique + called Triangulated Maximally Filtered Graph (TMFG). + + Parameters + ---------- + D : nd-array + N x N dissimilarity matrix - e.g. a distance: D=pdist(data,'euclidean') + and then D=squareform(D). + + S : nd-array + N x N similarity matrix (non-negative)- e.g. correlation + coefficient+1: S = 2-D**2/2 or another possible choice can be S = + exp(-D). + + Returns + ------- + T8 : DataFrame + N x 1 cluster membership vector. + Rpm : nd-array + N x N adjacency matrix of Plannar Maximally Filtered + Graph (PMFG). + Adjv : nd-array + Bubble cluster membership matrix from BubbleCluster8. + Dpm : nd-array + N x N shortest path length matrix of PMFG + Mv : nd-array + N x Nb bubble membership matrix. Nb(n,bi)=1 indicates vertex n + is a vertex of bubble bi. + Z : nd-array + Linkage matrix using DBHT hierarchy. + """ + + (Rpm, _, _, _, _) = PMFG_T2s(S) + Apm = Rpm.copy() + Apm[Apm != 0] = D[Apm != 0].copy() + (Dpm, _) = distance_wei(Apm) + (H1, Hb, Mb, CliqList, Sb) = CliqHierarchyTree2s(Rpm, method1="uniqueroot") + del H1, Sb + Mb = Mb[0 : CliqList.shape[0], :] + Mv = np.empty((Rpm.shape[0], 0)) + for i in range(0, Mb.shape[1]): + vec = np.zeros(Rpm.shape[0]) + vec[np.int32(np.unique(CliqList[Mb[:, i] != 0, :]))] = 1 + Mv = np.hstack((Mv, vec.reshape(-1, 1))) + + (Adjv, T8) = BubbleCluster8s(Rpm, Dpm, Hb, Mb, Mv, CliqList) + Z = HierarchyConstruct4s(Rpm, Dpm, T8, Adjv, Mv) + + if leaf_order == True: + Z = optimal_leaf_ordering(Z, squareform(D)) + + return (T8, Rpm, Adjv, Dpm, Mv, Z) + + +def j_LoGo(S, separators, cliques): + r""" + computes sparse inverse covariance, J, from a clique tree made of cliques + and separators. For more information see: :cite:`d-jLogo`. + + + Parameters + ---------- + S : ndarray + It is the complete covariance matrix. + separators : nd-array + It is the list of separators. + clique : nd-array + It is the list of cliques. + + Returns + ------- + JLogo : nd-array + Inverse covariance. + + Notes + ----- + separators and cliques can be the outputs of TMFG function + + """ + N = S.shape[0] + if isinstance(separators, dict) == False: + separators_temp = {} + for i in range(len(separators)): + separators_temp[i] = separators[i, :] + + if isinstance(cliques, dict) == False: + cliques_temp = {} + for i in range(len(cliques)): + cliques_temp[i] = cliques[i, :] + + Jlogo = np.zeros((N, N)) + for i in cliques_temp.keys(): + v = np.int32(cliques_temp[i]) + Jlogo[np.ix_(v, v)] = Jlogo[np.ix_(v, v)] + np.linalg.inv(S[np.ix_(v, v)]) + + for i in separators_temp.keys(): + v = np.int32(separators_temp[i]) + Jlogo[np.ix_(v, v)] = Jlogo[np.ix_(v, v)] - np.linalg.inv(S[np.ix_(v, v)]) + + return Jlogo + + +def PMFG_T2s(W, nargout=3): + r""" + Computes a Triangulated Maximally Filtered Graph (TMFG) :cite:`d-Massara` + starting from a tetrahedron and inserting recursively vertices inside + existing triangles (T2 move) in order to approximate a maximal planar + graph with the largest total weight - non negative weights. + + Parameters + ---------- + W : nd-array + An N x N matrix of non-negative weights. + nargout : int, optional + Number of results, posible values are 3, 4 and 5. + + Returns + ------- + A : nd-array + Adjacency matrix of the PMFG (with weights) + tri : nd-array + Matrix of triangles (triangular faces) of size 2N - 4 x 3 + separators : nd-array + Matrix of 3-cliques that are not triangular faces (all 3-cliques are + given by: [tri;separators]). + clique4 : nd-array, optional + List of all 4-cliques. + cliqueTree : nd-array, optional + 4-cliques tree structure (adjacency matrix). + + """ + N = W.shape[0] + if N < 9: + print("W Matrix too small \n") + if np.any(W < 0): + print("W Matrix has negative elements! \n") + + A = np.zeros((N, N)) # ininzialize adjacency matrix + in_v = -1 * np.ones(N, dtype=np.int32) # ininzialize list of inserted vertices + tri = np.zeros((2 * N - 4, 3)) # ininzialize list of triangles + separators = np.zeros( + (N - 4, 3) + ) # ininzialize list of 3-cliques (non face-triangles) + # find 3 vertices with largest strength + s = np.sum(W * (W > np.mean(W)), axis=1) + j = np.int32(np.argsort(s)[::-1].reshape(-1)) + + in_v[0:4] = j[0:4] + ou_v = np.setdiff1d(np.arange(0, N), in_v) # list of vertices not inserted yet + # build the tetrahedron with largest strength + tri[0, :] = in_v[[0, 1, 2]] + tri[1, :] = in_v[[1, 2, 3]] + tri[2, :] = in_v[[0, 1, 3]] + tri[3, :] = in_v[[0, 2, 3]] + A[in_v[0], in_v[1]] = 1 + A[in_v[0], in_v[2]] = 1 + A[in_v[0], in_v[3]] = 1 + A[in_v[1], in_v[2]] = 1 + A[in_v[1], in_v[3]] = 1 + A[in_v[2], in_v[3]] = 1 + # build initial gain table + gain = np.zeros((N, 2 * N - 4)) + gain[ou_v, 0] = np.sum(W[np.ix_(ou_v, np.int32(tri[0, :]))], axis=1) + gain[ou_v, 1] = np.sum(W[np.ix_(ou_v, np.int32(tri[1, :]))], axis=1) + gain[ou_v, 2] = np.sum(W[np.ix_(ou_v, np.int32(tri[2, :]))], axis=1) + gain[ou_v, 3] = np.sum(W[np.ix_(ou_v, np.int32(tri[3, :]))], axis=1) + + kk = 3 # number of triangles + for k in range(4, N): + # find best vertex to add in a triangle + if len(ou_v) == 1: # special case for the last vertex + ve = ou_v[0] + v = 0 + tr = np.argmax(gain[ou_v, :]) + else: + gij = np.max(gain[ou_v, :], axis=0) + v = np.argmax(gain[ou_v, :], axis=0) + tr = np.argmax(np.round(gij, 6).flatten()) + ve = ou_v[v[tr]] + v = v[tr] + + # update vertex lists + ou_v = ou_v[np.delete(np.arange(len(ou_v)), v)] + in_v[k] = ve + # update adjacency matrix + A[np.ix_([ve], np.int32(tri[tr, :]))] = 1 + # update 3-clique list + separators[k - 4, :] = tri[tr, :] + # update triangle list replacing 1 and adding 2 triangles + tri[kk + 1, :] = np.hstack((tri[tr, [0, 2]], ve)) # add + tri[kk + 2, :] = np.hstack((tri[tr, [1, 2]], ve)) # add + tri[tr, :] = np.hstack((tri[tr, [0, 1]], ve)) # replace + # update gain table + gain[ve, :] = 0 + gain[ou_v, tr] = np.sum(W[np.ix_(ou_v, np.int32(tri[tr, :]))], axis=1) + gain[ou_v, kk + 1] = np.sum(W[np.ix_(ou_v, np.int32(tri[kk + 1, :]))], axis=1) + gain[ou_v, kk + 2] = np.sum(W[np.ix_(ou_v, np.int32(tri[kk + 2, :]))], axis=1) + # update number of triangles + kk = kk + 2 + if np.mod(k, 1000) == 0: + print("PMFG T2: %0.2f per-cent done\n", k / N * 100) + + A = W * ((A + A.T) == 1) + + if nargout > 3: + cliques = np.vstack( + (in_v[0:4].reshape(1, -1), np.hstack((separators, in_v[4:].reshape(-1, 1)))) + ) + else: + cliques = None + + # computes 4-clique tree (note this may include incomplete cliques!) + if nargout > 4: + cliqueTree = np.zeros((cliques.shape[0], cliques.shape[0])) + for i in range(0, cliques.shape[0]): + ss = np.zeros(cliques.shape[0], 1) + for k in range(0, 3): + ss = ss + np.sum((cliques[i, k] == cliques), axis=1) + + cliqueTree[i, ss == 2] = 1 + else: + cliqueTree = None + + return (A, tri, separators, cliques, cliqueTree) + + +def distance_wei(L): + r""" + The distance matrix contains lengths of shortest paths between all + pairs of nodes. An entry (u,v) represents the length of shortest path + from node u to node v. The average shortest path length is the + characteristic path length of the network. + + Parameters + ---------- + L : nd-array + Directed/undirected connection-length matrix. + + Returns + ------- + D : nd-array + Distance (shortest weighted path) matrix + B : nd-array + Number of edges in shortest weighted path matrix + + Notes + ----- + The input matrix must be a connection-length matrix, typically + obtained via a mapping from weight to length. For instance, in a + weighted correlation network higher correlations are more naturally + interpreted as shorter distances and the input matrix should + consequently be some inverse of the connectivity matrix. + The number of edges in shortest weighted paths may in general + exceed the number of edges in shortest binary paths (i.e. shortest + paths computed on the binarized connectivity matrix), because shortest + weighted paths have the minimal weighted distance, but not necessarily + the minimal number of edges. + + Lengths between disconnected nodes are set to Inf. + Lengths on the main diagonal are set to 0. + + Algorithm\: Dijkstra's algorithm. + + Mika Rubinov, UNSW/U Cambridge, 2007-2012. + Rick Betzel and Andrea Avena, IU, 2012 + Modification history \: + 2007: original (MR) + 2009-08-04: min() function vectorized (MR) + 2012: added number of edges in shortest path as additional output (RB/AA) + 2013: variable names changed for consistency with other functions (MR) + + """ + + n = len(L) + D = np.ones((n, n)) * np.inf + np.fill_diagonal(D, 0) # distance matrix + B = np.zeros((n, n)) # number of edges matrix + for u in range(0, n): + S = np.full(n, True, dtype=bool) # distance permanence (true is temporary) + L1 = L.copy() + V = np.array([u]) + while 1: + S[V] = False # distance u->V is now permanent + L1[:, V] = 0 # no in-edges as already shortest + for v in V.tolist(): + # T = np.ravel(np.argwhere(L1[v, :])) # neighbours of shortest nodes + (_, T, _) = sp.find(L1[v, :]) # neighbours of shortest nodes + d = np.min( + np.vstack( + (D[np.ix_([u], T)], D[np.ix_([u], [v])] + L1[np.ix_([v], T)]) + ), + axis=0, + ) + wi = np.argmin( + np.vstack( + (D[np.ix_([u], T)], D[np.ix_([u], [v])] + L1[np.ix_([v], T)]) + ), + axis=0, + ) + D[np.ix_([u], T)] = d # smallest of old/new path lengths + ind = T[wi == 2] # indices of lengthened paths + B[u, ind] = B[u, v] + 1 # increment no. of edges in lengthened paths + + if D[u, S].size == 0: + minD = np.empty((0, 0)) + else: + minD = np.min(D[u, S]) + minD = np.array([minD]) + + if minD.shape[0] == 0 or np.isinf(minD): + # isempty: all nodes reached; isinf: some nodes cannot be reached + break + V = np.ravel(np.argwhere(D[u, :] == minD)) + + return (D, B) + + +def CliqHierarchyTree2s(Apm, method1): + r""" + ClqHierarchyTree2 looks for 3-cliques of a maximal planar graph, then + construct hierarchy of the cliques with the definition of 'inside' a + clique to be a subgraph with smaller size, when the entire graph is + made disjoint by removing the clique :cite:`d-Song2`. + + Parameters + ---------- + Apm : N + N x N Adjacency matrix of a maximal planar graph. + + method1 : str + Choose between 'uniqueroot' and 'equalroot'. Assigns connections + between final root cliques. Uses Voronoi tesselation between tiling + triangles. + + Returns + ------- + H1 : nd-array + Nc x Nc adjacency matrix for 3-clique hierarchical tree where Nc is + the number of 3-cliques. + H2 : nd-array + Nb x Nb adjacency matrix for bubble hierarchical tree where Nb is the + number of bubbles. + Mb : nd-array + Nc x Nb matrix bubble membership matrix. Mb(n,bi)=1 indicates that + 3-clique n belongs to bi bubble. + CliqList : nd-array + Nc x 3 matrix. Each row vector lists three vertices consisting a + 3-clique in the maximal planar graph. + Sb : nd-array + Nc x 1 vector. Sb(n)=1 indicates nth 3-clique is separating. + """ + N = Apm.shape[0] + # IndxTotal=1:N; + if sp.issparse(Apm) != 1: + A = 1.0 * sp.csr_matrix(Apm != 0).toarray() + else: + A = 1.0 * (Apm != 0) + + (K3, E, clique) = clique3(A) + del K3, E # , N3 + + Nc = clique.shape[0] + M = np.zeros((N, Nc)) + CliqList = clique.copy() + Sb = np.zeros(Nc) + del clique + for n in range(0, Nc): + cliq_vec = CliqList[n, :] + (T, IndxNot) = FindDisjoint(A, cliq_vec) + indx0 = np.argwhere(np.ravel(T) == 0) + indx1 = np.argwhere(np.ravel(T) == 1) + indx2 = np.argwhere(np.ravel(T) == 2) + if len(indx1) > len(indx2): + indx_s = np.vstack((indx2, indx0)) + del indx1, indx2 + else: + indx_s = np.vstack((indx1, indx0)) + del indx1, indx2 + + if (indx_s.shape[0] == 0) == 1: + Sb[n] = 0 + else: + Sb[n] = len(indx_s) - 3 # -3 + + M[indx_s, n] = 1 + # del Indicator, InsideCliq, count, T, Temp, cliq_vec, IndxNot, InsideCliq + del T, cliq_vec, IndxNot + + Pred = BuildHierarchy(M) + Root = np.argwhere(Pred == -1) + # for n=1:length(Root); + # Components{n}=find(M(:,Root(n))==1); + # end + del n + + if method1.lower() == "uniqueroot": + + if len(Root) > 1: + Pred = np.append(Pred[:], -1) + Pred[Root] = len(Pred) - 1 + + H = np.zeros((Nc + 1, Nc + 1)) + for n in range(0, len(Pred)): + if Pred[n] != -1: + H[n, np.int32(Pred[n])] = 1 + + H = H + H.T + elif method1.lower() == "equalroot": + if len(Root) > 1: + # %RootCliq=CliqList(Root,:); + Adj = AdjCliq(A, CliqList, Root) + H = np.zeros((Nc, Nc)) + for n in range(0, len(Pred)): + if Pred[n] != -1: + H[n, np.int32(Pred[n])] = 1 + if (Pred.shape[0] == 0) != 1: + H = H + H.T + H = H + Adj + else: + H = np.empty((0, 0)) + + H1 = H.copy() + + if (H1.shape[0] == 0) != 1: + (H2, Mb) = BubbleHierarchy(Pred, Sb, A, CliqList) + else: + H2 = np.empty((0, 0)) + Mb = np.empty((0, 0)) + + H2 = 1.0 * (H2 != 0) + Mb = Mb[0 : CliqList.shape[0], :] + + return (H1, H2, Mb, CliqList, Sb) + + +def BuildHierarchy(M): + Pred = -1 * np.ones(M.shape[1]) + for n in range(0, M.shape[1]): + # Children = np.argwhere(np.ravel(M[:, n]) == 1) + (_, Children, _) = sp.find(M[:, n] == 1) + ChildrenSum = np.sum(M[Children, :], axis=0) + Parents = np.argwhere(np.ravel(ChildrenSum) == len(Children)) + Parents = Parents[Parents != n] + if (Parents.shape[0] == 0) != 1: + ParentSum = np.sum(M[:, Parents], axis=0) + a = np.argwhere(ParentSum == np.min(ParentSum)) + if len(a) == 1: + Pred[n] = Parents[a] + else: + Pred = np.empty(0) + break + else: + Pred[n] = -1 + + return Pred + + +def FindDisjoint(Adj, Cliq): + N = Adj.shape[0] + Temp = Adj.copy() + T = np.zeros(N) + IndxTotal = np.arange(0, N) + IndxNot = np.argwhere( + np.logical_and(IndxTotal != Cliq[0], IndxTotal != Cliq[1], IndxTotal != Cliq[2]) + ) + Temp[np.int32(Cliq), :] = 0 + Temp[:, np.int32(Cliq)] = 0 + # %d = bfs(Temp,IndxNot(1)); + (d, _) = breadth(Temp, IndxNot[0]) + d[np.isinf(d)] = -1 + d[IndxNot[0]] = 0 + Indx1 = d == -1 + Indx2 = d != -1 + T[Indx1] = 1 + T[Indx2] = 2 + T[np.int32(Cliq)] = 0 + del Temp + + return (T, IndxNot) + + +def AdjCliq(A, CliqList, CliqRoot): + Nc = CliqList.shape[0] + CliqList_temp = np.int32(CliqList.copy()) + CliqRoot_temp = np.int32(np.ravel(CliqRoot)) + N = A.shape[0] + Adj = np.zeros((Nc, Nc)) + Indicator = np.zeros((N, 1)) + for n in range(0, len(CliqRoot_temp)): + Indicator[CliqList_temp[CliqRoot_temp[n], :]] = 1 + Indi = np.hstack( + ( + Indicator[CliqList_temp[CliqRoot_temp, 0], 0], + Indicator[CliqList_temp[CliqRoot_temp, 1], 0], + Indicator[CliqList_temp[CliqRoot_temp, 2], 0], + ) + ) + adjacent = CliqRoot_temp[np.sum(Indi.T, axis=0) == 1] + Adj[adjacent, n] = 0 + + Adj = Adj + Adj.T + + return Adj + + +def BubbleHierarchy(Pred, Sb, A, CliqList): + Nc = Pred.shape[0] + Root = np.argwhere(Pred == -1) + CliqCount = np.zeros(Nc) + CliqCount[Root] = 1 + Mb = np.empty((Nc, 0)) + + if len(Root) > 1: + TempVec = np.zeros((Nc, 1)) + TempVec[Root] = 1 + Mb = np.hstack((Mb, TempVec)) + del TempVec + + while np.sum(CliqCount) < Nc: + NxtRoot = np.empty((0, 1)) + + for n in range(0, len(Root)): + # DirectChild = np.ravel(np.argwhere(Pred == Root[n])) + (_, DirectChild, _) = sp.find(Pred == Root[n]) + TempVec = np.zeros((Nc, 1)) + TempVec[np.append(DirectChild, np.int32(Root[n])), 0] = 1 + Mb = np.hstack((Mb, TempVec)) + CliqCount[DirectChild] = 1 + + for m in range(0, len(DirectChild)): + if Sb[DirectChild[m]] != 0: + NxtRoot = np.vstack((NxtRoot, DirectChild[m])) + + del DirectChild, TempVec + + Root = np.unique(NxtRoot) + + Nb = Mb.shape[1] + H = np.zeros((Nb, Nb)) + + # if sum(IdentifyJoint==0)==0; + for n in range(0, Nb): + Indx = Mb[:, n] == 1 + JointSum = np.sum(Mb[Indx, :], axis=0) + Neigh = JointSum >= 1 + H[n, Neigh] = 1 + # else + # H=[]; + + H = H + H.T + H = H - np.diag(np.diag(H)) + + return (H, Mb) + + +def clique3(A): + r""" + Computes the list of 3-cliques. + + Parameters + ---------- + A : nd-array + N x N sparse adjacency matrix. + + Returns + ------- + clique : nd-array + Nc x 3 matrix. Each row vector contains the list of vertices for + a 3-clique. + """ + + A = A - np.diag(np.diag(A)) + A = 1.0 * (A != 0) + A2 = A @ A + P = (1.0 * (A2 != 0)) * (1.0 * (A != 0)) + + P = sp.csr_matrix(np.triu(P)) + + (r, c, _) = sp.find(P != 0) + E = np.hstack((r.reshape(-1, 1), c.reshape(-1, 1))) + + K3 = {} + N3 = np.zeros(len(r)) + for n in range(0, len(r)): + i = r[n] + j = c[n] + a = A[i, :] * A[j, :] + # indx = np.ravel(np.argwhere(a != 0)) + (_, indx, _) = sp.find(a != 0) + K3[n] = indx + N3[n] = len(indx) + + clique = np.zeros((1, 3)) + for n in range(0, len(r)): + temp = K3[n] + for m in range(0, len(temp)): + candidate = E[n, :] + candidate = np.hstack((candidate, temp[m])) + candidate = np.sort(candidate) + a = 1 * (clique[:, 0] == candidate[0]) + b = 1 * (clique[:, 1] == candidate[1]) + c = 1 * (clique[:, 2] == candidate[2]) + check = (a * b) * c + check = np.sum(check) + if check == 0: + clique = np.vstack((clique, candidate.reshape(1, -1))) + del candidate, check, a, b, c + + isort = np.lexsort((clique[:, 2], clique[:, 1], clique[:, 0])) + + clique = clique[isort] + clique = clique[1 : clique.shape[0], :] + + return (K3, E, clique) + + +def breadth(CIJ, source): + r""" + Implementation of breadth-first search. + + Parameters + ---------- + CIJ : nd-array + Binary (directed/undirected) connection matrix + source : nd-array + Source vertex + + Returns + ------- + distance : nd-array + Distance between 'source' and i'th vertex (0 for source vertex). + branch : nd-array + Vertex that precedes i in the breadth-first search tree (-1 for source + vertex) + + Notes + ----- + Breadth-first search tree does not contain all paths (or all shortest + paths), but allows the determination of at least one path with minimum + distance. The entire graph is explored, starting from source vertex + 'source'. + + Olaf Sporns, Indiana University, 2002/2007/2008 + """ + + N = CIJ.shape[0] + # colors: white, gray, black + white = 0 + gray = 1 + black = 2 + # initialize colors + color = np.zeros(N) + # initialize distances + distance = np.inf * np.ones(N) + # initialize branches + branch = np.zeros(N) + # start on vertex 'source' + color[source] = gray + distance[source] = 0 + branch[source] = -1 + Q = np.array(source).reshape(-1) + # keep going until the entire graph is explored + while (Q.shape[0] == 0) == 0: + u = Q[0] + # ns = np.argwhere(CIJ[u, :]) + (_, ns, _) = sp.find(CIJ[u, :]) + for v in ns: + # this allows the 'source' distance to itself to be recorded + if distance[v].all() == 0: + distance[v] = distance[u] + 1 + if color[v].all() == white: + color[v] = gray + distance[v] = distance[u] + 1 + branch[v] = u + Q = np.hstack((Q, v)) + + Q = Q[1 : len(Q)] + color[u] = black + + return (distance, branch) + + +def BubbleCluster8s(Rpm, Dpm, Hb, Mb, Mv, CliqList): + r""" + Obtains non-discrete and discrete clusterings from the bubble topology of + PMFG. + + Parameters + ---------- + Rpm : nd-array + N x N sparse weighted adjacency matrix of PMFG. + Dpm : nd-array + N x N shortest path lengths matrix of PMFG + Hb : nd-array + Undirected bubble tree of PMFG + Mb : nd-array + Nc x Nb bubble membership matrix for 3-cliques. Mb(n,bi)=1 indicates + that 3-clique n belongs to bi bubble. + Mv : nd-array + N x Nb bubble membership matrix for vertices. + CliqList : nd-array + Nc x 3 matrix of list of 3-cliques. Each row vector contains the list + of vertices for a particular 3-clique. + + Returns + ------- + Adjv : nd-array + N x Nk cluster membership matrix for vertices for non-discrete + clustering via the bubble topology. Adjv(n,k)=1 indicates cluster + membership of vertex n to kth non-discrete cluster. + Tc : nd-array + N x 1 cluster membership vector. Tc(n)=k indicates cluster membership + of vertex n to kth discrete cluster. + """ + + (Hc, Sep) = DirectHb( + Rpm, Hb, Mb, Mv, CliqList + ) # Assign directions on the bubble tree + N = Rpm.shape[0] # Number of vertices in the PMFG + # indx = np.ravel(np.argwhere(Sep == 1)) # Look for the converging bubbles + (_, indx, _) = sp.find(Sep == 1) # Look for the converging bubbles + Adjv = np.empty((0, 0)) + if len(indx) > 1: + Adjv = np.zeros( + (Mv.shape[0], len(indx)) + ) # Set the non-discrete cluster membership matrix 'Adjv' at default + # Identify the non-discrete cluster membership of vertices by each + # converging bubble + for n in range(0, len(indx)): + # %[d dt p]=bfs(Hc.T, indx[n]); + (d, _) = breadth(Hc.T, indx[n]) + d[np.isinf(d)] = -1 + d[indx[n]] = 0 + (r, c, _) = sp.find(Mv[:, d != -1] != 0) + Adjv[np.unique(r), n] = 1 + del d, r, c #%, dt, p + + Tc = -1 * np.ones(N) # Set the discrete cluster membership vector at default + Bubv = Mv[:, indx] # Gather the list of vertices in the converging bubbles + (_, cv, _) = sp.find( + np.sum(Bubv.T, axis=0).T == 1 + ) # Identify vertices which belong to single converging bubbles + (_, uv, _) = sp.find( + np.sum(Bubv.T, axis=0).T > 1 + ) # Identify vertices which belong to more than one converging bubbles. + Mdjv = np.zeros( + (N, len(indx)) + ) # Set the cluster membership matrix for vertices in the converging bubbles at default + Mdjv[cv, :] = Bubv[ + cv, : + ].copy() # Assign vertices which belong to single converging bubbles to the rightful clusters. + # Assign converging bubble membership of vertices in `uv' + for v in range(0, len(uv)): + v_cont = np.sum(Rpm[:, uv[v]].reshape(-1, 1) * Bubv, axis=0).reshape( + -1, 1 + ) # sum of edge weights linked to uv(v) in each converging bubble + all_cont = 3 * ( + np.sum(Bubv, axis=0) - 2 + ) # number of edges in converging bubble + all_cont = all_cont.reshape(-1, 1) + imx = np.argmax(v_cont / all_cont) + Mdjv[uv[v], imx] = 1 # Pick the most strongly associated converging bubble + + (v, ci, _) = sp.find(1 * (Mdjv != 0)) + Tc[v] = ci + del ( + v, + ci, + ) # Assign discrete cluster membership of vertices in the converging bubbles. + + Udjv = Dpm @ (Mdjv @ np.diag(1 / np.sum(1 * (Mdjv != 0), axis=0))) + Udjv[ + Adjv == 0 + ] = np.inf # Compute the distance between a vertex and the converging bubbles. + # mn = np.min(Udjv[np.sum(Mdjv.T, axis=0)==0,:].T) # Look for the closest converging bubble + imn = np.argmin(Udjv[np.sum(Mdjv, axis=1) == 0, :], axis=1) + Tc[ + Tc == -1 + ] = imn # Assign discrete cluster membership according to the distances to the converging bubbles + else: + Tc = np.ones( + N + ) # if there is one converging bubble, all vertices belong to a single cluster + + return (Adjv, Tc) + + +def DirectHb(Rpm, Hb, Mb, Mv, CliqList): + r""" + Computes directions on each separating 3-clique of a maximal planar + graph, hence computes Directed Bubble Hierarchical Tree (DBHT). + + Parameters + ---------- + Rpm : nd-array + N x N sparse weighted adjacency matrix of PMFG + Hb : nd-array + Undirected bubble tree of PMFG + Mb : nd-array + Nc x Nb bubble membership matrix for 3-cliques. Mb(n,bi)=1 indicates + that 3-clique n belongs to bi bubble. + Mv : nd-array + N x Nb bubble membership matrix for vertices. + CliqList : nd-array + Nc x 3 matrix of list of 3-cliques. Each row vector contains the list + of vertices for a particular 3-clique. + + Returns + ------- + Hc : nd-array + Nb x Nb unweighted directed adjacency matrix of DBHT. Hc(i,j)=1 + indicates a directed edge from bubble i to bubble j. + """ + + Hb_temp = 1 * (Hb != 0) + (r, c, _) = sp.find(sp.triu(sp.csr_matrix(Hb_temp)) != 0) + CliqEdge = np.empty((0, 3)) + for n in range(0, len(r)): + data = np.argwhere(np.logical_and(Mb[:, r[n]] != 0, Mb[:, c[n]] != 0)) + if data.shape[0] != 0: + data = np.hstack((r[n].reshape(1, -1), c[n].reshape(1, -1), data)) + CliqEdge = np.vstack((CliqEdge, data)) + + del r, c + + kb = np.sum(1 * (Hb_temp != 0), axis=0) + Hc = np.zeros((Mv.shape[1], Mv.shape[1])) + CliqEdge = np.int32(CliqEdge) + + for n in range(0, CliqEdge.shape[0]): + Temp = Hb_temp.copy() + Temp[CliqEdge[n, 0], CliqEdge[n, 1]] = 0 + Temp[CliqEdge[n, 1], CliqEdge[n, 0]] = 0 + (d, _) = breadth(Temp, np.array([0])) + d[np.isinf(d)] = -1 + d[0] = 0 + vo = np.int32(CliqList[CliqEdge[n, 2], :]) + bleft = CliqEdge[n, 0:2] + bleft = bleft[d[bleft] != -1] + bright = CliqEdge[n, 0:2] + bright = bright[d[bright] == -1] + vleftc = np.argwhere(Mv[:, d != -1] != 0) + vleft = vleftc[:, 0] + c = vleftc[:, 1] + vleft = np.setdiff1d(vleft, vo) + vrightc = np.argwhere(Mv[:, d == -1] != 0) + vright = vrightc[:, 0] + c = vrightc[:, 1] + vright = np.setdiff1d(vright, vo) + del c + left = np.sum(Rpm[np.ix_(vo, vleft)]) + right = np.sum(Rpm[np.ix_(vo, vright)]) + if left > right: + Hc[np.ix_(bright, bleft)] = left + else: + Hc[np.ix_(bleft, bright)] = right + del vleft, vright, vo, Temp, bleft, bright, right, left + + Sep = np.double((np.sum(Hc.T, axis=0) == 0)) + # Sep[(np.sum(Hc, axis=0) == 0) & (kb > 1)] = 2 + Sep[np.logical_and(np.sum(Hc, axis=0) == 0, kb > 1)] = 2 + + return (Hc, Sep) + + +def HierarchyConstruct4s(Rpm, Dpm, Tc, Adjv, Mv): + r""" + Constructs intra- and inter-cluster hierarchy by utilizing Bubble + hierarchy structure of a maximal planar graph, namely Planar Maximally + Filtered Graph (PMFG). + + Parameters + ---------- + Rpm : nd-array + N x N Weighted adjacency matrix of PMFG. + Dpm : nd-array + N x N shortest path length matrix of PMFG. + Tc : nd-array + N x 1 cluster membership vector from DBHT clustering. Tc(n)=z_i + indicate cluster of nth vertex. + Adjv : nd-array + Bubble cluster membership matrix from BubbleCluster8s. + Mv : nd-array + Bubble membership of vertices from BubbleCluster8s. + + Returns + ------- + Z : nd-array + (N-1) x 4 linkage matrix, in the same format as the output from matlab + function 'linkage'. + """ + + N = Dpm.shape[0] + kvec = np.int32(np.unique(Tc)) + LabelVec1 = np.arange(0, N) + # LinkageDist = np.zeros((1,1)) + E = sp.csr_matrix( + (np.ones(N), (np.arange(0, N), np.int32(Tc))), + shape=(N, np.int32(np.max(Tc) + 1)), + ).toarray() + Z = np.array(np.empty((0, 3))) + Tc = Tc + 1 + kvec = kvec + 1 + # Intra-cluster hierarchy construction + for n in range(0, len(kvec)): + Mc = ( + E[:, kvec[n] - 1].reshape(-1, 1) * Mv + ) # Get the list of bubbles which coincide with nth cluster + Mvv = BubbleMember( + Dpm, Rpm, Mv, Mc + ) # Assign each vertex in the nth cluster to a specific bubble. + (_, Bub, _) = sp.find( + np.sum(Mvv, axis=0) > 0 + ) # Get the list of bubbles which contain the vertices of nth cluster + nc = np.sum(Tc == kvec[n], axis=0) - 1 ########## + # %Apply the linkage within the bubbles. + for m in range(0, len(Bub)): + (_, V, _) = sp.find( + Mvv[:, Bub[m]] != 0 + ) # Retrieve the list of vertices assigned to mth bubble. + if len(V) > 1: + dpm = Dpm[ + np.ix_(V, V) + ] # Retrieve the distance matrix for the vertices in V + LabelVec = LabelVec1[ + V + ] # Initiate the label vector which labels for the clusters. + LabelVec2 = LabelVec1.copy() + for v in range(0, len(V) - 1): + (PairLink, dvu) = LinkageFunction( + dpm, LabelVec + ) # Look for the pair of clusters which produces the best linkage + LabelVec[ + np.logical_or(LabelVec == PairLink[0], LabelVec == PairLink[1]) + ] = ( + np.max(LabelVec1, axis=0) + 1 + ) # Merge the cluster pair by updating the label vector with a same label. + LabelVec2[V] = LabelVec.copy() + Z = DendroConstruct(Z, LabelVec1, LabelVec2, 1 / nc) + nc = nc - 1 + LabelVec1 = LabelVec2.copy() + del PairLink, dvu # , Vect + del LabelVec, dpm, LabelVec2 + del V + + (_, V, _) = sp.find(E[:, kvec[n] - 1] != 0) + dpm = Dpm[np.ix_(V, V)] + # %Perform linkage merging between the bubbles + LabelVec = LabelVec1[ + V + ] # Initiate the label vector which labels for the clusters. + LabelVec2 = LabelVec1.copy() + for b in range(0, len(Bub) - 1): + (PairLink, dvu) = LinkageFunction(dpm, LabelVec) + # %[PairLink,dvu]=LinkageFunction(rpm,LabelVec); + LabelVec[ + np.logical_or(LabelVec == PairLink[0], LabelVec == PairLink[1]) + ] = ( + np.max(LabelVec1) + 1 + ) # Merge the cluster pair by updating the label vector with a same label. + LabelVec2[V] = LabelVec.copy() + Z = DendroConstruct(Z, LabelVec1, LabelVec2, 1 / nc) + nc = nc - 1 + LabelVec1 = LabelVec2.copy() + del PairLink, dvu # , Vect + + del LabelVec, V, dpm, LabelVec2 # , rpm, + + # %Inter-cluster hierarchy construction + LabelVec2 = LabelVec1.copy() + dcl = np.ones(len(LabelVec1)) + for n in range(0, len(kvec) - 1): + (PairLink, dvu) = LinkageFunction(Dpm, LabelVec1) + # %[PairLink,dvu]=LinkageFunction(Rpm,LabelVec); + LabelVec2[np.logical_or(LabelVec1 == PairLink[0], LabelVec1 == PairLink[1])] = ( + np.max(LabelVec1, axis=0) + 1 + ) # Merge the cluster pair by updating the label vector with a same label. + dvu = np.unique(dcl[LabelVec1 == PairLink[0]]) + np.unique( + dcl[LabelVec1 == PairLink[1]] + ) + dcl[np.logical_or(LabelVec1 == PairLink[0], LabelVec1 == PairLink[1])] = dvu + Z = DendroConstruct(Z, LabelVec1, LabelVec2, dvu) + LabelVec1 = LabelVec2.copy() + del PairLink, dvu + del LabelVec1 + Z[:, 0:2] = Z[:, 0:2] + 1 + Z = from_mlab_linkage(Z) + + if len(np.unique(LabelVec2)) > 1: + print("Something Wrong in Merging. Check the codes.") + return None + + return Z + + +def LinkageFunction(d, labelvec): + lvec = np.unique(labelvec) + Links = np.empty((0, 3)) + for r in range(0, len(lvec) - 1): + vecr = (labelvec == lvec[r]).reshape(-1) + for c in range(r + 1, len(lvec)): + vecc = (labelvec == lvec[c]).reshape(-1) + x1 = np.ravel(np.logical_or(vecr, vecc)) + dd = d[np.ix_(x1, x1)] + + if dd[dd != 0].shape[0] == 0: + Link1 = np.hstack((lvec[r], lvec[c], 0)) + else: + Link1 = np.hstack((lvec[r], lvec[c], np.max(dd[dd != 0], axis=0))) + Links = np.vstack((Links, Link1)) + del vecc + + dvu = np.min(Links[:, 2], axis=0) + imn = np.argmin(Links[:, 2], axis=0) + PairLink = Links[imn, 0:2] + + return (PairLink, dvu) + + +def BubbleMember(Dpm, Rpm, Mv, Mc): + Mvv = np.zeros((Mv.shape[0], Mv.shape[1])) + (_, vu, _) = sp.find(np.sum(Mc.T, axis=0) > 1) + (_, v, _) = sp.find(np.sum(Mc.T, axis=0) == 1) + Mvv[v, :] = Mc[v, :] + for n in range(0, len(vu)): + (_, bub, _) = sp.find(Mc[vu[n], :] != 0) + vu_bub = np.sum(Rpm[:, vu[n]].reshape(-1, 1) * Mv[:, bub], axis=0).T + all_bub = np.diag(Mv[:, bub].T @ Rpm @ Mv[:, bub]) / 2 + frac = vu_bub / all_bub + # mx = np.max(frac, axis=0) + imx = np.argmax(frac, axis=0) + Mvv[vu[n], bub[imx]] = 1 + + return Mvv + + +def DendroConstruct(Zi, LabelVec1, LabelVec2, LinkageDist): + indx = (LabelVec1.T == LabelVec2.T) != 1 + if len(np.unique(LabelVec1[indx])) != 2: + print("Check the codes") + return + + Z = np.vstack((Zi, np.hstack((np.sort(np.unique(LabelVec1[indx])), LinkageDist)))) + + return Z diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/HCPortfolio.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/HCPortfolio.py new file mode 100644 index 00000000000..6709d8035dc --- /dev/null +++ b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/HCPortfolio.py @@ -0,0 +1,799 @@ +"""""" # +""" +Copyright (c) 2020-2022, Dany Cajas +All rights reserved. +This work is licensed under BSD 3-Clause "New" or "Revised" License. +License available at https://github.com/dcajasn/Riskfolio-Lib/blob/master/LICENSE.txt +""" + +import numpy as np +import pandas as pd +import scipy.cluster.hierarchy as hr +from scipy.spatial.distance import squareform +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.Portfolio as pf +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.RiskFunctions as rk +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.AuxFunctions as af +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.ParamsEstimation as pe +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.DBHT as db + + +class HCPortfolio(object): + r""" + Class that creates a portfolio object with all properties needed to + calculate optimal portfolios. + + Parameters + ---------- + returns : DataFrame, optional + A dataframe that containts the returns of the assets. + The default is None. + alpha : float, optional + Significance level of VaR, CVaR, EVaR, DaR, CDaR, EDaR and Tail Gini of losses. + The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of CVaR and Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + w_max : Series, optional + Upper bound constraint for hierarchical risk parity weights :cite:`c-Pfitzinger`. + w_min : Series, optional + Lower bound constraint for hierarchical risk parity weights :cite:`c-Pfitzinger`. + """ + + def __init__( + self, + returns=None, + alpha=0.05, + a_sim=100, + beta=None, + b_sim=None, + w_max=None, + w_min=None, + alpha_tail=0.05, + bins_info=None, + ): + self._returns = returns + self.alpha = alpha + self.a_sim = a_sim + self.beta = beta + self.b_sim = b_sim + self.alpha_tail = alpha_tail + self.bins_info = bins_info + self.asset_order = None + self.clusters = None + self.cov = None + self.mu = None + self.codep = None + self.codep_sorted = None + self.w_max = w_max + self.w_min = w_min + + @property + def returns(self): + if self._returns is not None and isinstance(self._returns, pd.DataFrame): + return self._returns + else: + raise NameError("returns must be a DataFrame") + + @returns.setter + def returns(self, value): + if value is not None and isinstance(value, pd.DataFrame): + self._returns = value + else: + raise NameError("returns must be a DataFrame") + + @property + def assetslist(self): + if self._returns is not None and isinstance(self._returns, pd.DataFrame): + return self._returns.columns.tolist() + + # get naive-risk weights + def _naive_risk(self, returns, cov, rm="MV", rf=0): + assets = returns.columns.tolist() + n = len(assets) + + if rm == "equal": + weight = np.ones((n, 1)) * 1 / n + else: + inv_risk = np.zeros((n, 1)) + for i in assets: + k = assets.index(i) + w = np.zeros((n, 1)) + w[k, 0] = 1 + w = pd.DataFrame(w, columns=["weights"], index=assets) + if rm == "vol": + risk = rk.Sharpe_Risk( + w, + cov=cov, + returns=returns, + rm="MV", + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + else: + risk = rk.Sharpe_Risk( + w, + cov=cov, + returns=returns, + rm=rm, + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + inv_risk[k, 0] = risk + + if rm == "MV": + inv_risk = 1 / np.power(inv_risk, 2) + else: + inv_risk = 1 / inv_risk + weight = inv_risk * (1 / np.sum(inv_risk)) + + weight = weight.reshape(-1, 1) + + return weight + + # get optimal weights + def _opt_w(self, returns, mu, cov, obj="MinRisk", rm="MV", rf=0, l=2): + if returns.shape[1] == 1: + weight = np.array([1]).reshape(-1, 1) + else: + if obj in {"MinRisk", "Utility", "Sharpe"}: + port = pf.Portfolio(returns=returns) + port.assets_stats(method_mu="hist", method_cov="hist", d=0.94) + port.cov = cov + if mu is not None: + port.mu = mu + weight = port.optimization( + model="Classic", rm=rm, obj=obj, rf=rf, l=l, hist=True + ).to_numpy() + elif obj in {"ERC"}: + port = pf.Portfolio(returns=returns) + port.assets_stats(method_mu="hist", method_cov="hist", d=0.94) + port.cov = cov + weight = port.rp_optimization( + model="Classic", rm=rm, rf=rf, b=None, hist=True + ).to_numpy() + + weight = weight.reshape(-1, 1) + + return weight + + # Create hierarchical clustering + def _hierarchical_clustering( + self, + model="HRP", + linkage="ward", + codependence="pearson", + max_k=10, + leaf_order=True, + ): + + # Calculating distance + if codependence in {"pearson", "spearman", "custom_cov"}: + dist = np.sqrt(np.clip((1 - self.codep) / 2, a_min=0.0, a_max=1.0)) + elif codependence in {"abs_pearson", "abs_spearman", "distance"}: + dist = np.sqrt(np.clip((1 - self.codep), a_min=0.0, a_max=1.0)) + elif codependence in {"mutual_info"}: + dist = af.var_info_matrix(self.returns, self.bins_info).astype(float) + elif codependence in {"tail"}: + dist = -np.log(self.codep).astype(float) + + # Hierarchcial clustering + dist = dist.to_numpy() + dist = pd.DataFrame(dist, columns=self.codep.columns, index=self.codep.index) + if linkage == "DBHT": + # different choices for D, S give different outputs! + D = dist.to_numpy() # dissimilatity matrix + if codependence in {"pearson", "spearman", "custom_cov"}: + codep = 1 - dist**2 + S = codep.to_numpy() # similarity matrix + else: + S = self.codep.to_numpy() # similarity matrix + (_, _, _, _, _, clustering) = db.DBHTs( + D, S, leaf_order=leaf_order + ) # DBHT clustering + else: + p_dist = squareform(dist, checks=False) + clustering = hr.linkage(p_dist, method=linkage, optimal_ordering=leaf_order) + + if model in {"HERC", "HERC2", "NCO"}: + # optimal number of clusters + k = af.two_diff_gap_stat(self.codep, dist, clustering, max_k) + else: + k = None + + return clustering, k + + # sort clustered items by distance + def _seriation(self, clusters): + return hr.leaves_list(clusters) + + # compute HRP weight allocation through recursive bisection + def _recursive_bisection(self, sort_order, rm="MV", rf=0): + + if isinstance(self.w_max, pd.Series) and isinstance(self.w_min, pd.Series): + if (self.w_max.all() >= self.w_min.all()).item(): + flag = True + else: + raise NameError("All upper bounds must be higher than lower bounds") + else: + flag = False + + weight = pd.Series(1, index=sort_order) # set initial weights to 1 + items = [sort_order] + + while len(items) > 0: # loop while weights is under 100% + items = [ + i[j:k] + for i in items + for j, k in ( + (0, len(i) // 2), + (len(i) // 2, len(i)), + ) # get cluster indi + if len(i) > 1 + ] + + # allocate weight to left and right cluster + for i in range(0, len(items), 2): + left_cluster = items[i] + right_cluster = items[i + 1] + + # Left cluster + left_cov = self.cov.iloc[left_cluster, left_cluster] + left_returns = self.returns.iloc[:, left_cluster] + left_weight = self._naive_risk(left_returns, left_cov, rm=rm, rf=rf) + + if rm == "vol": + left_risk = rk.Sharpe_Risk( + left_weight, + cov=left_cov, + returns=left_returns, + rm="MV", + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + else: + left_risk = rk.Sharpe_Risk( + left_weight, + cov=left_cov, + returns=left_returns, + rm=rm, + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + if rm == "MV": + left_risk = np.power(left_risk, 2) + + # Right cluster + right_cov = self.cov.iloc[right_cluster, right_cluster] + right_returns = self.returns.iloc[:, right_cluster] + right_weight = self._naive_risk(right_returns, right_cov, rm=rm, rf=rf) + + if rm == "vol": + right_risk = rk.Sharpe_Risk( + right_weight, + cov=right_cov, + returns=right_returns, + rm="MV", + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + else: + right_risk = rk.Sharpe_Risk( + right_weight, + cov=right_cov, + returns=right_returns, + rm=rm, + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + if rm == "MV": + right_risk = np.power(right_risk, 2) + + # Allocate weight to clusters + alpha_1 = 1 - left_risk / (left_risk + right_risk) + + # Weights constraints + if flag: + a1 = np.sum(self.w_max[left_cluster]) / weight[left_cluster[0]] + a2 = np.max( + [ + np.sum(self.w_min[left_cluster]) / weight[left_cluster[0]], + alpha_1, + ] + ) + alpha_1 = np.min([a1, a2]) + a1 = np.sum(self.w_max[right_cluster]) / weight[right_cluster[0]] + a2 = np.max( + [ + np.sum(self.w_min[right_cluster]) + / weight[right_cluster[0]], + 1 - alpha_1, + ] + ) + alpha_1 = 1 - np.min([a1, a2]) + + weight[left_cluster] *= alpha_1 # weight 1 + weight[right_cluster] *= 1 - alpha_1 # weight 2 + + weight.index = self.asset_order + + return weight + + # compute HRP weight allocation through cluster-based bisection + def _hierarchical_recursive_bisection( + self, Z, rm="MV", rf=0, linkage="ward", model="HERC" + ): + + # Transform linkage to tree and reverse order + root, nodes = hr.to_tree(Z, rd=True) + nodes = np.array(nodes) + nodes_1 = np.array([i.dist for i in nodes]) + idx = np.argsort(nodes_1) + nodes = nodes[idx][::-1].tolist() + + weight = pd.Series(1, index=self.cov.index) # Set initial weights to 1 + + clustering_inds = hr.fcluster(Z, self.k, criterion="maxclust") + clusters = { + i: [] for i in range(min(clustering_inds), max(clustering_inds) + 1) + } + for i, v in enumerate(clustering_inds): + clusters[v].append(i) + + # Loop through k clusters + for i in nodes[: self.k - 1]: + if i.is_leaf() == False: # skip leaf-nodes + left = i.get_left().pre_order() # lambda i: i.id) # get left cluster + right = i.get_right().pre_order() # lambda i: i.id) # get right cluster + left_set = set(left) + right_set = set(right) + left_risk = 0 + right_risk = 0 + + # Allocate weight to clusters + if rm == "equal": + alpha_1 = 0.5 + + else: + for j in clusters.keys(): + if set(clusters[j]).issubset(left_set): + # Left cluster + left_cov = self.cov.iloc[clusters[j], clusters[j]] + left_returns = self.returns.iloc[:, clusters[j]] + left_weight = self._naive_risk( + left_returns, left_cov, rm=rm, rf=rf + ) + + if rm == "vol": + left_risk_ = rk.Sharpe_Risk( + left_weight, + cov=left_cov, + returns=left_returns, + rm="MV", + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + else: + left_risk_ = rk.Sharpe_Risk( + left_weight, + cov=left_cov, + returns=left_returns, + rm=rm, + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + if rm == "MV": + left_risk_ = np.power(left_risk_, 2) + + left_risk += left_risk_ + + elif set(clusters[j]).issubset(right_set): + # Right cluster + right_cov = self.cov.iloc[clusters[j], clusters[j]] + right_returns = self.returns.iloc[:, clusters[j]] + right_weight = self._naive_risk( + right_returns, right_cov, rm=rm, rf=rf + ) + + if rm == "vol": + right_risk_ = rk.Sharpe_Risk( + right_weight, + cov=right_cov, + returns=right_returns, + rm="MV", + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + else: + right_risk_ = rk.Sharpe_Risk( + right_weight, + cov=right_cov, + returns=right_returns, + rm=rm, + rf=rf, + alpha=self.alpha, + a_sim=self.a_sim, + beta=self.beta, + b_sim=self.b_sim, + ) + if rm == "MV": + right_risk_ = np.power(right_risk_, 2) + + right_risk += right_risk_ + + alpha_1 = 1 - left_risk / (left_risk + right_risk) + + weight[left] *= alpha_1 # weight 1 + weight[right] *= 1 - alpha_1 # weight 2 + + # Get constituents of k clusters + clustered_assets = pd.Series( + hr.cut_tree(Z, n_clusters=self.k).flatten(), index=self.cov.index + ) + # Multiply within-cluster weight with inter-cluster weight + for i in range(self.k): + cluster = clustered_assets.loc[clustered_assets == i] + cluster_cov = self.cov.loc[cluster.index, cluster.index] + cluster_returns = self.returns.loc[:, cluster.index] + if model == "HERC": + cluster_weights = pd.Series( + self._naive_risk( + cluster_returns, cluster_cov, rm=rm, rf=rf + ).flatten(), + index=cluster_cov.index, + ) + elif model == "HERC2": + cluster_weights = pd.Series( + self._naive_risk( + cluster_returns, cluster_cov, rm="equal", rf=rf + ).flatten(), + index=cluster_cov.index, + ) + weight.loc[cluster_weights.index] *= cluster_weights + + return weight + + # compute intra-cluster weights + def _intra_weights(self, Z, obj="MinRisk", rm="MV", rf=0, l=2): + # Get constituents of k clusters + clustered_assets = pd.Series( + hr.cut_tree(Z, n_clusters=self.k).flatten(), index=self.cov.index + ) + + # get covariance matrices for each cluster + intra_weights = pd.DataFrame(index=clustered_assets.index) + for i in range(self.k): + cluster = clustered_assets.loc[clustered_assets == i] + if self.mu is not None: + cluster_mu = self.mu.loc[:, cluster.index] + else: + cluster_mu = None + cluster_cov = self.cov.loc[cluster.index, cluster.index] + cluster_returns = self.returns.loc[:, cluster.index] + weights = pd.Series( + self._opt_w( + cluster_returns, cluster_mu, cluster_cov, obj=obj, rm=rm, rf=rf, l=l + ).flatten(), + index=cluster_cov.index, + ) + intra_weights[i] = weights + + intra_weights = intra_weights.fillna(0) + return intra_weights + + def _inter_weights(self, intra_weights, obj="MinRisk", rm="MV", rf=0, l=2): + # inter-cluster mean vector + if self.mu is not None: + tot_mu = self.mu @ intra_weights + else: + tot_mu = None + # inter-cluster covariance matrix + tot_cov = intra_weights.T.dot(np.dot(self.cov, intra_weights)) + # inter-cluster returns matrix + tot_ret = self.returns @ intra_weights + + # inter-cluster weights + inter_weights = pd.Series( + self._opt_w(tot_ret, tot_mu, tot_cov, obj=obj, rm=rm, rf=rf, l=l).flatten(), + index=intra_weights.columns, + ) + # determine the weight on each cluster by multiplying the intra-cluster weight with the inter-cluster weight + weights = intra_weights.mul(inter_weights, axis=1).sum(axis=1).sort_index() + return weights + + # Allocate weights + def optimization( + self, + model="HRP", + codependence="pearson", + covariance="hist", + obj="MinRisk", + rm="MV", + rf=0, + l=2, + custom_cov=None, + custom_mu=None, + linkage="single", + k=None, + max_k=10, + bins_info="KN", + alpha_tail=0.05, + leaf_order=True, + d=0.94, + **kwargs, + ): + r""" + This method calculates the optimal portfolio according to the + optimization model selected by the user. + + Parameters + ---------- + model : str, can be {'HRP', 'HERC' or 'HERC2'} + The hierarchical cluster portfolio model used for optimize the + portfolio. The default is 'HRP'. Posible values are: + + - 'HRP': Hierarchical Risk Parity. + - 'HERC': Hierarchical Equal Risk Contribution. + - 'HERC2': HERC but splitting weights equally within clusters. + - 'NCO': Nested Clustered Optimization. + + codependence : str, optional + The codependence or similarity matrix used to build the distance + metric and clusters. The default is 'pearson'. Posible values are: + + - 'pearson': pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{pearson}_{i,j})}`. + - 'spearman': spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{spearman}_{i,j})}`. + - 'abs_pearson': absolute value pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho^{pearson}_{i,j}|)}`. + - 'abs_spearman': absolute value spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho^{spearman}_{i,j}|)}`. + - 'distance': distance correlation matrix. Distance formula :math:`D_{i,j} = \sqrt{(1-\rho^{distance}_{i,j})}`. + - 'mutual_info': mutual information matrix. Distance used is variation information matrix. + - 'tail': lower tail dependence index matrix. Dissimilarity formula :math:`D_{i,j} = -\log{\lambda_{i,j}}`. + - 'custom_cov': use custom correlation matrix based on the custom_cov parameter. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{pearson}_{i,j})}`. + + covariance : str, optional + The method used to estimate the covariance matrix: + The default is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1': use ewma with adjust=True. For more information see `EWM `_. + - 'ewma2': use ewma with adjust=False. For more information see `EWM `_. + - 'ledoit': use the Ledoit and Wolf Shrinkage method. + - 'oas': use the Oracle Approximation Shrinkage method. + - 'shrunk': use the basic Shrunk Covariance method. + - 'gl': use the basic Graphical Lasso Covariance method. + - 'jlogo': use the j-LoGo Covariance method. For more information see: :cite:`c-jLogo`. + - 'fixed': denoise using fixed method. For more information see chapter 2 of :cite:`c-MLforAM`. + - 'spectral': denoise using spectral method. For more information see chapter 2 of :cite:`c-MLforAM`. + - 'shrink': denoise using shrink method. For more information see chapter 2 of :cite:`c-MLforAM`. + - 'custom_cov': use custom covariance matrix. + + obj : str can be {'MinRisk', 'Utility', 'Sharpe' or 'ERC'}. + Objective function used by the NCO model. + The default is 'MinRisk'. Posible values are: + + - 'MinRisk': Minimize the selected risk measure. + - 'Utility': Maximize the Utility function :math:`\mu w - l \phi_{i}(w)`. + - 'Sharpe': Maximize the risk adjusted return ratio based on the selected risk measure. + - 'ERC': Equally risk contribution portfolio of the selected risk measure. + + rm : str, optional + The risk measure used to optimze the portfolio. If model is 'NCO', + the risk measures available depends on the objective functon. + The default is 'MV'. Posible values are: + + - 'equal': Equally weighted. + - 'vol': Standard Deviation. + - 'MV': Variance. + - 'MAD': Mean Absolute Deviation. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'VaR': Value at Risk. + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'WR': Worst Realization (Minimax). + - 'RG': Range of returns. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'MDD': Maximum Drawdown of uncompounded cumulative returns (Calmar Ratio). + - 'ADD': Average Drawdown of uncompounded cumulative returns. + - 'DaR': Drawdown at Risk of uncompounded cumulative returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'EDaR': Entropic Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + - 'MDD_Rel': Maximum Drawdown of compounded cumulative returns (Calmar Ratio). + - 'ADD_Rel': Average Drawdown of compounded cumulative returns. + - 'DaR_Rel': Drawdown at Risk of compounded cumulative returns. + - 'CDaR_Rel': Conditional Drawdown at Risk of compounded cumulative returns. + - 'EDaR_Rel': Entropic Drawdown at Risk of compounded cumulative returns. + - 'UCI_Rel': Ulcer Index of compounded cumulative returns. + + rf : float, optional + Risk free rate, must be in the same period of assets returns. + The default is 0. + l : scalar, optional + Risk aversion factor of the 'Utility' objective function. + The default is 2. + custom_cov : DataFrame or None, optional + Custom covariance matrix, used when codependence or covariance + parameters have value 'custom_cov'. The default is None. + custom_mu : DataFrame or None, optional + Custom mean vector when NCO objective is 'Utility' or 'Sharpe'. + The default is None. + linkage : string, optional + Linkage method of hierarchical clustering. For more information see `linkage `_. + The default is 'single'. Posible values are: + + - 'single'. + - 'complete'. + - 'average'. + - 'weighted'. + - 'centroid'. + - 'median'. + - 'ward'. + - 'DBHT': Direct Bubble Hierarchical Tree. + + k : int, optional + Number of clusters. This value is took instead of the optimal number + of clusters calculated with the two difference gap statistic. + The default is None. + max_k : int, optional + Max number of clusters used by the two difference gap statistic + to find the optimal number of clusters. The default is 10. + bins_info: int or str + Number of bins used to calculate variation of information. The default + value is 'KN'. Posible values are: + + - 'KN': Knuth's choice method. See more in `knuth_bin_width `_. + - 'FD': Freedman–Diaconis' choice method. See more in `freedman_bin_width `_. + - 'SC': Scotts' choice method. See more in `scott_bin_width `_. + - 'HGR': Hacine-Gharbi and Ravier' choice method. + - int: integer value choice by user. + + alpha_tail : float, optional + Significance level for lower tail dependence index. The default is 0.05. + leaf_order : bool, optional + Indicates if the cluster are ordered so that the distance between + successive leaves is minimal. The default is True. + d : scalar + The smoothing factor of ewma methods. + The default is 0.94. + **kwargs: + Other variables related to covariance estimation. See + `Scikit Learn `_ + and chapter 2 of :cite:`d-MLforAM` for more details. + + Returns + ------- + w : DataFrame + The weights of optimal portfolio. + + """ + + # Covariance matrix + if covariance == "custom_cov": + self.cov = custom_cov.copy() + else: + self.cov = pe.covar_matrix( + self.returns, method=covariance, d=0.94, **kwargs + ) + + # Custom mean vector + if custom_mu is not None: + if isinstance(custom_mu, pd.Series) == True: + self.mu = custom_mu.to_frame().T + elif isinstance(custom_mu, pd.DataFrame) == True: + if custom_mu.shape[0] > 1 and custom_mu.shape[1] == 1: + self.mu = custom_mu.T + elif custom_mu.shape[0] == 1 and custom_mu.shape[1] > 1: + self.mu = custom_mu + else: + raise NameError("custom_mu must be a column DataFrame") + else: + raise NameError("custom_mu must be a column DataFrame or Series") + + self.alpha_tail = alpha_tail + self.bins_info = bins_info + + # Codependence matrix + if codependence in {"pearson", "spearman"}: + self.codep = self.returns.corr(method=codependence).astype(float) + elif codependence in {"abs_pearson", "abs_spearman"}: + self.codep = np.abs(self.returns.corr(method=codependence[4:])).astype( + float + ) + elif codependence in {"distance"}: + self.codep = af.dcorr_matrix(self.returns).astype(float) + elif codependence in {"mutual_info"}: + self.codep = af.mutual_info_matrix(self.returns, self.bins_info).astype( + float + ) + elif codependence in {"tail"}: + self.codep = af.ltdi_matrix(self.returns, alpha=self.alpha_tail).astype( + float + ) + elif codependence in {"custom_cov"}: + self.codep = af.cov2corr(custom_cov).astype(float) + + # Step-1: Tree clustering + self.clusters, self.k = self._hierarchical_clustering( + model, linkage, codependence, max_k, leaf_order=leaf_order + ) + if k is not None: + self.k = int(k) + + # Step-2: Seriation (Quasi-Diagnalization) + self.sort_order = self._seriation(self.clusters) + asset_order = self.assetslist + asset_order[:] = [self.assetslist[i] for i in self.sort_order] + self.asset_order = asset_order.copy() + self.codep_sorted = self.codep.reindex( + index=self.asset_order, columns=self.asset_order + ) + + if isinstance(self.w_max, pd.Series) and isinstance(self.w_min, pd.Series): + self.w_max = self.w_max.reindex(index=self.asset_order) + self.w_max.index = self.sort_order + self.w_min = self.w_min.reindex(index=self.asset_order) + self.w_min.index = self.sort_order + + # Step-3: Recursive bisection + if model == "HRP": + # Recursive bisection + weights = self._recursive_bisection(self.sort_order, rm=rm, rf=rf) + elif model in ["HERC", "HERC2"]: + # Cluster-based Recursive bisection + weights = self._hierarchical_recursive_bisection( + self.clusters, rm=rm, rf=rf, linkage=linkage, model=model + ) + elif model == "NCO": + # Step-3.1: Determine intra-cluster weights + intra_weights = self._intra_weights( + self.clusters, obj=obj, rm=rm, rf=rf, l=l + ) + + # Step-3.2: Determine inter-cluster weights and multiply with 􏰁→ intra-cluster weights + weights = self._inter_weights(intra_weights, obj=obj, rm=rm, rf=rf, l=l) + + if isinstance(self.w_max, pd.Series) and isinstance(self.w_min, pd.Series): + self.w_max = self.w_max.sort_index() + self.w_max.index = self.assetslist + self.w_min = self.w_min.sort_index() + self.w_max.index = self.assetslist + + weights = weights.loc[self.assetslist].to_frame() + weights.columns = ["weights"] + + return weights diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/OwaWeights.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/OwaWeights.py new file mode 100644 index 00000000000..3adc8bb6a99 --- /dev/null +++ b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/OwaWeights.py @@ -0,0 +1,257 @@ +"""""" # +""" +Copyright (c) 2020-2022, Dany Cajas +All rights reserved. +This work is licensed under BSD 3-Clause "New" or "Revised" License. +License available at https://github.com/dcajasn/Riskfolio-Lib/blob/master/LICENSE.txt +""" + +import numpy as np + + +def owa_gmd(T): + r""" + Calculate the OWA weights to calculate the Gini mean difference (GMD) + of a returns series. + + Parameters + ---------- + T : int + Number of observations of the returns series. + + Returns + ------- + value : 1d-array + An OWA weights vector of size Tx1. + """ + + w_ = [] + for i in range(1, T + 1): + w_.append(2 * i - 1 - T) + w_ = 2 * np.array(w_) / (T * (T - 1)) + w_ = w_.reshape(-1, 1) + + return w_ + + +def owa_cvar(T, alpha=0.05): + r""" + Calculate the OWA weights to calculate the Conditional Value at Risk (CVaR) + of a returns series. + + Parameters + ---------- + T : int + Number of observations of the returns series. + alpha : float, optional + Significance level of CVaR. The default is 0.05. + + Returns + ------- + value : 1d-array + An OWA weights vector of size Tx1. + """ + + k = int(np.ceil(T * alpha)) - 1 + w_ = np.zeros((T, 1)) + w_[:k, :] = -1 / (T * alpha) + w_[k, :] = -1 - np.sum(w_[:k, :]) + + return w_ + + +def owa_wcvar(T, alphas, weights): + r""" + Calculate the OWA weights to calculate the Weighted Conditional Value at + Risk (WCVaR) of a returns series. + + Parameters + ---------- + T : int + Number of observations of the returns series. + alphas : list + List of significance levels of each CVaR model. + weights : list + List of weights of each CVaR model. + + Returns + ------- + value : 1d-array + An OWA weights vector of size Tx1. + """ + + w_ = 0 + for i, j in zip(alphas, weights): + w_ += owa_cvar(T, i) * j + + return w_ + + +def owa_tg(T, alpha=0.05, a_sim=100): + r""" + Calculate the OWA weights to calculate the Tail Gini of a returns series. + + Parameters + ---------- + T : int + Number of observations of the returns series. + alpha : float, optional + Significance level of TaiL Gini. The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate the Tail Gini. The default is 100. + + Returns + ------- + value : 1d-array + A OWA weights vector of size Tx1. + """ + + alphas = np.linspace(alpha, 0.0001, a_sim)[::-1] + w_ = [(alphas[1] - 0) * alphas[0] / alphas[-1] ** 2] + for i in range(1, len(alphas) - 1): + w_.append((alphas[i + 1] - alphas[i - 1]) * alphas[i] / alphas[-1] ** 2) + w_.append((alphas[-1] - alphas[-2]) / alphas[-1]) + w_ = owa_wcvar(T, alphas, w_) + + return w_ + + +def owa_wr(T): + r""" + Calculate the OWA weights to calculate the Worst realization (minimum) of a returns series. + + Parameters + ---------- + T : int + Number of observations of the returns series. + + Returns + ------- + value : 1d-array + A OWA weights vector of size Tx1. + """ + + w_ = np.zeros((T, 1)) + w_[0, :] = -1 + + return w_ + + +def owa_rg(T): + r""" + Calculate the OWA weights to calculate the range of a returns series. + + Parameters + ---------- + T : int + Number of observations of the returns series. + + Returns + ------- + value : 1d-array + A OWA weights vector of size Tx1. + """ + + w_ = np.zeros((T, 1)) + w_[0, :] = -1 + w_[-1, :] = 1 + + return w_ + + +def owa_cvrg(T, alpha=0.05, beta=None): + r""" + Calculate the OWA weights to calculate the CVaR range of a returns series. + + Parameters + ---------- + T : int + Number of observations of the returns series. + alpha : float, optional + Significance level of CVaR of losses. The default is 0.05. + beta : float, optional + Significance level of CVaR of gains. If None it duplicates alpha. + The default is None. + + Returns + ------- + value : 1d-array + A OWA weights vector of size Tx1. + """ + + if beta is None: + beta = alpha + + w_ = owa_cvar(T, alpha) - owa_cvar(T, beta)[::-1] + + return w_ + + +def owa_wcvrg(T, alphas, weights_a, betas=None, weights_b=None): + r""" + Calculate the OWA weights to calculate the WCVaR range of a returns series. + + Parameters + ---------- + T : int + Number of observations of the returns series. + alphas : list + List of significance levels of each CVaR of losses model. + weights_a : list + List of weights of each CVaR of losses model. + betas : list, optional + List of significance levels of each CVaR of gains model. If None it duplicates alpha. + The default is None. + weights_b : list, optional + List of weights of each CVaR of gains model. If None it duplicates weights_a. + The default is None. + + Returns + ------- + value : 1d-array + A OWA weights vector of size Tx1. + """ + + if betas is None or weights_b is None: + betas = alphas + weights_b = weights_a + + w_ = owa_wcvar(T, alphas, weights_a) - owa_wcvar(T, betas, weights_b)[::-1] + + return w_ + + +def owa_tgrg(T, alpha=0.05, a_sim=100, beta=None, b_sim=None): + r""" + Calculate the OWA weights to calculate the Tail Gini range of a returns + series. + + Parameters + ---------- + T : int + Number of observations of the returns series. + alpha : float, optional + Significance level of Tail Gini of losses. The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + + Returns + ------- + value : 1d-array + A OWA weights vector of size Tx1. + """ + + if beta is None: + beta = alpha + if b_sim is None: + b_sim = a_sim + + w_ = owa_tg(T, alpha, a_sim) - owa_tg(T, beta, b_sim)[::-1] + + return w_ diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/ParamsEstimation.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/ParamsEstimation.py new file mode 100644 index 00000000000..2d8afb111ac --- /dev/null +++ b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/ParamsEstimation.py @@ -0,0 +1,1500 @@ +"""""" # +""" +Copyright (c) 2020-2022, Dany Cajas +All rights reserved. +This work is licensed under BSD 3-Clause "New" or "Revised" License. +License available at https://github.com/dcajasn/Riskfolio-Lib/blob/master/LICENSE.txt +""" + +import numpy as np +import pandas as pd +import statsmodels.api as sm +import sklearn.covariance as skcov +from sklearn.preprocessing import StandardScaler +from sklearn.decomposition import PCA +from numpy.linalg import inv +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.AuxFunctions as af +import arch.bootstrap as bs +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.DBHT as db + + +def mean_vector(X, method="hist", d=0.94): + r""" + Calculate the expected returns vector using the selected method. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + method : str, optinal + The method used to estimate the expected returns. + The default value is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1': use ewma with adjust=True. For more information see `EWM `_. + - 'ewma2': use ewma with adjust=False. For more information see `EWM `_. + d : scalar + The smoothing factor of ewma methods. + The default is 0.94. + + Returns + ------- + mu : 1d-array + The estimation of expected returns. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + + if not isinstance(X, pd.DataFrame): + raise ValueError("X must be a DataFrame") + + assets = X.columns.tolist() + + if method == "hist": + mu = np.array(X.mean(), ndmin=2) + elif method == "ewma1": + mu = np.array(X.ewm(alpha=1 - d).mean().iloc[-1, :], ndmin=2) + elif method == "ewma2": + mu = np.array(X.ewm(alpha=1 - d, adjust=False).mean().iloc[-1, :], ndmin=2) + + mu = pd.DataFrame(np.array(mu, ndmin=2), columns=assets) + + return mu + + +def covar_matrix(X, method="hist", d=0.94, **kwargs): + r""" + Calculate the covariance matrix using the selected method. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + method : str, optional + The method used to estimate the covariance matrix: + The default is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1': use ewma with adjust=True. For more information see `EWM `_. + - 'ewma2': use ewma with adjust=False. For more information see `EWM `_. + - 'ledoit': use the Ledoit and Wolf Shrinkage method. + - 'oas': use the Oracle Approximation Shrinkage method. + - 'shrunk': use the basic Shrunk Covariance method. + - 'gl': use the basic Graphical Lasso Covariance method. + - 'jlogo': use the j-LoGo Covariance method. For more information see: :cite:`b-jLogo`. + - 'fixed': denoise using fixed method. For more information see chapter 2 of :cite:`b-MLforAM`. + - 'spectral': denoise using spectral method. For more information see chapter 2 of :cite:`b-MLforAM`. + - 'shrink': denoise using shrink method. For more information see chapter 2 of :cite:`b-MLforAM`. + d : scalar + The smoothing factor of ewma methods. + The default is 0.94. + **kwargs: + Other variables related to covariance estimation. See + `Scikit Learn `_ + and chapter 2 of :cite:`b-MLforAM` for more details. + + Returns + ------- + cov : nd-array + The estimation of covariance matrix. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + + if not isinstance(X, pd.DataFrame): + raise ValueError("X must be a DataFrame") + + assets = X.columns.tolist() + + if method == "hist": + cov = np.cov(X.T) + elif method == "ewma1": + cov = X.ewm(alpha=1 - d).cov() + item = cov.iloc[-1, :].name[0] + cov = cov.loc[(item, slice(None)), :] + elif method == "ewma2": + cov = X.ewm(alpha=1 - d, adjust=False).cov() + item = cov.iloc[-1, :].name[0] + cov = cov.loc[(item, slice(None)), :] + elif method == "ledoit": + lw = skcov.LedoitWolf(**kwargs) + lw.fit(X) + cov = lw.covariance_ + elif method == "oas": + oas = skcov.OAS(**kwargs) + oas.fit(X) + cov = oas.covariance_ + elif method == "shrunk": + sc = skcov.ShrunkCovariance(**kwargs) + sc.fit(X) + cov = sc.covariance_ + elif method == "gl": + gl = skcov.GraphicalLassoCV(**kwargs) + gl.fit(X) + cov = gl.covariance_ + elif method == "jlogo": + S = np.cov(X.T) + R = np.corrcoef(X.T) + D = np.sqrt(np.clip((1 - R) / 2, a_min=0.0, a_max=1.0)) + (_, _, separators, cliques, _) = db.PMFG_T2s(1 - D**2, nargout=4) + cov = db.j_LoGo(S, separators, cliques) + cov = np.linalg.inv(cov) + elif method in ["fixed", "spectral", "shrink"]: + cov = np.cov(X.T) + T, N = X.shape + q = T / N + cov = af.denoiseCov(cov, q, kind=method, **kwargs) + + cov = pd.DataFrame(np.array(cov, ndmin=2), columns=assets, index=assets) + + return cov + + +def forward_regression(X, y, criterion="pvalue", threshold=0.05, verbose=False): + r""" + Select the variables that estimate the best model using stepwise + forward regression. In case none of the variables has a p-value lower + than threshold, the algorithm will select the variable with lowest p-value. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + y : Series of shape (n_samples, 1) + Target vector, where n_samples in the number of samples. + criterion : str, optional + The default is 'pvalue'. Posible values of the criterion used to select + the best features are: + + - 'pvalue': select the features based on p-values. + - 'AIC': select the features based on lowest Akaike Information Criterion. + - 'SIC': select the features based on lowest Schwarz Information Criterion. + - 'R2': select the features based on highest R Squared. + - 'R2_A': select the features based on highest Adjusted R Squared. + + thresholdt : scalar, optional + Is the maximum p-value for each variable that will be + accepted in the model. The default is 0.05. + verbose : bool, optional + Enable verbose output. The default is False. + + Returns + ------- + value : list + A list of the variables that produce the best model. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + if not isinstance(X, pd.DataFrame): + raise ValueError("X must be a DataFrame") + + if not isinstance(y, pd.DataFrame) and not isinstance(y, pd.Series): + raise ValueError("y must be a column DataFrame") + + if isinstance(y, pd.DataFrame): + if y.shape[0] > 1 and y.shape[1] > 1: + raise ValueError("y must be a column DataFrame") + + included = [] + aic = 1e10 + sic = 1e10 + r2 = -1e10 + r2_a = -1e10 + pvalues = None + + if criterion == "pvalue": + value = 0 + while value <= threshold: + excluded = list(set(X.columns) - set(included)) + best_pvalue = 999999 + new_feature = None + for i in excluded: + factors = included + [i] + X1 = X[factors] + X1 = sm.add_constant(X1) + results = sm.OLS(y, X1).fit() + new_pvalues = results.pvalues + new_pvalues = new_pvalues[new_pvalues.index != "const"] + cond_1 = new_pvalues.max() + if best_pvalue > new_pvalues[i] and cond_1 <= threshold: + best_pvalue = results.pvalues[i] + new_feature = i + pvalues = new_pvalues.copy() + + if pvalues is not None: + value = pvalues[pvalues.index != "const"].max() + + if new_feature is None: + break + else: + included.append(new_feature) + + if verbose: + print("Add {} with p-value {:.6}".format(new_feature, best_pvalue)) + + # This part is how to deal when there isn't an asset with pvalue lower than threshold + if len(included) == 0: + excluded = list(set(X.columns) - set(included)) + best_pvalue = 999999 + new_feature = None + for i in excluded: + factors = included + [i] + X1 = X[factors] + X1 = sm.add_constant(X1) + results = sm.OLS(y, X1).fit() + new_pvalues = results.pvalues + new_pvalues = new_pvalues[new_pvalues.index != "const"] + if best_pvalue > new_pvalues[i]: + best_pvalue = results.pvalues[i] + new_feature = i + pvalues = new_pvalues.copy() + + value = pvalues[pvalues.index != "const"].max() + + included.append(new_feature) + + if verbose: + print( + "Add {} with p-value {:.6}".format(pvalues.idxmax(), pvalues.max()) + ) + + else: + excluded = X.columns.tolist() + for i in range(X.shape[1]): + j = 0 + value = None + for i in excluded: + factors = included.copy() + factors.append(i) + X1 = X[factors] + X1 = sm.add_constant(X1) + results = sm.OLS(y, X1).fit() + + if criterion == "AIC": + if results.aic < aic: + value = i + aic = results.aic + if criterion == "SIC": + if results.bic < sic: + value = i + sic = results.bic + if criterion == "R2": + if results.rsquared > r2: + value = i + r2 = results.rsquared + if criterion == "R2_A": + if results.rsquared_adj > r2_a: + value = i + r2_a = results.rsquared_adj + + j += 1 + if j == len(excluded): + if value is None: + break + else: + excluded.remove(value) + included.append(value) + if verbose: + if criterion == "AIC": + print( + "Add {} with AIC {:.6}".format(value, results.aic) + ) + elif criterion == "SIC": + print( + "Add {} with SIC {:.6}".format(value, results.bic) + ) + elif criterion == "R2": + print( + "Add {} with R2 {:.6}".format( + value, results.rsquared + ) + ) + elif criterion == "R2_A": + print( + "Add {} with Adjusted R2 {:.6}".format( + value, results.rsquared_adj + ) + ) + + return included + + +def backward_regression(X, y, criterion="pvalue", threshold=0.05, verbose=False): + r""" + Select the variables that estimate the best model using stepwise + backward regression. In case none of the variables has a p-value lower + than threshold, the algorithm will select the variable with lowest p-value. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + y : Series of shape (n_samples, 1) + Target vector, where n_samples in the number of samples. + criterion : str, optional + The default is 'pvalue'. Posible values of the criterion used to select + the best features are: + + - 'pvalue': select the features based on p-values. + - 'AIC': select the features based on lowest Akaike Information Criterion. + - 'SIC': select the features based on lowest Schwarz Information Criterion. + - 'R2': select the features based on highest R Squared. + - 'R2_A': select the features based on highest Adjusted R Squared. + threshold : scalar, optional + Is the maximum p-value for each variable that will be + accepted in the model. The default is 0.05. + verbose : bool, optional + Enable verbose output. The default is False. + + Returns + ------- + value : list + A list of the variables that produce the best model. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + + if not isinstance(X, pd.DataFrame): + raise ValueError("X must be a DataFrame") + + if not isinstance(y, pd.DataFrame) and not isinstance(y, pd.Series): + raise ValueError("y must be a column DataFrame") + + if isinstance(y, pd.DataFrame): + if y.shape[0] > 1 and y.shape[1] > 1: + raise ValueError("y must be a column DataFrame") + + X1 = sm.add_constant(X) + results = sm.OLS(y, X1).fit() + pvalues = results.pvalues + aic = results.aic + sic = results.bic + r2 = results.rsquared + r2_a = results.rsquared_adj + + included = pvalues.index.tolist() + excluded = ["const"] + + if criterion == "pvalue": + while pvalues[pvalues.index != "const"].max() > threshold: + factors = pvalues[~pvalues.index.isin(excluded)].index.tolist() + X1 = X[factors] + X1 = sm.add_constant(X1) + results = sm.OLS(y, X1).fit() + pvalues = results.pvalues + pvalues = pvalues[pvalues.index != "const"] + if pvalues.shape[0] == 0: + break + excluded = ["const", pvalues.idxmax()] + if verbose and pvalues.max() > threshold: + print( + "Drop {} with p-value {:.6}".format(pvalues.idxmax(), pvalues.max()) + ) + + included = pvalues.index.tolist() + + # This part is how to deal when there isn't an asset with pvalue lower than threshold + if len(included) == 0: + excluded = list(set(X.columns) - set(included)) + best_pvalue = 999999 + new_feature = None + for i in excluded: + factors = included + [i] + X1 = X[factors] + X1 = sm.add_constant(X1) + results = sm.OLS(y, X1).fit() + new_pvalues = results.pvalues + new_pvalues = results.pvalues + new_pvalues = new_pvalues[new_pvalues.index != "const"] + if best_pvalue > new_pvalues[i]: + best_pvalue = results.pvalues[i] + new_feature = i + pvalues = new_pvalues.copy() + + value = pvalues[pvalues.index != "const"].max() + + included.append(new_feature) + + if verbose: + print( + "Add {} with p-value {:.6}".format(pvalues.idxmax(), pvalues.max()) + ) + + else: + included.remove("const") + for i in range(X.shape[1]): + j = 0 + value = None + for i in included: + factors = included.copy() + factors.remove(i) + X1 = X[factors] + X1 = sm.add_constant(X1) + results = sm.OLS(y, X1).fit() + + if criterion == "AIC": + if results.aic < aic: + value = i + aic = results.aic + elif criterion == "SIC": + if results.bic < sic: + value = i + sic = results.bic + elif criterion == "R2": + if results.rsquared > r2: + value = i + r2 = results.rsquared + elif criterion == "R2_A": + if results.rsquared_adj > r2_a: + value = i + r2_a = results.rsquared_adj + + j += 1 + if j == len(included): + if value is None: + break + else: + included.remove(value) + if verbose: + if criterion == "AIC": + print( + "Drop {} with AIC {:.6}".format(value, results.aic) + ) + elif criterion == "SIC": + print( + "Drop {} with SIC {:.6}".format(value, results.bic) + ) + elif criterion == "R2": + print( + "Drop {} with R2 {:.6}".format( + value, results.rsquared + ) + ) + elif criterion == "R2_A": + print( + "Drop {} with Adjusted R2 {:.6}".format( + value, results.rsquared_adj + ) + ) + + return included + + +def PCR(X, y, n_components=0.95): + r""" + Estimate the coeficients using Principal Components Regression (PCR). + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + y : Series of shape (n_samples, 1) + Target vector, where n_samples in the number of samples. + n_components : int, float, None or str, optional + if 1 < n_components (int), it represents the number of components that + will be keep. if 0 < n_components < 1 (float), it represents the + percentage of variance that the is explained by the components keeped. + See `PCA `_ + for more details. The default is 0.95. + + Returns + ------- + value : nd-array + An array with the coefficients of the model calculated using PCR. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + + if not isinstance(X, pd.DataFrame): + raise ValueError("X must be a DataFrame") + + if not isinstance(y, pd.DataFrame) and not isinstance(y, pd.Series): + raise ValueError("y must be a column DataFrame") + + if isinstance(y, pd.DataFrame): + if y.shape[0] > 1 and y.shape[1] > 1: + raise ValueError("y must be a column DataFrame") + + scaler = StandardScaler() + scaler.fit(X) + X_std = scaler.transform(X) + + pca = PCA(n_components=n_components) + pca.fit(X_std) + Z_p = pca.transform(X_std) + V_p = pca.components_.T + + results = sm.OLS(y, sm.add_constant(Z_p)).fit() + beta_pc = results.params[1:] + beta_pc = np.array(beta_pc, ndmin=2) + + std = np.array(np.std(X, axis=0, ddof=1), ndmin=2) + mean = np.array(np.mean(X, axis=0), ndmin=2) + beta = V_p @ beta_pc.T / std.T + + beta_0 = np.array(y.mean(), ndmin=2) - np.sum(beta * mean.T) + + beta = np.insert(beta, 0, beta_0) + beta = np.array(beta, ndmin=2) + + return beta + + +def loadings_matrix( + X, + Y, + feature_selection="stepwise", + stepwise="Forward", + criterion="pvalue", + threshold=0.05, + n_components=0.95, + verbose=False, +): + r""" + Estimate the loadings matrix using stepwise regression. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + Y : DataFrame of shape (n_samples, n_assets) + Target matrix, where n_samples in the number of samples and + n_assets is the number of assets. + feature_selection: str 'stepwise' or 'PCR', optional + Indicate the method used to estimate the loadings matrix. + The default is 'stepwise'. + stepwise: str 'Forward' or 'Backward', optional + Indicate the method used for stepwise regression. + The default is 'Forward'. + criterion : str, optional + The default is 'pvalue'. Posible values of the criterion used to select + the best features are: + + - 'pvalue': select the features based on p-values. + - 'AIC': select the features based on lowest Akaike Information Criterion. + - 'SIC': select the features based on lowest Schwarz Information Criterion. + - 'R2': select the features based on highest R Squared. + - 'R2_A': select the features based on highest Adjusted R Squared. + threshold : scalar, optional + Is the maximum p-value for each variable that will be + accepted in the model. The default is 0.05. + n_components : int, float, None or str, optional + if 1 < n_components (int), it represents the number of components that + will be keep. if 0 < n_components < 1 (float), it represents the + percentage of variance that the is explained by the components keeped. + See `PCA `_ + for more details. The default is 0.95. + verbose : bool, optional + Enable verbose output. The default is False. + + Returns + ------- + loadings : DataFrame + A DataFrame with the loadings matrix. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + if not isinstance(X, pd.DataFrame): + raise ValueError("X must be a DataFrame") + + if not isinstance(Y, pd.DataFrame): + raise ValueError("Y must be a DataFrame") + + rows = Y.columns.tolist() + cols = X.columns.tolist() + cols.insert(0, "const") + loadings = np.zeros((len(rows), len(cols))) + loadings = pd.DataFrame(loadings, index=rows, columns=cols) + + for i in rows: + if feature_selection == "stepwise": + if stepwise == "Forward": + included = forward_regression( + X, Y[i], criterion=criterion, threshold=threshold, verbose=verbose + ) + elif stepwise == "Backward": + included = backward_regression( + X, Y[i], criterion=criterion, threshold=threshold, verbose=verbose + ) + else: + raise ValueError("Choose and adecuate stepwise method") + results = sm.OLS(Y[i], sm.add_constant(X[included])).fit() + params = results.params + loadings.loc[i, params.index.tolist()] = params.T + elif feature_selection == "PCR": + beta = PCR(X, Y[i], n_components=n_components) + beta = pd.Series(np.ravel(beta), index=cols) + loadings.loc[i, cols] = beta.T + + return loadings + + +def risk_factors( + X, + Y, + B=None, + const=False, + method_mu="hist", + method_cov="hist", + feature_selection="stepwise", + stepwise="Forward", + criterion="pvalue", + threshold=0.05, + n_components=0.95, + error=True, + **kwargs +): + r""" + Estimate the expected returns vector and covariance matrix based on risk + factors models :cite:`b-Ross` :cite:`b-Fan`. + + .. math:: + \begin{aligned} + R & = \alpha + B F + \epsilon \\ + \mu_{f} & = \alpha +BE(F) \\ + \Sigma_{f} & = B \Sigma_{F} B^{T} + \Sigma_{\epsilon} \\ + \end{aligned} + + + where: + + :math:`R` is the series returns. + + :math:`\alpha` is the intercept. + + :math:`B` is the loadings matrix. + + :math:`F` is the expected returns vector of the risk factors. + + :math:`\Sigma_{F}` is the covariance matrix of the risk factors. + + :math:`\Sigma_{\epsilon}` is the covariance matrix of error terms. + + :math:`\mu_{f}` is the expected returns vector obtained with the + risk factor model. + + :math:`\Sigma_{f}` is the covariance matrix obtained with the risk + factor model. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + Y : DataFrame of shape (n_samples, n_assets) + Target matrix, where n_samples in the number of samples and + n_assets is the number of assets. + B : DataFrame of shape (n_assets, n_features), optional + Loadings matrix. If is not specified, is estimated using + stepwise regression. The default is None. + const : bool, optional + Indicate if the loadings matrix has a constant. + The default is False. + method: str, 'stepwise' or 'PCR', optional + Indicate the method used to estimate the loadings matrix. + The default is 'stepwise'. + stepwise: str, 'Forward' or 'Backward' + Indicate the method used for stepwise regression. + The default is 'Forward'. + criterion : str, optional + The default is 'pvalue'. Posible values of the criterion used to select + the best features are: + + - 'pvalue': select the features based on p-values. + - 'AIC': select the features based on lowest Akaike Information Criterion. + - 'SIC': select the features based on lowest Schwarz Information Criterion. + - 'R2': select the features based on highest R Squared. + - 'R2_A': select the features based on highest Adjusted R Squared. + threshold : scalar, optional + Is the maximum p-value for each variable that will be + accepted in the model. The default is 0.05. + n_components : int, float, None or str, optional + if 1 < n_components (int), it represents the number of components that + will be keep. if 0 < n_components < 1 (float), it represents the + percentage of variance that the is explained by the components keeped. + See `PCA `_ + for more details. The default is 0.95. + error : bool + Indicate if diagonal covariance matrix of errors is included (only + when B is estimated through a regression). + **kwargs : dict + Other variables related to the expected returns and covariance estimation. + + Returns + ------- + mu : DataFrame + The mean vector of risk factors model. + cov : DataFrame + The covariance matrix of risk factors model. + returns : DataFrame + The returns based on a risk factor model. + nav : DataFrame + The cumulated uncompound returns based on a risk factor model. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + if not isinstance(X, pd.DataFrame) and not isinstance(Y, pd.DataFrame): + raise ValueError("X and Y must be DataFrames") + + if B is None: + B = loadings_matrix( + X, + Y, + feature_selection=feature_selection, + stepwise=stepwise, + criterion=criterion, + threshold=threshold, + n_components=n_components, + verbose=False, + ) + elif not isinstance(B, pd.DataFrame): + raise ValueError("B must be a DataFrame") + + X1 = X.copy() + if const == True or "const" in B.columns.tolist(): + X1 = sm.add_constant(X) + + assets = Y.columns.tolist() + dates = X.index.tolist() + + mu_f = np.array(mean_vector(X1, method=method_mu, **kwargs), ndmin=2) + S_f = np.array(covar_matrix(X1, method=method_cov, **kwargs), ndmin=2) + B = np.array(B, ndmin=2) + + returns = np.array(X1, ndmin=2) @ B.T + mu = B @ mu_f.T + + if error == True: + e = np.array(Y, ndmin=2) - returns + S_e = np.diag(np.var(np.array(e), ddof=1, axis=0)) + S = B @ S_f @ B.T + S_e + elif error == False: + S = B @ S_f @ B.T + + mu = pd.DataFrame(mu.T, columns=assets) + cov = pd.DataFrame(S, index=assets, columns=assets) + returns = pd.DataFrame(returns, index=dates, columns=assets) + nav = returns.cumsum() + + return mu, cov, returns, nav + + +def black_litterman( + X, w, P, Q, delta=1, rf=0, eq=True, method_mu="hist", method_cov="hist", **kwargs +): + r""" + Estimate the expected returns vector and covariance matrix based + on the Black Litterman model :cite:`b-BlackLitterman` :cite:`b-Black1`. + + .. math:: + \begin{aligned} + \Pi & = \delta \Sigma w \\ + \Pi_{BL} & = \left [ (\tau\Sigma)^{-1}+ P^{T} \Omega^{-1}P \right]^{-1} + \left[(\tau\Sigma)^{-1} \Pi + P^{T} \Omega^{-1} Q \right] \\ + M & = \left((\tau\Sigma)^{-1} + P^{T}\Omega^{-1} P \right)^{-1} \\ + \mu_{BL} & = \Pi_{BL} + r_{f} \\ + \Sigma_{BL} & = \Sigma + M \\ + \end{aligned} + + + where: + + :math:`r_{f}` is the risk free rate. + + :math:`\delta` is the risk aversion factor. + + :math:`\Pi` is the equilibrium excess returns. + + :math:`\Sigma` is the covariance matrix. + + :math:`P` is the views matrix. + + :math:`Q` is the views returns matrix. + + :math:`\Omega` is the covariance matrix of the error views. + + :math:`\mu_{BL}` is the mean vector obtained with the black + litterman model. + + :math:`\Sigma_{BL}` is the covariance matrix obtained with the black + litterman model. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_assets) + Assets matrix, where n_samples is the number of samples and + n_assets is the number of assets. + w : DataFrame of shape (n_assets, 1) + Weights matrix, where n_assets is the number of assets. + P : DataFrame of shape (n_views, n_assets) + Analyst's views matrix, can be relative or absolute. + Q : DataFrame of shape (n_views, 1) + Expected returns of analyst's views. + delta : float, optional + Risk aversion factor. The default value is 1. + rf : scalar, optional + Risk free rate. The default is 0. + eq : bool, optional + Indicate if use equilibrum or historical excess returns. + The default is True. + method_mu : str, can be {'hist', 'ewma1' or 'ewma2'} + The method used to estimate the expected returns. + The default value is 'hist'. + + - 'hist': use historical estimates. + - 'ewma1': use ewma with adjust=True. For more information see `EWM `_. + - 'ewma2': use ewma with adjust=False. For more information see `EWM `_. + method_cov : str, optional + The method used to estimate the covariance matrix: + The default is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1': use ewma with adjust=True. For more information see `EWM `_. + - 'ewma2': use ewma with adjust=False. For more information see `EWM `_. + - 'ledoit': use the Ledoit and Wolf Shrinkage method. + - 'oas': use the Oracle Approximation Shrinkage method. + - 'shrunk': use the basic Shrunk Covariance method. + - 'gl': use the basic Graphical Lasso Covariance method. + - 'jlogo': use the j-LoGo Covariance method. For more information see: :cite:`b-jLogo`. + - 'fixed': denoise using fixed method. For more information see chapter 2 of :cite:`b-MLforAM`. + - 'spectral': denoise using spectral method. For more information see chapter 2 of :cite:`b-MLforAM`. + - 'shrink': denoise using shrink method. For more information see chapter 2 of :cite:`b-MLforAM`. + **kwargs : dict + Other variables related to the expected returns and covariance estimation. + + Returns + ------- + mu : DataFrame + The mean vector of Black Litterman model. + cov : DataFrame + The covariance matrix of Black Litterman model. + w : DataFrame + The equilibrium weights of Black Litterman model, without constraints. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + if not isinstance(X, pd.DataFrame) and not isinstance(w, pd.DataFrame): + raise ValueError("X and w must be DataFrames") + + if w.shape[0] > 1 and w.shape[1] > 1: + raise ValueError("w must be a column DataFrame") + + assets = X.columns.tolist() + + w = np.array(w, ndmin=2) + if w.shape[0] == 1: + w = w.T + + mu = np.array(mean_vector(X, method=method_mu, **kwargs), ndmin=2) + S = np.array(covar_matrix(X, method=method_cov, **kwargs), ndmin=2) + P = np.array(P, ndmin=2) + Q = np.array(Q, ndmin=2) + tau = 1 / X.shape[0] + Omega = np.array(np.diag(np.diag(P @ (tau * S) @ P.T)), ndmin=2) + + if eq == True: + PI = delta * (S @ w) + elif eq == False: + PI = mu.T - rf + + PI_ = inv(inv(tau * S) + P.T @ inv(Omega) @ P) @ ( + inv(tau * S) @ PI + P.T @ inv(Omega) @ Q + ) + M = inv(inv(tau * S) + P.T @ inv(Omega) @ P) + # PI_1 = PI + (tau * S* P.T) * inv(P * tau * S * P.T + Omega) * (Q - P * PI) + # M = tau * S - (tau * S * P.T) * inv(P * tau * S * P.T + Omega) * P * tau * S + + mu = PI_ + rf + mu = mu.T + cov = S + M + w = inv(delta * cov) @ PI_ + + mu = pd.DataFrame(mu, columns=assets) + cov = pd.DataFrame(cov, index=assets, columns=assets) + w = pd.DataFrame(w, index=assets) + + return mu, cov, w + + +def augmented_black_litterman( + X, + w, + F=None, + B=None, + P=None, + Q=None, + P_f=None, + Q_f=None, + delta=1, + rf=0, + eq=True, + const=True, + method_mu="hist", + method_cov="hist", + **kwargs +): + r""" + Estimate the expected returns vector and covariance matrix based + on the Augmented Black Litterman model :cite:`b-WCheung`. + + .. math:: + \begin{aligned} + \Pi^{a} & = \delta \left [ \begin{array}{c} \Sigma \\ \Sigma_{F} B^{T} \\ \end{array} \right ] w \\ + P^{a} & = \left [ \begin{array}{cc} P & 0 \\ 0 & P_{F} \\ \end{array} \right ] \\ + Q^{a} & = \left [ \begin{array}{c} Q \\ Q_{F} \\ \end{array} \right ] \\ + \Sigma^{a} & = \left [ \begin{array}{cc} \Sigma & B \Sigma_{F}\\ \Sigma_{F} B^{T} & \Sigma_{F} \\ \end{array} \right ] \\ + \Omega^{a} & = \left [ \begin{array}{cc} \Omega & 0 \\ 0 & \Omega_{F} \\ \end{array} \right ] \\ + \Pi^{a}_{BL} & = \left [ (\tau \Sigma^{a})^{-1} + (P^{a})^{T} (\Omega^{a})^{-1} P^{a} \right ]^{-1} + \left [ (\tau\Sigma^{a})^{-1} \Pi^{a} + (P^{a})^{T} (\Omega^{a})^{-1} Q^{a} \right ] \\ + M^{a} & = \left ( (\tau\Sigma^{a})^{-1} + (P^{a})^{T} (\Omega^{a})^{-1} P^{a} \right )^{-1} \\ + \mu^{a}_{BL} & = \Pi^{a}_{BL} + r_{f} \\ + \Sigma^{a}_{BL} & = \Sigma^{a} + M^{a} \\ + \end{aligned} + + + where: + + :math:`r_{f}` is the risk free rate. + + :math:`\delta` is the risk aversion factor. + + :math:`B` is the loadings matrix. + + :math:`\Sigma` is the covariance matrix of assets. + + :math:`\Sigma_{F}` is the covariance matrix of factors. + + :math:`\Sigma^{a}` is the augmented covariance matrix. + + :math:`P` is the assets views matrix. + + :math:`Q` is the assets views returns matrix. + + :math:`P_{F}` is the factors views matrix. + + :math:`Q_{F}` is the factors views returns matrix. + + :math:`P^{a}` is the augmented views matrix. + + :math:`Q^{a}` is the augmented views returns matrix. + + :math:`\Pi^{a}` is the augmented equilibrium excess returns. + + :math:`\Omega` is the covariance matrix of errors of assets views. + + :math:`\Omega_{F}` is the covariance matrix of errors of factors views. + + :math:`\Omega^{a}` is the covariance matrix of errors of augmented views. + + :math:`\mu^{a}_{BL}` is the mean vector obtained with the Augmented Black + Litterman model. + + :math:`\Sigma^{a}_{BL}` is the covariance matrix obtained with the Augmented + Black Litterman model. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_assets) + Assets matrix, where n_samples is the number of samples and + n_assets is the number of features. + w : DataFrame of shape (n_assets, 1) + Weights matrix, where n_assets is the number of assets. + F : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + B : DataFrame of shape (n_assets, n_features), optional + Loadings matrix. The default is None. + P : DataFrame of shape (n_views, n_assets) + Analyst's views matrix, can be relative or absolute. + Q : DataFrame of shape (n_views, 1) + Expected returns of analyst's views. + P_f : DataFrame of shape (n_views, n_features) + Analyst's factors views matrix, can be relative or absolute. + Q_f : DataFrame of shape (n_views, 1) + Expected returns of analyst's factors views. + delta : float, optional + Risk aversion factor. The default value is 1. + rf : scalar, optional + Risk free rate. The default is 0. + eq : bool, optional + Indicate if use equilibrum or historical excess returns. + The default is True. + const : bool, optional + Indicate if use equilibrum or historical excess returns. + The default is True. + method_mu : str, can be {'hist', 'ewma1' or 'ewma2'} + The method used to estimate the expected returns. + The default value is 'hist'. + + - 'hist': use historical estimates. + - 'ewma1': use ewma with adjust=True. For more information see `EWM `_. + - 'ewma2': use ewma with adjust=False. For more information see `EWM `_. + method_cov : str, optional + The method used to estimate the covariance matrix: + The default is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1': use ewma with adjust=True. For more information see `EWM `_. + - 'ewma2': use ewma with adjust=False. For more information see `EWM `_. + - 'ledoit': use the Ledoit and Wolf Shrinkage method. + - 'oas': use the Oracle Approximation Shrinkage method. + - 'shrunk': use the basic Shrunk Covariance method. + - 'gl': use the basic Graphical Lasso Covariance method. + - 'jlogo': use the j-LoGo Covariance method. For more information see: :cite:`b-jLogo`. + - 'fixed': denoise using fixed method. For more information see chapter 2 of :cite:`b-MLforAM`. + - 'spectral': denoise using spectral method. For more information see chapter 2 of :cite:`b-MLforAM`. + - 'shrink': denoise using shrink method. For more information see chapter 2 of :cite:`b-MLforAM`. + **kwargs : dict + Other variables related to the expected returns and covariance estimation. + + Returns + ------- + mu : DataFrame + The mean vector of Augmented Black Litterman model. + cov : DataFrame + The covariance matrix of Augmented Black Litterman model. + w : DataFrame + The equilibrium weights of Augmented Black Litterman model, without constraints. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + if not isinstance(X, pd.DataFrame) and not isinstance(w, pd.DataFrame): + raise ValueError("X and w must be DataFrames") + + if not isinstance(F, pd.DataFrame) and not isinstance(B, pd.DataFrame): + raise ValueError("F and B must be DataFrames") + + if w.shape[0] > 1 and w.shape[1] > 1: + raise ValueError("w must be a column DataFrame") + + assets = X.columns.tolist() + N = len(assets) + + w = np.array(w, ndmin=2) + if w.shape[0] == 1: + w = w.T + + if B is not None: + B = np.array(B, ndmin=2) + if const == True: + alpha = B[:, :1] + B = B[:, 1:] + + mu = np.array(mean_vector(X, method=method_mu, **kwargs), ndmin=2) + S = np.array(covar_matrix(X, method=method_cov, **kwargs), ndmin=2) + + tau = 1 / X.shape[0] + + if F is not None: + mu_f = np.array(mean_vector(F, method=method_mu, **kwargs), ndmin=2) + S_f = np.array(covar_matrix(F, method=method_cov, **kwargs), ndmin=2) + + if P is not None and Q is not None and P_f is None and Q_f is None: + S_a = S + P_a = P + Q_a = Q + Omega = np.array(np.diag(np.diag(P @ (tau * S) @ P.T)), ndmin=2) + Omega_a = Omega + + if eq == True: + PI_a_ = delta * S_a @ w + elif eq == False: + PI_a_ = mu.T - rf + elif P is None and Q is None and P_f is not None and Q_f is not None: + S_a = S_f + P_a = P_f + Q_a = Q_f + Omega_f = np.array(np.diag(np.diag(P_f @ (tau * S_f) @ P_f.T)), ndmin=2) + Omega_a = Omega_f + + if eq == True: + PI_a_ = delta * (S_f @ B.T) @ w + elif eq == False: + PI_a_ = mu_f.T - rf + + elif P is not None and Q is not None and P_f is not None and Q_f is not None: + S_a = np.hstack((np.vstack((S, S_f @ B.T)), np.vstack((B @ S_f, S_f)))) + + P = np.array(P, ndmin=2) + Q = np.array(Q, ndmin=2) + P_f = np.array(P_f, ndmin=2) + Q_f = np.array(Q_f, ndmin=2) + zeros_1 = np.zeros((P_f.shape[0], P.shape[1])) + zeros_2 = np.zeros((P.shape[0], P_f.shape[1])) + P_a = np.hstack((np.vstack((P, zeros_1)), np.vstack((zeros_2, P_f)))) + Q_a = np.vstack((Q, Q_f)) + + Omega = np.array(np.diag(np.diag(P @ (tau * S) @ P.T)), ndmin=2) + Omega_f = np.array(np.diag(np.diag(P_f @ (tau * S_f) @ P_f.T)), ndmin=2) + zeros = np.zeros((Omega.shape[0], Omega_f.shape[0])) + Omega_a = np.hstack((np.vstack((Omega, zeros.T)), np.vstack((zeros, Omega_f)))) + + if eq == True: + PI_a_ = delta * (np.vstack((S, S_f @ B.T)) @ w) + elif eq == False: + PI_a_ = np.vstack((mu.T, mu_f.T)) - rf + + PI_a = inv(inv(tau * S_a) + P_a.T @ inv(Omega_a) @ P_a) @ ( + inv(tau * S_a) @ PI_a_ + P_a.T @ inv(Omega_a) @ Q_a + ) + M_a = inv(inv(tau * S_a) + P_a.T @ inv(Omega_a) @ P_a) + # PI_a = PI_a_ + (tau * S_a @ P_a.T) * inv(P_a @ tau * S_a @ P_a.T + Omega) * (Q_a - P_a @ PI_a_) + # M = tau * S_a - (tau * S_a @ P_a.T) * inv(P_a @ tau * S_a @ P_a.T + Omega_a) @ P_a @ tau * S_a + + mu_a = PI_a + rf + mu_a = mu_a.T + cov_a = S_a + M_a + w_a = inv(delta * cov_a) @ PI_a + + if P is None and Q is None and P_f is not None and Q_f is not None: + mu_a = mu_a @ B.T + cov_a = B @ cov_a @ B.T + w_a = inv(delta * cov_a) @ B @ PI_a + + if const == True: + mu_a = mu_a[:, :N] + alpha.T + + mu_a = pd.DataFrame(mu_a[:, :N], columns=assets) + cov_a = pd.DataFrame(cov_a[:N, :N], index=assets, columns=assets) + w_a = pd.DataFrame(w_a[:N, 0], index=assets) + + return mu_a, cov_a, w_a + + +def black_litterman_bayesian( + X, + F, + B, + P_f, + Q_f, + delta=1, + rf=0, + eq=True, + const=True, + diag=True, + method_mu="hist", + method_cov="hist", + **kwargs +): + r""" + Estimate the expected returns vector and covariance matrix based + on the black litterman model :cite:`b-BLB`. + + .. math:: + \begin{aligned} + \Sigma_{F} & = B \Sigma_{F} B^{T} + D \\ + \overline{\Pi}_{F} & = \left ( \Sigma_{F}^{-1} + P_{F}^{T}\Omega_{F}^{-1}P_{F} \right )^{-1} \left ( \Sigma_{F}^{-1}\Pi_{F} + P_{F}^{T}\Omega_{F}^{-1}Q_{F} \right) \\ + \overline{\Sigma}_{F} & = \left ( \Sigma_{F}^{-1} + P_{F}^{T}\Omega_{F}^{-1}P_{F} \right )^{-1} \\ + \Sigma_{BLB} & = \left( \Sigma^{-1} - \Sigma^{-1} B \left( \overline{\Sigma}_{F}^{-1} + B^{T}\Sigma^{-1}B \right)^{-1} B^{T}\Sigma^{-1} \right )^{-1} \\ + \mu_{BLB} & = \Sigma_{BLB} \left ( \Sigma^{-1} B \left( \overline{\Sigma}_{F}^{-1} +B^{T}\Sigma^{-1}B \right)^{-1} \overline{\Sigma}_{F}^{-1} \overline{\Pi}_{F} \right ) + r_{f} \\ + \end{aligned} + + + where: + + :math:`r_{f}` is the risk free rate. + + :math:`B` is the loadings matrix. + + :math:`D` is a diagonal matrix of variance of errors of a factor model. + + :math:`\Sigma` is the covariance matrix obtained with a factor model. + + :math:`\Pi_{F}` is the equilibrium excess returns of factors. + + :math:`\overline{\Pi}_{F}` is the posterior excess returns of factors. + + :math:`\Sigma_{F}` is the covariance matrix of factors. + + :math:`\overline{\Sigma}_{F}` is the posterior covariance matrix of factors. + + :math:`P_{F}` is the factors views matrix. + + :math:`Q_{F}` is the factors views returns matrix. + + :math:`\Omega_{F}` is the covariance matrix of errors of factors views. + + :math:`\mu_{BLB}` is the mean vector obtained with the Black + Litterman Bayesian model or posterior predictive mean. + + :math:`\Sigma_{BLB}` is the covariance matrix obtained with the Black + Litterman Bayesian model or posterior predictive covariance. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_assets) + Assets matrix, where n_samples is the number of samples and + n_assets is the number of assets. + F : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + B : DataFrame of shape (n_assets, n_features), optional + Loadings matrix. The default is None. + P_f : DataFrame of shape (n_views, n_features) + Analyst's factors views matrix, can be relative or absolute. + Q_f : DataFrame of shape (n_views, 1) + Expected returns of analyst's factors views. + delta : float, optional + Risk aversion factor. The default value is 1. + rf : scalar, optional + Risk free rate. The default is 0. + eq : bool, optional + Indicate if use equilibrum or historical excess returns. + The default is True. + const : bool, optional + Indicate if the loadings matrix has a constant. + The default is True. + diag : bool, optional + Indicate if we use the diagonal matrix to calculate covariance matrix + of factor model, only useful when we work with a factor model based on + a regresion model (only equity portfolio). + The default is True. + method_mu : str, can be {'hist', 'ewma1' or 'ewma2'} + The method used to estimate the expected returns. + The default value is 'hist'. + + - 'hist': use historical estimates. + - 'ewma1': use ewma with adjust=True. For more information see `EWM `_. + - 'ewma2': use ewma with adjust=False, For more information see `EWM `_. + method_cov : str, optional + The method used to estimate the covariance matrix: + The default is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1': use ewma with adjust=True. For more information see `EWM `_. + - 'ewma2': use ewma with adjust=False. For more information see `EWM `_. + - 'ledoit': use the Ledoit and Wolf Shrinkage method. + - 'oas': use the Oracle Approximation Shrinkage method. + - 'shrunk': use the basic Shrunk Covariance method. + - 'gl': use the basic Graphical Lasso Covariance method. + - 'jlogo': use the j-LoGo Covariance method. For more information see: :cite:`b-jLogo`. + - 'fixed': denoise using fixed method. For more information see chapter 2 of :cite:`b-MLforAM`. + - 'spectral': denoise using spectral method. For more information see chapter 2 of :cite:`b-MLforAM`. + - 'shrink': denoise using shrink method. For more information see chapter 2 of :cite:`b-MLforAM`. + **kwargs : dict + Other variables related to the expected returns and covariance estimation. + + Returns + ------- + mu : DataFrame + The mean vector of Black Litterman model. + cov : DataFrame + The covariance matrix of Black Litterman model. + w : DataFrame + The equilibrium weights of Black Litterman model, without constraints. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + if not isinstance(X, pd.DataFrame): + raise ValueError("X must be DataFrames") + + if not isinstance(F, pd.DataFrame) and not isinstance(B, pd.DataFrame): + raise ValueError("F and B must be DataFrames") + + assets = X.columns.tolist() + + if B is not None: + B = np.array(B, ndmin=2) + if const == True: + alpha = B[:, :1] + B = B[:, 1:] + + mu_f = np.array(mean_vector(F, method=method_mu, **kwargs), ndmin=2) + mu_f = (mu_f - rf).T + + tau = 1 / X.shape[0] + + S_f = np.array(covar_matrix(F, method=method_cov, **kwargs), ndmin=2) + S = B @ S_f @ B.T + + if diag == True: + D = X.to_numpy() - F @ B.T + D = np.diag(D.var()) + S = S + D + + Omega_f = np.array(np.diag(np.diag(P_f @ (tau * S_f) @ P_f.T)), ndmin=2) + + S_hat = inv(inv(S_f) + P_f.T @ inv(Omega_f) @ P_f) + + Pi_hat = S_hat @ (inv(S_f) @ mu_f + P_f.T @ inv(Omega_f) @ Q_f) + + S_blb = inv(inv(S) - inv(S) @ B @ inv(inv(S_hat) + B.T @ inv(S) @ B) @ B.T @ inv(S)) + + Pi_blb = ( + S_blb @ inv(S) @ B @ inv(inv(S_hat) + B.T @ inv(S) @ B) @ inv(S_hat) @ Pi_hat + ) + + mu = Pi_blb + rf + + if const == True: + mu = mu + alpha + mu = mu.T + cov = S_blb + w = inv(delta * cov) @ mu.T + + mu = pd.DataFrame(mu, columns=assets) + cov = pd.DataFrame(cov, index=assets, columns=assets) + w = pd.DataFrame(w, index=assets) + + return mu, cov, w + + +def bootstrapping(X, kind="stationary", q=0.05, n_sim=3000, window=3, seed=0): + r""" + Estimates the uncertainty sets of mean and covariance matrix through the selected + bootstrapping method. + + Parameters + ---------- + X : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + kind : str + The bootstrapping method. The default value is 'stationary'. Posible values are: + + - 'stationary': stationary bootstrapping method, see `StationaryBootstrap `_ for more details. + - 'circular': circular bootstrapping method, see `CircularBlockBootstrap `_ for more details. + - 'moving': moving bootstrapping method, see `MovingBlockBootstrap `_ for more details. + q : scalar + Significance level of the selected bootstrapping method. + The default is 0.05. + n_sim : scalar + Number of simulations of the bootstrapping method. + The default is 3000. + window: + Block size of the bootstrapping method. Must be greather than 1 + and lower than the n_samples - n_features + 1 + The default is 3. + seed: + Seed used to generate random numbers for bootstrapping method. + The default is 0. + + Returns + ------- + mu_l : DataFrame + The q/2 percentile of mean vector obtained through the selected bootstrapping method. + mu_u : DataFrame + The 1-q/2 percentile of mean vector obtained through the selected bootstrapping method. + cov_l : DataFrame + The q/2 percentile of covariance matrix obtained through the selected bootstrapping method. + cov_u : DataFrame + The 1-q/2 percentile of covariance matrix obtained through the selected bootstrapping method. + cov_mu : DataFrame + The covariance matrix of estimation errors of mean vector obtained through the selected bootstrapping method. + We take the diagonal of this matrix following :cite:`b-fabozzi2007robust`. + + Raises + ------ + ValueError + When the value cannot be calculated. + + """ + + if not isinstance(X, pd.DataFrame): + raise ValueError("X must be a DataFrame") + + if window >= X.shape[0] - window + 1: + raise ValueError("block must be lower than n_samples - window + 1") + elif window <= 1: + raise ValueError("block must be greather than 1") + + cols = X.columns.tolist() + cols_2 = [i + "-" + j for i in cols for j in cols] + m = len(cols) + mus = np.zeros((n_sim, 1, m)) + covs = np.zeros((n_sim, m, m)) + + if kind == "stationary": + gen = bs.StationaryBootstrap(window, X, seed=seed) + elif kind == "circular": + gen = bs.CircularBlockBootstrap(window, X, seed=seed) + elif kind == "moving": + gen = bs.MovingBlockBootstrap(window, X, seed=seed) + else: + raise ValueError("kind only can be 'stationary', 'circular' or 'moving'") + + i = 0 + for data in gen.bootstrap(n_sim): + A = data[0][0] + mus[i] = A.mean().to_numpy().reshape(1, m) + covs[i] = A.cov().to_numpy() + i += 1 + + mu_l = np.percentile(mus, q / 2 * 100, axis=0, keepdims=True).reshape(1, m) + mu_u = np.percentile(mus, 100 - q / 2 * 100, axis=0, keepdims=True).reshape(1, m) + + cov_l = np.percentile(covs, q / 2 * 100, axis=0, keepdims=True).reshape(m, m) + cov_u = np.percentile(covs, 100 - q / 2 * 100, axis=0, keepdims=True).reshape(m, m) + + mu_l = pd.DataFrame(mu_l, index=[0], columns=cols) + mu_u = pd.DataFrame(mu_u, index=[0], columns=cols) + + cov_l = pd.DataFrame(cov_l, index=cols, columns=cols) + cov_u = pd.DataFrame(cov_u, index=cols, columns=cols) + + cov_mu = mus.reshape(n_sim, m) - X.mean().to_numpy().reshape(1, m) + cov_mu = np.cov(cov_mu.T) + + cov_mu = np.diag(np.diag(cov_mu)) + cov_mu = pd.DataFrame(cov_mu, index=cols, columns=cols) + + cov_sigma = covs - X.cov().to_numpy() + cov_sigma = cov_sigma.reshape((n_sim, m * m), order="F") + cov_sigma = np.cov(cov_sigma.T) + + cov_sigma = np.diag(np.diag(cov_sigma)) + cov_sigma = pd.DataFrame(cov_sigma, index=cols_2, columns=cols_2) + + if af.is_pos_def(cov_l) == False: + cov_l = af.cov_fix(cov_l, method="clipped", threshold=1e-3) + + if af.is_pos_def(cov_u) == False: + cov_u = af.cov_fix(cov_u, method="clipped", threshold=1e-3) + + return mu_l, mu_u, cov_l, cov_u, cov_mu, cov_sigma diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/PlotFunctions.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/PlotFunctions.py new file mode 100644 index 00000000000..f61f5d8019b --- /dev/null +++ b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/PlotFunctions.py @@ -0,0 +1,2736 @@ +"""""" # +""" +Copyright (c) 2020-2022, Dany Cajas +All rights reserved. +This work is licensed under BSD 3-Clause "New" or "Revised" License. +License available at https://github.com/dcajasn/Riskfolio-Lib/blob/master/LICENSE.txt +""" + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import matplotlib.lines as mlines +import matplotlib.ticker as mticker +from matplotlib import cm +from matplotlib.gridspec import GridSpec +from matplotlib.patches import Patch +import scipy.stats as st +import scipy.cluster.hierarchy as hr +from scipy.spatial.distance import squareform +import networkx as nx +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.RiskFunctions as rk +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.AuxFunctions as af +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.DBHT as db + +__all__ = [ + "plot_series", + "plot_frontier", + "plot_pie", + "plot_bar", + "plot_frontier_area", + "plot_risk_con", + "plot_hist", + "plot_range", + "plot_drawdown", + "plot_table", + "plot_clusters", + "plot_dendrogram", + "plot_network", +] + +rm_names = [ + "Standard Deviation", + "Mean Absolute Deviation", + "Semi Standard Deviation", + "Value at Risk", + "Conditional Value at Risk", + "Entropic Value at Risk", + "Worst Realization", + "First Lower Partial Moment", + "Second Lower Partial Moment", + "Max Drawdown", + "Average Drawdown", + "Drawdown at Risk", + "Conditional Drawdown at Risk", + "Entropic Drawdown at Risk", + "Ulcer Index", + "Gini Mean Difference", + "Tail Gini", + "Range", + "Conditional Value at Risk Range", + "Tail Gini Range", +] + +rmeasures = [ + "MV", + "MAD", + "MSV", + "VaR", + "CVaR", + "EVaR", + "WR", + "FLPM", + "SLPM", + "MDD", + "ADD", + "DaR", + "CDaR", + "EDaR", + "UCI", + "GMD", + "TG", + "RG", + "CVRG", + "TGRG", +] + + +def plot_series(returns, w, cmap="tab20", height=6, width=10, ax=None): + r""" + Create a chart with the compound cumulated of the portfolios. + + Parameters + ---------- + returns : DataFrame + Assets returns. + w : DataFrame of shape (n_assets, n_portfolios) + Portfolio weights. + cmap : cmap, optional + Colorscale, represente the risk adjusted return ratio. + The default is 'tab20'. + height : float, optional + Height of the image in inches. The default is 6. + width : float, optional + Width of the image in inches. The default is 10. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_series(returns=Y, w=ws, cmap='tab20', height=6, width=10, + ax=None) + + .. image:: images/Port_Series.png + + + """ + if not isinstance(returns, pd.DataFrame): + raise ValueError("returns must be a DataFrame") + + if not isinstance(w, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if returns.shape[1] != w.shape[0]: + a1 = str(returns.shape) + a2 = str(w.shape) + raise ValueError("shapes " + a1 + " and " + a2 + " not aligned") + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + ax.grid(linestyle=":") + title = "Historical Compounded Cumulative Returns" + ax.set_title(title) + + labels = w.columns.tolist() + + colormap = cm.get_cmap(cmap) + colormap = colormap(np.linspace(0, 1, 20)) + + if cmap == "gist_rainbow": + colormap = colormap[::-1] + + cycle = plt.cycler("color", colormap) + ax.set_prop_cycle(cycle) + + X = w.columns.tolist() + index = returns.index.tolist() + + for i in range(len(X)): + a = np.array(returns, ndmin=2) @ np.array(w[X[i]], ndmin=2).T + prices = 1 + np.insert(a, 0, 0, axis=0) + prices = np.cumprod(prices, axis=0) + prices = np.ravel(prices).tolist() + del prices[0] + + ax.plot_date(index, prices, "-", label=labels[i]) + + ax.xaxis.set_major_locator(mdates.AutoDateLocator(tz=None, minticks=5, maxticks=10)) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) + + ticks_loc = ax.get_yticks().tolist() + ax.set_yticks(ax.get_yticks().tolist()) + ax.set_yticklabels(["{:3.2f}".format(x) for x in ticks_loc]) + ax.legend(loc="center left", bbox_to_anchor=(1, 0.5)) + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_frontier( + w_frontier, + mu, + cov=None, + returns=None, + rm="MV", + kelly=False, + rf=0, + alpha=0.05, + a_sim=100, + beta=None, + b_sim=None, + cmap="viridis", + w=None, + label="Portfolio", + marker="*", + s=16, + c="r", + height=6, + width=10, + t_factor=252, + ax=None, +): + r""" + Creates a plot of the efficient frontier for a risk measure specified by + the user. + + Parameters + ---------- + w_frontier : DataFrame + Portfolio weights of some points in the efficient frontier. + mu : DataFrame of shape (1, n_assets) + Vector of expected returns, where n_assets is the number of assets. + cov : DataFrame of shape (n_features, n_features) + Covariance matrix, where n_features is the number of features. + returns : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + rm : str, optional + The risk measure used to estimate the frontier. + The default is 'MV'. Posible values are: + + - 'MV': Standard Deviation. + - 'MAD': Mean Absolute Deviation. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'WR': Worst Realization (Minimax). + - 'RG': Range of returns. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'MDD': Maximum Drawdown of uncompounded returns (Calmar Ratio). + - 'ADD': Average Drawdown of uncompounded cumulative returns. + - 'DaR': Drawdown at Risk of uncompounded cumulative returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'EDaR': Entropic Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + + kelly : bool, optional + Method used to calculate mean return. Posible values are False for + arithmetic mean return and True for mean logarithmic return. The default + is False. + rf : float, optional + Risk free rate or minimum aceptable return. The default is 0. + alpha : float, optional + Significante level of VaR, CVaR, Tail Gini, EVaR, CDaR and EDaR. The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of CVaR and Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + cmap : cmap, optional + Colorscale, represente the risk adjusted return ratio. + The default is 'viridis'. + w : DataFrame of shape (n_assets, 1), optional + A portfolio specified by the user. The default is None. + label : str, optional + Name of portfolio that appear on plot legend. + The default is 'Portfolio'. + marker : str, optional + Marker of w. The default is "*". + s : float, optional + Size of marker. The default is 16. + c : str, optional + Color of marker. The default is 'r'. + height : float, optional + Height of the image in inches. The default is 6. + width : float, optional + Width of the image in inches. The default is 10. + t_factor : float, optional + Factor used to annualize expected return and expected risks for + risk measures based on returns (not drawdowns). The default is 252. + + .. math:: + + \begin{align} + \text{Annualized Return} & = \text{Return} \, \times \, \text{t_factor} \\ + \text{Annualized Risk} & = \text{Risk} \, \times \, \sqrt{\text{t_factor}} + \end{align} + + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib Axes + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + label = 'Max Risk Adjusted Return Portfolio' + mu = port.mu + cov = port.cov + returns = port.returns + + ax = rp.plot_frontier(w_frontier=ws, mu=mu, cov=cov, returns=returns, + rm=rm, rf=0, alpha=0.05, cmap='viridis', w=w1, + label=label, marker='*', s=16, c='r', + height=6, width=10, t_factor=252, ax=None) + + .. image:: images/MSV_Frontier.png + + + """ + + if not isinstance(w_frontier, pd.DataFrame): + raise ValueError("w_frontier must be a DataFrame") + + if not isinstance(mu, pd.DataFrame): + raise ValueError("mu must be a DataFrame") + + if not isinstance(cov, pd.DataFrame): + raise ValueError("cov must be a DataFrame") + + if not isinstance(returns, pd.DataFrame): + raise ValueError("returns must be a DataFrame") + + if returns.shape[1] != w_frontier.shape[0]: + a1 = str(returns.shape) + a2 = str(w_frontier.shape) + raise ValueError("shapes " + a1 + " and " + a2 + " not aligned") + + if w is not None: + if not isinstance(w, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if w.shape[1] > 1 and w.shape[0] == 0: + w = w.T + elif w.shape[1] > 1 and w.shape[0] > 0: + raise ValueError("w must be a column DataFrame") + + if returns.shape[1] != w.shape[0]: + a1 = str(returns.shape) + a2 = str(w.shape) + raise ValueError("shapes " + a1 + " and " + a2 + " not aligned") + + if beta is None: + beta = alpha + if b_sim is None: + b_sim = a_sim + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + mu_ = np.array(mu, ndmin=2) + + if kelly == False: + ax.set_ylabel("Expected Arithmetic Return") + elif kelly == True: + ax.set_ylabel("Expected Logarithmic Return") + + item = rmeasures.index(rm) + x_label = rm_names[item] + " (" + rm + ")" + ax.set_xlabel("Expected Risk - " + x_label) + + title = "Efficient Frontier Mean - " + x_label + ax.set_title(title) + + X1 = [] + Y1 = [] + Z1 = [] + + for i in range(w_frontier.shape[1]): + weights = np.array(w_frontier.iloc[:, i], ndmin=2).T + risk = rk.Sharpe_Risk( + weights, + cov=cov, + returns=returns, + rm=rm, + rf=rf, + alpha=alpha, + a_sim=a_sim, + beta=beta, + b_sim=b_sim, + ) + + if kelly == False: + ret = mu_ @ weights + elif kelly == True: + ret = 1 / returns.shape[0] * np.sum(np.log(1 + returns @ weights)) + ret = ret.item() * t_factor + + if rm not in ["MDD", "ADD", "CDaR", "EDaR", "UCI"]: + risk = risk * t_factor**0.5 + + ratio = (ret - rf) / risk + + X1.append(risk) + Y1.append(ret) + Z1.append(ratio) + + ax1 = ax.scatter(X1, Y1, c=Z1, cmap=cmap) + + if w is not None: + X2 = [] + Y2 = [] + for i in range(w.shape[1]): + weights = np.array(w.iloc[:, i], ndmin=2).T + risk = rk.Sharpe_Risk( + weights, + cov=cov, + returns=returns, + rm=rm, + rf=rf, + alpha=alpha, + a_sim=a_sim, + beta=beta, + b_sim=b_sim, + ) + if kelly == False: + ret = mu_ @ weights + elif kelly == True: + ret = 1 / returns.shape[0] * np.sum(np.log(1 + returns @ weights)) + ret = ret.item() * t_factor + + if rm not in ["MDD", "ADD", "CDaR", "EDaR", "UCI"]: + risk = risk * t_factor**0.5 + + X2.append(risk) + Y2.append(ret) + + ax.scatter(X2, Y2, marker=marker, s=s**2, c=c, label=label) + ax.legend(loc="upper left") + + xmin = np.min(X1) - np.abs(np.max(X1) - np.min(X1)) * 0.1 + xmax = np.max(X1) + np.abs(np.max(X1) - np.min(X1)) * 0.1 + ymin = np.min(Y1) - np.abs(np.max(Y1) - np.min(Y1)) * 0.1 + ymax = np.max(Y1) + np.abs(np.max(Y1) - np.min(Y1)) * 0.1 + + ax.set_ylim(ymin, ymax) + ax.set_xlim(xmin, xmax) + + ax.xaxis.set_major_locator(plt.AutoLocator()) + + ticks_loc = ax.get_yticks().tolist() + ax.set_yticks(ax.get_yticks().tolist()) + ax.set_yticklabels(["{:.2%}".format(x) for x in ticks_loc]) + ticks_loc = ax.get_xticks().tolist() + ax.set_xticks(ax.get_xticks().tolist()) + ax.set_xticklabels(["{:.2%}".format(x) for x in ticks_loc]) + + ax.tick_params(axis="y", direction="in") + ax.tick_params(axis="x", direction="in") + + ax.grid(linestyle=":") + + colorbar = ax.figure.colorbar(ax1) + colorbar.set_label("Risk Adjusted Return Ratio") + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_pie( + w, title="", others=0.05, nrow=25, cmap="tab20", height=6, width=8, ax=None +): + r""" + Create a pie chart with portfolio weights. + + Parameters + ---------- + w : DataFrame of shape (n_assets, 1) + Portfolio weights. + title : str, optional + Title of the chart. The default is "". + others : float, optional + Percentage of others section. The default is 0.05. + nrow : int, optional + Number of rows of the legend. The default is 25. + cmap : cmap, optional + Color scale used to plot each asset weight. + The default is 'tab20'. + height : float, optional + Height of the image in inches. The default is 10. + width : float, optional + Width of the image in inches. The default is 10. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis. + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_pie(w=w1, title='Portafolio', height=6, width=10, + cmap="tab20", ax=None) + + .. image:: images/Pie_Chart.png + + + """ + + if not isinstance(w, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if w.shape[1] > 1 and w.shape[0] == 0: + w = w.T + elif w.shape[1] > 1 and w.shape[0] > 0: + raise ValueError("w must be a column DataFrame") + + if ax is None: + ax = plt.gca() + fig = plt.gcf() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + labels = w.index.tolist() + sizes = w.iloc[:, 0].tolist() + abs_sizes = [np.absolute(s) for s in sizes] + sizes2 = pd.DataFrame([labels, abs_sizes, sizes]).T + sizes2.columns = ["labels", "abs_values", "values"] + sizes2 = sizes2.sort_values(by=["abs_values"], ascending=False) + sizes2.index = [i for i in range(0, len(labels))] + sizes3 = sizes2.cumsum() + sizes3["abs_values"] = sizes3["abs_values"] / sizes3["abs_values"].max() + l = sizes3[sizes3["abs_values"] >= 1 - others].index.tolist()[0] + + a1 = sizes2["abs_values"].sum() - sizes2[sizes2.index <= l]["abs_values"].sum() + a2 = sizes2["values"].sum() - sizes2[sizes2.index <= l]["values"].sum() + item = pd.DataFrame(["Others", a1, a2]).T + item.columns = ["labels", "abs_values", "values"] + sizes2 = sizes2[sizes2.index <= l] + sizes2 = sizes2.append(item) + + abs_sizes = sizes2["abs_values"].tolist() + sizes = sizes2["values"].tolist() + labels = sizes2["labels"].tolist() + sizes2 = ["{0:.1%}".format(i) for i in sizes] + + if title == "": + title = "Portfolio Composition" + + limit = np.round(np.min(sizes), 4) + if limit < 0: + title += " (Areas in Absolute Values)" + + ax.set_title(title) + + colormap = cm.get_cmap(cmap) + colormap = colormap(np.linspace(0, 1, 20)) + + if cmap == "gist_rainbow": + colormap = colormap[::-1] + + cycle = plt.cycler("color", colormap) + ax.set_prop_cycle(cycle) + + size = 0.4 + + # set up style cycles + + wedges, texts = ax.pie( + abs_sizes, + radius=1, + wedgeprops=dict(width=size, edgecolor="black"), + startangle=-15, + normalize=True, + ) + + # Equal aspect ratio ensures that pie is drawn as a circle. + + ax.axis("equal") + + n = int(np.ceil(l / nrow)) + + ax.legend(wedges, labels, loc="center left", bbox_to_anchor=(1, 0.5), ncol=n) + + bbox_props = dict(boxstyle="square,pad=0.3", fc="w", ec="k", lw=0.72) + kw = dict( + xycoords="data", + textcoords="data", + arrowprops=dict(arrowstyle="-"), + bbox=bbox_props, + zorder=0, + va="center", + ) + + for i, p in enumerate(wedges): + ang = (p.theta2 - p.theta1) / 2.0 + p.theta1 + y = np.sin(np.deg2rad(ang)) + x = np.cos(np.deg2rad(ang)) + horizontalalignment = {-1: "right", 1: "left"}[int(np.sign(x))] + connectionstyle = "angle,angleA=0,angleB={}".format(ang) + kw["arrowprops"].update({"connectionstyle": connectionstyle}) + name = str(labels[i]) + " " + str(sizes2[i]) + ax.annotate( + name, + xy=(x, y), + xytext=(1.1 * np.sign(x), 1.1 * y), + horizontalalignment=horizontalalignment, + **kw + ) + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_bar( + w, + title="", + kind="v", + others=0.05, + nrow=25, + cpos="tab:green", + cneg="darkorange", + cothers="dodgerblue", + height=6, + width=10, + ax=None, +): + r""" + Create a bar chart with portfolio weights. + + Parameters + ---------- + w : DataFrame of shape (n_assets, 1) + Portfolio weights. + title : str, optional + Title of the chart. The default is "". + kind : str, optional + Kind of bar plot, "v" for vertical bars and "h" for horizontal bars. + The default is "v". + others : float, optional + Percentage of others section. The default is 0.05. + nrow : int, optional + Max number of bars that be plotted. The default is 25. + cpos : str, optional + Color for positives weights. The default is 'tab:green'. + cneg : str, optional + Color for negatives weights. The default is 'darkorange'. + cothers : str, optional + Color for others bar. The default is 'dodgerblue'. + height : float, optional + Height of the image in inches. The default is 10. + width : float, optional + Width of the image in inches. The default is 10. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis. + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_bar(w, title='Portafolio', kind="v", others=0.05, + nrow=25, height=6, width=10, ax=None) + + .. image:: images/Bar_Chart.png + + + """ + + if not isinstance(w, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if w.shape[1] > 1 and w.shape[0] == 0: + w = w.T + elif w.shape[1] > 1 and w.shape[0] > 0: + raise ValueError("w must be a column DataFrame") + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + labels = w.index.tolist() + sizes = w.iloc[:, 0].tolist() + abs_sizes = [np.absolute(s) for s in sizes] + sizes2 = pd.DataFrame([labels, abs_sizes, sizes]).T + sizes2.columns = ["labels", "abs_values", "values"] + sizes2 = sizes2.sort_values(by=["abs_values"], ascending=False) + sizes2.index = [i for i in range(0, len(labels))] + sizes3 = sizes2.cumsum() + sizes3["abs_values"] = sizes3["abs_values"] / sizes3["abs_values"].max() + + l1 = sizes3[sizes3["abs_values"] >= 1 - others].index.tolist() + if len(l1) > 0: + l1 = l1[0] + else: + l1 = -1 + + l2 = sizes2[sizes2["abs_values"] < 0.01].index.tolist() + if len(l2) > 0: + l2 = l2[0] + else: + l2 = -1 + + if l1 > nrow: + a1 = sizes2["abs_values"].sum() - sizes2[sizes2.index <= l1]["abs_values"].sum() + a2 = sizes2["values"].sum() - sizes2[sizes2.index <= l1]["values"].sum() + item = pd.DataFrame(["Others", a1, a2]).T + item.columns = ["labels", "abs_values", "values"] + sizes2 = sizes2[sizes2.index <= l1] + sizes2 = sizes2.sort_values(by=["values"], ascending=False) + sizes2 = sizes2.append(item) + elif l2 > 0: + a1 = sizes2["abs_values"].sum() - sizes2[sizes2.index <= l2]["abs_values"].sum() + a2 = sizes2["values"].sum() - sizes2[sizes2.index <= l2]["values"].sum() + item = pd.DataFrame(["Others", a1, a2]).T + item.columns = ["labels", "abs_values", "values"] + sizes2 = sizes2[sizes2.index <= l2] + sizes2 = sizes2.sort_values(by=["values"], ascending=False) + sizes2 = sizes2.append(item) + else: + sizes2 = sizes2.sort_values(by=["values"], ascending=False) + + sizes = sizes2["values"].tolist() + labels = sizes2["labels"].tolist() + sizes2 = ["{0:.1%}".format(i) for i in sizes] + + if title == "": + title = "Portfolio Composition" + + ax.set_title(title) + + if kind == "v": + sizes = np.array(sizes) + labels = np.array(labels) + ax.bar(labels, np.where(sizes >= 0, sizes, 0), color=cpos, width=0.5) + ax.bar(labels, np.where(sizes < 0, sizes, 0), color=cneg, width=0.5) + + if l1 > nrow: + ax.bar( + labels, np.where(labels == "Others", sizes, 0), color=cothers, width=0.5 + ) + b = "Others (Sum Abs < " + "{:.1%}".format(others) + ")" + elif l2 > 0: + ax.bar( + labels, np.where(labels == "Others", sizes, 0), color=cothers, width=0.5 + ) + b = "Others (Abs < " + "{:.1%}".format(0.01) + ")" + + ticks_loc = ax.get_yticks().tolist() + ax.set_yticks(ax.get_yticks().tolist()) + ax.set_yticklabels(["{:.2%}".format(x) for x in ticks_loc]) + ax.set_xlim(-0.5, len(sizes) - 0.5) + + r = plt.gcf().canvas.get_renderer() + transf = ax.transData.inverted() + + for i, v in enumerate(sizes): + t = ax.text(i, v, sizes2[i], color="black") + bb = t.get_window_extent(renderer=r) + bb = bb.transformed(transf) + h_text = bb.height + x_text = bb.x0 - (bb.x1 - bb.x0) * 0.4 + y_text = bb.y0 + if v >= 0: + t.set_position((x_text, y_text + h_text * 0.8)) + else: + t.set_position((x_text, y_text - h_text)) + + elif kind == "h": + sizes.reverse() + labels.reverse() + sizes2.reverse() + sizes = np.array(sizes) + labels = np.array(labels) + ax.barh(labels, np.where(sizes >= 0, sizes, 0), color=cpos, height=0.5) + ax.barh(labels, np.where(sizes < 0, sizes, 0), color=cneg, height=0.5) + + if l1 > nrow: + ax.barh( + labels, + np.where(labels == "Others", sizes, 0), + color=cothers, + height=0.5, + ) + b = "Others (Sum Abs < " + "{:.1%}".format(others) + ")" + elif l2 > 0: + ax.barh( + labels, + np.where(labels == "Others", sizes, 0), + color=cothers, + height=0.5, + ) + b = "Others (Abs < " + "{:.1%}".format(0.01) + ")" + else: + b = None + + ticks_loc = ax.get_xticks().tolist() + ax.set_xticks(ax.get_xticks().tolist()) + ax.set_xticklabels(["{:.2%}".format(x) for x in ticks_loc]) + ax.set_ylim(-0.5, len(sizes) - 0.5) + + r = plt.gcf().canvas.get_renderer() + transf = ax.transData.inverted() + + for i, v in enumerate(sizes): + t = ax.text(v, i, sizes2[i], color="black") + bb = t.get_window_extent(renderer=r) + bb = bb.transformed(transf) + w_text = bb.width + x_text = bb.x0 + y_text = bb.y0 + if v >= 0: + t.set_position((x_text + w_text * 0.15, y_text)) + else: + t.set_position((x_text - w_text, y_text)) + + ax.grid(linestyle=":") + + if b is None: + ax.legend(["Positive Weights", "Negative Weights"]) + else: + ax.legend(["Positive Weights", "Negative Weights", b]) + + if kind == "v": + ax.axhline(y=0, xmin=0, xmax=1, color="gray", label=False) + elif kind == "h": + ax.axvline(x=0, ymin=0, ymax=1, color="gray", label=False) + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_frontier_area(w_frontier, nrow=25, cmap="tab20", height=6, width=10, ax=None): + r""" + Create a chart with the asset composition of the efficient frontier. + + Parameters + ---------- + w_frontier : DataFrame + Weights of portfolios in the efficient frontier. + nrow : int, optional + Number of rows of the legend. The default is 25. + cmap : cmap, optional + Color scale used to plot each asset weight. + The default is 'tab20'. + height : float, optional + Height of the image in inches. The default is 6. + width : float, optional + Width of the image in inches. The default is 10. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis. + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_frontier_area(w_frontier=ws, cmap="tab20", height=6, + width=10, ax=None) + + .. image:: images/Area_Frontier.png + + + """ + + if not isinstance(w_frontier, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + ax.set_title("Efficient Frontier's Assets Structure") + labels = w_frontier.index.tolist() + + colormap = cm.get_cmap(cmap) + colormap = colormap(np.linspace(0, 1, 20)) + + if cmap == "gist_rainbow": + colormap = colormap[::-1] + + cycle = plt.cycler("color", colormap) + ax.set_prop_cycle(cycle) + + X = w_frontier.columns.tolist() + + ax.stackplot(X, w_frontier, labels=labels, alpha=0.7, edgecolor="black") + + ax.set_ylim(0, 1) + ax.set_xlim(0, len(X) - 1) + + ticks_loc = ax.get_yticks().tolist() + ax.set_yticks(ax.get_yticks().tolist()) + ax.set_yticklabels(["{:3.2%}".format(x) for x in ticks_loc]) + ax.grid(linestyle=":") + + n = int(np.ceil(len(labels) / nrow)) + + ax.legend(labels, loc="center left", bbox_to_anchor=(1, 0.5), ncol=n) + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_risk_con( + w, + cov=None, + returns=None, + rm="MV", + rf=0, + alpha=0.05, + a_sim=100, + beta=None, + b_sim=None, + color="tab:blue", + height=6, + width=10, + t_factor=252, + ax=None, +): + r""" + Create a chart with the risk contribution per asset of the portfolio. + + Parameters + ---------- + w : DataFrame of shape (n_assets, 1) + Portfolio weights. + cov : DataFrame of shape (n_features, n_features) + Covariance matrix, where n_features is the number of features. + returns : DataFrame of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + rm : str, optional + Risk measure used to estimate risk contribution. + The default is 'MV'. Posible values are: + + - 'MV': Standard Deviation. + - 'MAD': Mean Absolute Deviation. + - 'GMD': Gini Mean Difference. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'WR': Worst Realization (Minimax). + - 'RG': Range of returns. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'MDD': Maximum Drawdown of uncompounded cumulative returns (Calmar Ratio). + - 'ADD': Average Drawdown of uncompounded cumulative returns. + - 'DaR': Drawdown at Risk of uncompounded cumulative returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + + rf : float, optional + Risk free rate or minimum aceptable return. The default is 0. + alpha : float, optional + Significante level of VaR, CVaR, Tail Gini, EVaR, CDaR and EDaR. The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of CVaR and Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + color : str, optional + Color used to plot each asset risk contribution. + The default is 'tab:blue'. + height : float, optional + Height of the image in inches. The default is 6. + width : float, optional + Width of the image in inches. The default is 10. + t_factor : float, optional + Factor used to annualize expected return and expected risks for + risk measures based on returns (not drawdowns). The default is 252. + + .. math:: + + \begin{align} + \text{Annualized Return} & = \text{Return} \, \times \, \text{t_factor} \\ + \text{Annualized Risk} & = \text{Risk} \, \times \, \sqrt{\text{t_factor}} + \end{align} + + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis. + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_risk_con(w=w2, cov=cov, returns=returns, rm=rm, + rf=0, alpha=0.05, color="tab:blue", height=6, + width=10, t_factor=252, ax=None) + + .. image:: images/Risk_Con.png + + + """ + + if not isinstance(w, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if beta is None: + beta = alpha + if b_sim is None: + b_sim = a_sim + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + item = rmeasures.index(rm) + title = "Risk (" + rm_names[item] + ") Contribution per Asset" + ax.set_title(title) + + X = w.index.tolist() + + RC = rk.Risk_Contribution( + w, + cov=cov, + returns=returns, + rm=rm, + rf=rf, + alpha=alpha, + a_sim=a_sim, + beta=beta, + b_sim=b_sim, + ) + + if rm not in ["MDD", "ADD", "CDaR", "EDaR", "UCI"]: + RC = RC * t_factor**0.5 + + ax.bar(X, RC, alpha=0.7, color=color, edgecolor="black") + + ax.set_xlim(-0.5, len(X) - 0.5) + ax.tick_params(axis="x", labelrotation=90) + + ticks_loc = ax.get_yticks().tolist() + ax.set_yticks(ax.get_yticks()) + ax.set_yticklabels(["{:3.4%}".format(x) for x in ticks_loc]) + ax.grid(linestyle=":") + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_hist(returns, w, alpha=0.05, a_sim=100, bins=50, height=6, width=10, ax=None): + r""" + Create a histogram of portfolio returns with the risk measures. + + Parameters + ---------- + returns : DataFrame + Assets returns. + w : DataFrame of shape (n_assets, 1) + Portfolio weights. + alpha : float, optional + Significante level of VaR, CVaR, Tail Gini and EVaR. The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + bins : float, optional + Number of bins of the histogram. The default is 50. + height : float, optional + Height of the image in inches. The default is 6. + width : float, optional + Width of the image in inches. The default is 10. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis. + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_hist(returns=Y, w=w1, alpha=0.05, bins=50, height=6, + width=10, ax=None) + + .. image:: images/Histogram.png + + + """ + + if not isinstance(returns, pd.DataFrame): + raise ValueError("returns must be a DataFrame") + + if not isinstance(w, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if w.shape[1] > 1 and w.shape[0] == 0: + w = w.T + elif w.shape[1] > 1 and w.shape[0] > 0: + raise ValueError("w must be a DataFrame") + + if returns.shape[1] != w.shape[0]: + a1 = str(returns.shape) + a2 = str(w.shape) + raise ValueError("shapes " + a1 + " and " + a2 + " not aligned") + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + a = np.array(returns, ndmin=2) @ np.array(w, ndmin=2) + ax.set_title("Portfolio Returns Histogram") + n, bins1, patches = ax.hist( + a, bins, density=1, edgecolor="skyblue", color="skyblue", alpha=0.5 + ) + mu = np.mean(a) + sigma = np.std(a, axis=0, ddof=1).item() + risk = [ + mu, + mu - sigma, + mu - rk.MAD(a), + mu - rk.GMD(a), + -rk.VaR_Hist(a, alpha), + -rk.CVaR_Hist(a, alpha), + -rk.TG(a, alpha), + -rk.EVaR_Hist(a, alpha)[0], + -rk.WR(a), + ] + label = [ + "Mean: " + "{0:.2%}".format(risk[0]), + "Mean - Std. Dev.(" + + "{0:.2%}".format(-risk[1] + mu) + + "): " + + "{0:.2%}".format(risk[1]), + "Mean - MAD(" + + "{0:.2%}".format(-risk[2] + mu) + + "): " + + "{0:.2%}".format(risk[2]), + "Mean - GMD(" + + "{0:.2%}".format(-risk[3] + mu) + + "): " + + "{0:.2%}".format(risk[3]), + "{0:.2%}".format((1 - alpha)) + " Confidence VaR: " + "{0:.2%}".format(risk[4]), + "{0:.2%}".format((1 - alpha)) + + " Confidence CVaR: " + + "{0:.2%}".format(risk[5]), + "{0:.2%}".format((1 - alpha)) + + " Confidence Tail Gini: " + + "{0:.2%}".format(risk[6]), + "{0:.2%}".format((1 - alpha)) + + " Confidence EVaR: " + + "{0:.2%}".format(risk[7]), + "Worst Realization: " + "{0:.2%}".format(risk[8]), + ] + color = [ + "b", + "r", + "fuchsia", + "navy", + "darkorange", + "limegreen", + "mediumvioletred", + "dodgerblue", + "darkgrey", + ] + + for i, j, k in zip(risk, label, color): + ax.axvline(x=i, color=k, linestyle="-", label=j) + + # add a 'best fit' line + y = (1 / (np.sqrt(2 * np.pi) * sigma)) * np.exp( + -0.5 * (1 / sigma * (bins1 - mu)) ** 2 + ) + ax.plot( + bins1, + y, + "--", + color="orange", + label="Normal: $\mu=" + + "{0:.2%}".format(mu) + + "$%, $\sigma=" + + "{0:.2%}".format(sigma) + + "$%", + ) + + factor = (np.max(a) - np.min(a)) / bins + + ax.xaxis.set_major_locator(plt.AutoLocator()) + ticks_loc = ax.get_xticks().tolist() + ax.set_xticks(ax.get_xticks()) + ax.set_xticklabels(["{:3.2%}".format(x) for x in ticks_loc]) + ticks_loc = ax.get_yticks().tolist() + ax.set_yticks(ax.get_yticks()) + ax.set_yticklabels(["{:3.2%}".format(x * factor) for x in ticks_loc]) + ax.legend(loc="upper right") # , fontsize = 'x-small') + ax.grid(linestyle=":") + ax.set_ylabel("Probability Density") + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_range( + returns, + w, + alpha=0.05, + a_sim=100, + beta=None, + b_sim=None, + bins=50, + height=6, + width=10, + ax=None, +): + r""" + Create a histogram of portfolio returns with the range risk measures. + + Parameters + ---------- + returns : DataFrame + Assets returns. + w : DataFrame of shape (n_assets, 1) + Portfolio weights. + alpha : float, optional + Significance level of CVaR and Tail Gini of losses. + The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of CVaR and Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + bins : float, optional + Number of bins of the histogram. The default is 50. + height : float, optional + Height of the image in inches. The default is 6. + width : float, optional + Width of the image in inches. The default is 10. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis. + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = plot_range(returns=Y, w=w1, alpha=0.05, a_sim=100, beta=None, + b_sim=None, bins=50, height=6, width=10, ax=None) + + .. image:: images/Range.png + + + """ + + if not isinstance(returns, pd.DataFrame): + raise ValueError("returns must be a DataFrame") + + if not isinstance(w, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if w.shape[1] > 1 and w.shape[0] == 0: + w = w.T + elif w.shape[1] > 1 and w.shape[0] > 0: + raise ValueError("w must be a DataFrame") + + if returns.shape[1] != w.shape[0]: + a1 = str(returns.shape) + a2 = str(w.shape) + raise ValueError("shapes " + a1 + " and " + a2 + " not aligned") + + if beta is None: + beta = alpha + if b_sim is None: + b_sim = a_sim + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + a = np.array(returns, ndmin=2) @ np.array(w, ndmin=2) + ax.set_title("Portfolio Returns Range") + + df = dict( + risk=["Range", "Tail Gini Range", "CVaR Range"], + lower=[], + upper=[], + ) + + df["lower"].append(np.min(a)) + df["lower"].append(-rk.TG(a, alpha=alpha, a_sim=a_sim)) + df["lower"].append(-rk.CVaR_Hist(a, alpha=alpha)) + df["upper"].append(-np.min(-a)) + df["upper"].append(rk.TG(-a, alpha=beta, a_sim=b_sim)) + df["upper"].append(rk.CVaR_Hist(-a, alpha=beta)) + df = pd.DataFrame(df) + df.set_index("risk", inplace=True) + + # Func to draw line segment + def newline(p1, p2, color="black"): + ax = fig.gca() + l = mlines.Line2D([p1[0], p2[0]], [p1[1], p2[1]], color=color) + ax.add_line(l) + return l + + n, _, _ = ax.hist(a, bins=bins, density=True, color="darkgrey", alpha=0.3) + + risk = [ + rk.RG(a), + rk.CVRG(a, alpha=alpha, beta=beta), + rk.TGRG(a, alpha=alpha, a_sim=a_sim, beta=beta, b_sim=b_sim), + ] + + label = [ + "Range :" + "{0:.2%}".format(risk[0]), + "Tail Gini Range (" + + "{0:.1%}".format((1 - alpha)) + + ", " + + "{0:.1%}".format((1 - beta)) + + "): " + + "{0:.2%}".format(risk[1]), + "CVaR Range (" + + "{0:.1%}".format((1 - alpha)) + + ", " + + "{0:.1%}".format((1 - beta)) + + "): " + + "{0:.2%}".format(risk[2]), + ] + + colors = ["dodgerblue", "fuchsia", "limegreen"] + + y_max = np.ceil(n.max()) + + j = 1 + for i in df.index: + x1 = df.loc[i, "lower"] + x2 = df.loc[i, "upper"] + y1 = j * y_max / 4 + ax.vlines( + x=x1, + ymin=0, + ymax=y1, + color=colors[j - 1], + alpha=1, + linewidth=1, + linestyles="dashed", + ) + ax.vlines( + x=x2, + ymin=0, + ymax=y1, + color=colors[j - 1], + alpha=1, + linewidth=1, + linestyles="dashed", + ) + ax.scatter(y=y1, x=x1, s=50, color=colors[j - 1], alpha=1, label=label[j - 1]) + ax.scatter(y=y1, x=x2, s=50, color=colors[j - 1], alpha=1) + newline([x1, y1], [x2, y1], color=colors[j - 1]) + j += 1 + + ax.set(ylim=(0, y_max)) + + factor = (np.max(a) - np.min(a)) / bins + ax.xaxis.set_major_locator(plt.AutoLocator()) + ticks_loc = ax.get_xticks().tolist() + ax.set_xticks(ax.get_xticks()) + ax.set_xticklabels(["{:3.2%}".format(x) for x in ticks_loc]) + ticks_loc = ax.get_yticks().tolist() + ax.set_yticks(ax.get_yticks()) + ax.set_yticklabels(["{:3.2%}".format(x * factor) for x in ticks_loc]) + ax.legend(loc="upper right") # , fontsize = 'x-small') + ax.grid(linestyle=":") + ax.set_ylabel("Probability Density") + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_drawdown(nav, w, alpha=0.05, height=8, width=10, ax=None): + r""" + Create a chart with the evolution of portfolio prices and drawdown. + + Parameters + ---------- + nav : DataFrame + Cumulative assets returns. + w : DataFrame, optional + A portfolio specified by the user to compare with the efficient + frontier. The default is None. + alpha : float, optional + Significante level of DaR and CDaR. The default is 0.05. + height : float, optional + Height of the image in inches. The default is 8. + width : float, optional + Width of the image in inches. The default is 10. + ax : matplotlib axis of size (2,1), optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : np.array + Returns the a np.array with Axes objects with plots for further tweaking. + + Example + ------- + :: + + nav=port.nav + + ax = rp.plot_drawdown(nav=nav, w=w1, alpha=0.05, height=8, width=10, ax=None) + + .. image:: images/Drawdown.png + + + """ + + if not isinstance(nav, pd.DataFrame): + raise ValueError("nav must be a DataFrame") + + if not isinstance(w, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if w.shape[1] > 1 and w.shape[0] == 0: + w = w.T + elif w.shape[1] > 1 and w.shape[0] > 0: + raise ValueError("w must be a DataFrame") + + if nav.shape[1] != w.shape[0]: + a1 = str(nav.shape) + a2 = str(w.shape) + raise ValueError("shapes " + a1 + " and " + a2 + " not aligned") + + if ax is None: + fig = plt.gcf() + ax = fig.subplots(nrows=2, ncols=1) + ax = np.ravel(ax) + fig.set_figwidth(width) + fig.set_figheight(height) + else: + if isinstance(ax, plt.Axes): + fig = ax.get_figure() + gs = GridSpec(2, 1, figure=fig) + ax.set_position(gs[0].get_position(fig)) + ax.set_subplotspec(gs[0]) + fig.add_subplot(gs[1]) + ax = fig.axes + elif ( + len(np.ravel(ax)) > 2 + or not isinstance(ax[0], plt.Axes) + or not isinstance(ax[1], plt.Axes) + ): + print("ax must be an array with two Axes or a subplot with 2 rows") + return + ax = np.ravel(ax) + fig = ax[0].get_figure() + + index = nav.index.tolist() + a = nav.to_numpy() + a = np.insert(a, 0, 0, axis=0) + a = np.diff(a, axis=0) + a = a @ w.to_numpy() + prices = 1 + np.insert(a, 0, 0, axis=0) + prices = np.cumprod(prices, axis=0) + prices = np.ravel(prices).tolist() + prices2 = 1 + np.array(np.cumsum(a, axis=0)) + prices2 = np.ravel(prices2).tolist() + del prices[0] + + DD = [] + peak = -99999 + for i in range(0, len(prices)): + if prices2[i] > peak: + peak = prices2[i] + DD.append((peak - prices2[i])) + DD = -np.array(DD) + titles = [ + "Historical Compounded Cumulative Returns", + "Historical Uncompounded Drawdown", + ] + data = [prices, DD] + color1 = ["b", "orange"] + risk = [ + -rk.UCI_Abs(a), + -rk.ADD_Abs(a), + -rk.DaR_Abs(a, alpha), + -rk.CDaR_Abs(a, alpha), + -rk.EDaR_Abs(a, alpha)[0], + -rk.MDD_Abs(a), + ] + label = [ + "Ulcer Index: " + "{0:.2%}".format(risk[0]), + "Average Drawdown: " + "{0:.2%}".format(risk[1]), + "{0:.2%}".format((1 - alpha)) + " Confidence DaR: " + "{0:.2%}".format(risk[2]), + "{0:.2%}".format((1 - alpha)) + + " Confidence CDaR: " + + "{0:.2%}".format(risk[3]), + "{0:.2%}".format((1 - alpha)) + + " Confidence EDaR: " + + "{0:.2%}".format(risk[4]), + "Maximum Drawdown: " + "{0:.2%}".format(risk[5]), + ] + color2 = ["b", "r", "fuchsia", "limegreen", "dodgerblue", "darkgrey"] + + j = 0 + + ymin = np.min(DD) * 1.5 + + locator = mdates.AutoDateLocator(minticks=5, maxticks=10) + formatter = mdates.DateFormatter("%Y-%m") + for i in ax: + i.clear() + i.plot_date(index, data[j], "-", color=color1[j]) + if j == 1: + i.fill_between(index, 0, data[j], facecolor=color1[j], alpha=0.3) + for k in range(0, len(risk)): + i.axhline(y=risk[k], color=color2[k], linestyle="-", label=label[k]) + i.set_ylim(ymin, 0) + i.legend(loc="lower right") # , fontsize = 'x-small') + i.set_title(titles[j]) + i.xaxis.set_major_locator(locator) + i.xaxis.set_major_formatter(formatter) + ticks_loc = i.get_yticks().tolist() + i.set_yticks(i.get_yticks()) + i.set_yticklabels(["{:3.2%}".format(x) for x in ticks_loc]) + i.grid(linestyle=":") + j = j + 1 + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_table( + returns, + w, + MAR=0, + alpha=0.05, + height=9, + width=12, + t_factor=252, + ini_days=1, + days_per_year=252, + ax=None, +): + r""" + Create a table with information about risk measures and risk adjusted + return ratios. + + Parameters + ---------- + returns : DataFrame + Assets returns. + w : DataFrame + Portfolio weights. + MAR: float, optional + Minimum acceptable return. + alpha: float, optional + Significance level for VaR, CVaR, EVaR, DaR and CDaR. + height : float, optional + Height of the image in inches. The default is 9. + width : float, optional + Width of the image in inches. The default is 12. + t_factor : float, optional + Factor used to annualize expected return and expected risks for + risk measures based on returns (not drawdowns). The default is 252. + + .. math:: + + \begin{align} + \text{Annualized Return} & = \text{Return} \, \times \, \text{t_factor} \\ + \text{Annualized Risk} & = \text{Risk} \, \times \, \sqrt{\text{t_factor}} + \end{align} + + ini_days : float, optional + If provided, it is the number of days of compounding for first return. + It is used to calculate Compound Annual Growth Rate (CAGR). This value + depend on assumptions used in t_factor, for example if data is monthly + you can use 21 (252 days per year) or 30 (360 days per year). The + default is 1 for daily returns. + days_per_year: float, optional + Days per year assumption. It is used to calculate Compound Annual + Growth Rate (CAGR). Default value is 252 trading days per year. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_table(returns=Y, w=w1, MAR=0, alpha=0.05, ax=None) + + .. image:: images/Port_Table.png + + + """ + if not isinstance(returns, pd.DataFrame): + raise ValueError("returns must be a DataFrame") + + if not isinstance(w, pd.DataFrame): + raise ValueError("w must be a DataFrame") + + if returns.shape[1] != w.shape[0]: + a1 = str(returns.shape) + a2 = str(w.shape) + raise ValueError("shapes " + a1 + " and " + a2 + " not aligned") + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + mu = returns.mean() + cov = returns.cov() + days = (returns.index[-1] - returns.index[0]).days + ini_days + + X = returns @ w + X = X.to_numpy().ravel() + + rowLabels = [ + "Profitability and Other Inputs", + "Mean Return (1)", + "Compound Annual Growth Rate (CAGR)", + "Minimum Acceptable Return (MAR) (1)", + "Significance Level", + "", + "Risk Measures based on Returns", + "Standard Deviation (2)", + "Mean Absolute Deviation (MAD) (2)", + "Semi Standard Deviation (2)", + "First Lower Partial Moment (FLPM) (2)", + "Second Lower Partial Moment (SLPM) (2)", + "Value at Risk (VaR) (2)", + "Conditional Value at Risk (CVaR) (2)", + "Entropic Value at Risk (EVaR) (2)", + "Worst Realization (2)", + "Skewness", + "Kurtosis", + "", + "Risk Measures based on Drawdowns (3)", + "Ulcer Index (UCI)", + "Average Drawdown (ADD)", + "Drawdown at Risk (DaR)", + "Conditional Drawdown at Risk (CDaR)", + "Entropic Drawdown at Risk (EDaR)", + "Max Drawdown (MDD)", + "(1) Annualized, multiplied by " + str(t_factor), + "(2) Annualized, multiplied by √" + str(t_factor), + "(3) Based on uncompounded cumulated returns", + ] + + indicators = [ + "", + (mu @ w).to_numpy().item() * t_factor, + np.power(np.prod(1 + X), days_per_year / days) - 1, + MAR, + alpha, + "", + "", + np.sqrt(w.T @ cov @ w).to_numpy().item() * t_factor**0.5, + rk.MAD(X) * t_factor**0.5, + rk.SemiDeviation(X) * t_factor**0.5, + rk.LPM(X, MAR=MAR, p=1) * t_factor**0.5, + rk.LPM(X, MAR=MAR, p=2) * t_factor**0.5, + rk.VaR_Hist(X, alpha=alpha) * t_factor**0.5, + rk.CVaR_Hist(X, alpha=alpha) * t_factor**0.5, + rk.EVaR_Hist(X, alpha=alpha)[0] * t_factor**0.5, + rk.WR(X) * t_factor**0.5, + st.skew(X, bias=False), + st.kurtosis(X, bias=False), + "", + "", + rk.UCI_Abs(X), + rk.ADD_Abs(X), + rk.DaR_Abs(X), + rk.CDaR_Abs(X, alpha=alpha), + rk.EDaR_Abs(X, alpha=alpha)[0], + rk.MDD_Abs(X), + "", + "", + "", + ] + + ratios = [] + for i in range(len(indicators)): + if i < 6 or indicators[i] == "" or rowLabels[i] in ["Skewness", "Kurtosis"]: + ratios.append("") + else: + if indicators[i] == 0: + ratios.append("") + else: + ratio = (indicators[1] - MAR) / indicators[i] + ratios.append(ratio) + + for i in range(len(indicators)): + if indicators[i] != "": + if rowLabels[i] in ["Skewness", "Kurtosis"]: + indicators[i] = "{:.5f}".format(indicators[i]) + else: + indicators[i] = "{:.4%}".format(indicators[i]) + if ratios[i] != "": + ratios[i] = "{:.6f}".format(ratios[i]) + + data = pd.DataFrame({"A": rowLabels, "B": indicators, "C": ratios}).to_numpy() + + ax.set_axis_off() + ax.axis("tight") + ax.axis("off") + + colLabels = ["", "Values", "(Return - MAR)/Risk"] + colWidths = [0.45, 0.275, 0.275] + rowHeight = 0.07 + + table = ax.table( + cellText=data, + colLabels=colLabels, + colWidths=colWidths, + cellLoc="center", + loc="upper left", + bbox=[-0.03, 0, 1, 1], + ) + + table.auto_set_font_size(False) + + cellDict = table.get_celld() + k = 1 + + rowHeight = 1 / len(rowLabels) + ncols = len(colLabels) + nrows = len(rowLabels) + + for i in range(0, ncols): + cellDict[(0, i)].set_text_props(weight="bold", color="white", size="x-large") + cellDict[(0, i)].set_facecolor("darkblue") + cellDict[(0, i)].set_edgecolor("white") + cellDict[(0, i)].set_height(rowHeight) + for j in range(1, nrows + 1): + cellDict[(j, 0)].set_text_props( + weight="bold", color="black", size="x-large", ha="left" + ) + cellDict[(j, i)].set_text_props(color="black", size="x-large") + cellDict[(j, 0)].set_edgecolor("white") + cellDict[(j, i)].set_edgecolor("white") + if k % 2 != 0: + cellDict[(j, 0)].set_facecolor("whitesmoke") + cellDict[(j, i)].set_facecolor("whitesmoke") + if j in [6, 19]: + cellDict[(j, 0)].set_facecolor("white") + cellDict[(j, i)].set_facecolor("white") + if j in [1, 7, 20]: + cellDict[(j, 0)].set_text_props(color="white") + cellDict[(j, 0)].set_facecolor("orange") + cellDict[(j, i)].set_facecolor("orange") + k = 1 + k += 1 + + cellDict[(j, i)].set_height(rowHeight) + + for i in range(0, ncols): + for j in range(nrows - 2, nrows + 1): + cellDict[(j, i)].set_text_props( + weight="normal", color="black", size="large" + ) + cellDict[(j, i)].set_facecolor("white") + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_clusters( + returns, + custom_cov=None, + codependence="pearson", + linkage="ward", + k=None, + max_k=10, + bins_info="KN", + alpha_tail=0.05, + leaf_order=True, + dendrogram=True, + cmap="viridis", + linecolor="fuchsia", + title="", + height=12, + width=12, + ax=None, +): + r""" + Create a clustermap plot based on the selected codependence measure. + + Parameters + ---------- + returns : DataFrame + Assets returns. + custom_cov : DataFrame or None, optional + Custom covariance matrix, used when codependence parameter has value + 'custom_cov'. The default is None. + codependence : str, can be {'pearson', 'spearman', 'abs_pearson', 'abs_spearman', 'distance', 'mutual_info', 'tail' or 'custom_cov'} + The codependence or similarity matrix used to build the distance + metric and clusters. The default is 'pearson'. Posible values are: + + - 'pearson': pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho_{i,j})}`. + - 'spearman': spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho_{i,j})}`. + - 'abs_pearson': absolute value pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho_{i,j}|)}`. + - 'abs_spearman': absolute value spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho_{i,j}|)}`. + - 'distance': distance correlation matrix. Distance formula :math:`D_{i,j} = \sqrt{(1-|\rho_{i,j}|)}`. + - 'mutual_info': mutual information matrix. Distance used is variation information matrix. + - 'tail': lower tail dependence index matrix. Dissimilarity formula :math:`D_{i,j} = -\log{\lambda_{i,j}}`. + - 'custom_cov': use custom correlation matrix based on the custom_cov parameter. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{pearson}_{i,j})}`. + + linkage : string, optional + Linkage method of hierarchical clustering, see `linkage `_ for more details. + The default is 'ward'. Posible values are: + + - 'single'. + - 'complete'. + - 'average'. + - 'weighted'. + - 'centroid'. + - 'median'. + - 'ward'. + - 'DBHT': Direct Bubble Hierarchical Tree. + + k : int, optional + Number of clusters. This value is took instead of the optimal number + of clusters calculated with the two difference gap statistic. + The default is None. + max_k : int, optional + Max number of clusters used by the two difference gap statistic + to find the optimal number of clusters. The default is 10. + bins_info: int or str + Number of bins used to calculate variation of information. The default + value is 'KN'. Posible values are: + + - 'KN': Knuth's choice method. See more in `knuth_bin_width `_. + - 'FD': Freedman–Diaconis' choice method. See more in `freedman_bin_width `_. + - 'SC': Scotts' choice method. See more in `scott_bin_width `_. + - 'HGR': Hacine-Gharbi and Ravier' choice method. + - int: integer value choice by user. + + alpha_tail : float, optional + Significance level for lower tail dependence index. The default is 0.05. + leaf_order : bool, optional + Indicates if the cluster are ordered so that the distance between + successive leaves is minimal. The default is True. + dendrogram : bool, optional + Indicates if the plot has or not a dendrogram. The default is True. + cmap : str or cmap, optional + Colormap used to plot the pcolormesh plot. The default is 'viridis'. + linecolor : str, optional + Color used to identify the clusters in the pcolormesh plot. + The default is fuchsia'. + title : str, optional + Title of the chart. The default is "". + height : float, optional + Height of the image in inches. The default is 12. + width : float, optional + Width of the image in inches. The default is 12. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_clusters(returns=Y, correlation='spearman', + linkage='ward', k=None, max_k=10, + leaf_order=True, dendrogram=True, ax=None) + + .. image:: images/Assets_Clusters.png + + + """ + + if not isinstance(returns, pd.DataFrame): + raise ValueError("returns must be a DataFrame") + + if ax is None: + fig = plt.gcf() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + ax.grid(False) + ax.axis("off") + + labels = np.array(returns.columns.tolist()) + + vmin, vmax = 0, 1 + # Calculating codependence matrix and distance metric + if codependence in {"pearson", "spearman"}: + codep = returns.corr(method=codependence) + dist = np.sqrt(np.clip((1 - codep) / 2, a_min=0.0, a_max=1.0)) + vmin, vmax = -1, 1 + elif codependence in {"abs_pearson", "abs_spearman"}: + codep = np.abs(returns.corr(method=codependence[4:])) + dist = np.sqrt(np.clip((1 - codep), a_min=0.0, a_max=1.0)) + elif codependence in {"distance"}: + codep = af.dcorr_matrix(returns).astype(float) + dist = np.sqrt(np.clip((1 - codep), a_min=0.0, a_max=1.0)) + elif codependence in {"mutual_info"}: + codep = af.mutual_info_matrix(returns, bins_info).astype(float) + dist = af.var_info_matrix(returns, bins_info).astype(float) + elif codependence in {"tail"}: + codep = af.ltdi_matrix(returns, alpha_tail).astype(float) + dist = -np.log(codep) + elif codependence in {"custom_cov"}: + codep = af.cov2corr(custom_cov).astype(float) + dist = np.sqrt(np.clip((1 - codep) / 2, a_min=0.0, a_max=1.0)) + + # Hierarchcial clustering + dist = dist.to_numpy() + dist = pd.DataFrame(dist, columns=codep.columns, index=codep.index) + dim = len(dist) + if linkage == "DBHT": + # different choices for D, S give different outputs! + D = dist.to_numpy() # dissimilatity matrix + if codependence in {"pearson", "spearman", "custom_cov"}: + S = (1 - dist**2).to_numpy() + else: + S = codep.to_numpy() # similarity matrix + (_, _, _, _, _, clustering) = db.DBHTs( + D, S, leaf_order=leaf_order + ) # DBHT clustering + else: + p_dist = squareform(dist, checks=False) + clustering = hr.linkage(p_dist, method=linkage, optimal_ordering=leaf_order) + + # Ordering clusterings + permutation = hr.leaves_list(clustering) + permutation = permutation.tolist() + ordered_codep = codep.to_numpy()[permutation, :][:, permutation] + + # optimal number of clusters + if k is None: + k = af.two_diff_gap_stat(codep, dist, clustering, max_k) + + clustering_inds = hr.fcluster(clustering, k, criterion="maxclust") + clusters = {i: [] for i in range(min(clustering_inds), max(clustering_inds) + 1)} + for i, v in enumerate(clustering_inds): + clusters[v].append(i) + + ax = fig.add_axes([0.3, 0.1, 0.6, 0.6]) + + im = ax.pcolormesh(ordered_codep, cmap=cmap, vmin=vmin, vmax=vmax) + ax.set_xticks(np.arange(codep.shape[0]) + 0.5, minor=False) + ax.set_yticks(np.arange(codep.shape[0]) + 0.5, minor=False) + ax.set_xticklabels(labels[permutation], rotation=90, ha="center") + ax.set_yticklabels(labels[permutation], va="center") + ax.yaxis.set_label_position("right") + ax.yaxis.tick_right() + + if linecolor is not None: + for cluster_id, cluster in clusters.items(): + + amin = permutation.index(cluster[0]) + xmin, xmax = amin, amin + len(cluster) + ymin, ymax = amin, amin + len(cluster) + + for i in cluster: + a = permutation.index(i) + if a < amin: + xmin, xmax = a, a + len(cluster) + ymin, ymax = a, a + len(cluster) + amin = a + + ax.axvline( + x=xmin, ymin=ymin / dim, ymax=(ymax) / dim, linewidth=4, color=linecolor + ) + ax.axvline( + x=xmax, ymin=ymin / dim, ymax=(ymax) / dim, linewidth=4, color=linecolor + ) + ax.axhline( + y=ymin, xmin=xmin / dim, xmax=(xmax) / dim, linewidth=4, color=linecolor + ) + ax.axhline( + y=ymax, xmin=xmin / dim, xmax=(xmax) / dim, linewidth=4, color=linecolor + ) + + axcolor = fig.add_axes([1.02, 0.1, 0.02, 0.6]) + plt.colorbar(im, cax=axcolor) + + if dendrogram == True: + + ax1 = fig.add_axes([0.3, 0.71, 0.6, 0.2]) + + root, nodes = hr.to_tree(clustering, rd=True) + nodes = [i.dist for i in nodes] + nodes.sort() + nodes = nodes[::-1][: k - 1] + color_threshold = np.min(nodes) + + colors = af.color_list(k) + + hr.set_link_color_palette(colors) + hr.dendrogram( + clustering, + color_threshold=color_threshold, + above_threshold_color="grey", + ax=ax1, + ) + hr.set_link_color_palette(None) + ax1.xaxis.set_major_locator(mticker.FixedLocator(np.arange(codep.shape[0]))) + ax1.set_xticklabels(labels[permutation], rotation=90, ha="center") + + i = 0 + for coll in ax1.collections[:-1]: # the last collection is the ungrouped level + xmin, xmax = np.inf, -np.inf + ymax = -np.inf + for p in coll.get_paths(): + (x0, _), (x1, y1) = p.get_extents().get_points() + xmin = min(xmin, x0) + xmax = max(xmax, x1) + ymax = max(ymax, y1) + rec = plt.Rectangle( + (xmin - 4, 0), + xmax - xmin + 8, + ymax * 1.05, + facecolor=colors[i], # coll.get_color()[0], + alpha=0.2, + edgecolor="none", + ) + ax1.add_patch(rec) + i += 1 + + ax1.set_xticks([]) + ax1.set_yticks([]) + + for i in {"right", "left", "top", "bottom"}: + side = ax1.spines[i] + side.set_visible(False) + + ax2 = fig.add_axes([0.09, 0.1, 0.2, 0.6]) + + hr.set_link_color_palette(colors) + hr.dendrogram( + clustering, + color_threshold=color_threshold, + above_threshold_color="grey", + orientation="left", + ax=ax2, + ) + hr.set_link_color_palette(None) + + ax2.xaxis.set_major_locator(mticker.FixedLocator(np.arange(codep.shape[0]))) + ax2.set_xticklabels(labels[permutation], rotation=90, ha="center") + + i = 0 + for coll in ax2.collections[:-1]: # the last collection is the ungrouped level + ymin, ymax = np.inf, -np.inf + xmax = -np.inf + for p in coll.get_paths(): + (_, y0), (x1, y1) = p.get_extents().get_points() + ymin = min(ymin, y0) + ymax = max(ymax, y1) + xmax = max(xmax, x1) + rec = plt.Rectangle( + (0, ymin - 4), + xmax * 1.05, + ymax - ymin + 8, + facecolor=colors[i], # coll.get_color()[0], + alpha=0.2, + edgecolor="none", + ) + ax2.add_patch(rec) + i += 1 + + ax2.set_xticks([]) + ax2.set_yticks([]) + ax2.set_yticklabels([]) + for i in {"right", "left", "top", "bottom"}: + side = ax2.spines[i] + side.set_visible(False) + + if title == "": + title = ( + "Assets Clustermap (" + + codependence.capitalize() + + " & " + + linkage + + " linkage)" + ) + + if dendrogram == True: + ax1.set_title(title) + elif dendrogram == False: + ax.set_title(title) + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_dendrogram( + returns, + custom_cov=None, + codependence="pearson", + linkage="ward", + k=None, + max_k=10, + bins_info="KN", + alpha_tail=0.05, + leaf_order=True, + title="", + height=5, + width=12, + ax=None, +): + r""" + Create a dendrogram based on the selected codependence measure. + + Parameters + ---------- + returns : DataFrame + Assets returns. + custom_cov : DataFrame or None, optional + Custom covariance matrix, used when codependence parameter has value + 'custom_cov'. The default is None. + codependence : str, can be {'pearson', 'spearman', 'abs_pearson', 'abs_spearman', 'distance', 'mutual_info', 'tail' or 'custom_cov'} + The codependence or similarity matrix used to build the distance + metric and clusters. The default is 'pearson'. Posible values are: + + - 'pearson': pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho_{i,j})}`. + - 'spearman': spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho_{i,j})}`. + - 'abs_pearson': absolute value pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho_{i,j}|)}`. + - 'abs_spearman': absolute value spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho_{i,j}|)}`. + - 'distance': distance correlation matrix. Distance formula :math:`D_{i,j} = \sqrt{(1-|\rho_{i,j}|)}`. + - 'mutual_info': mutual information matrix. Distance used is variation information matrix. + - 'tail': lower tail dependence index matrix. Dissimilarity formula :math:`D_{i,j} = -\log{\lambda_{i,j}}`. + - 'custom_cov': use custom correlation matrix based on the custom_cov parameter. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{pearson}_{i,j})}`. + + linkage : string, optional + Linkage method of hierarchical clustering, see `linkage `_ for more details. + The default is 'ward'. Posible values are: + + - 'single'. + - 'complete'. + - 'average'. + - 'weighted'. + - 'centroid'. + - 'median'. + - 'ward'. + - 'DBHT': Direct Bubble Hierarchical Tree. + + k : int, optional + Number of clusters. This value is took instead of the optimal number + of clusters calculated with the two difference gap statistic. + The default is None. + max_k : int, optional + Max number of clusters used by the two difference gap statistic + to find the optimal number of clusters. The default is 10. + bins_info: int or str + Number of bins used to calculate variation of information. The default + value is 'KN'. Posible values are: + + - 'KN': Knuth's choice method. See more in `knuth_bin_width `_. + - 'FD': Freedman–Diaconis' choice method. See more in `freedman_bin_width `_. + - 'SC': Scotts' choice method. See more in `scott_bin_width `_. + - 'HGR': Hacine-Gharbi and Ravier' choice method. + - int: integer value choice by user. + + alpha_tail : float, optional + Significance level for lower tail dependence index. The default is 0.05. + leaf_order : bool, optional + Indicates if the cluster are ordered so that the distance between + successive leaves is minimal. The default is True. + title : str, optional + Title of the chart. The default is "". + height : float, optional + Height of the image in inches. The default is 5. + width : float, optional + Width of the image in inches. The default is 12. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_dendrogram(returns=Y, correlation='spearman', + linkage='ward', k=None, max_k=10, + leaf_order=True, ax=None) + + .. image:: images/Assets_Dendrogram.png + + + """ + if not isinstance(returns, pd.DataFrame): + raise ValueError("returns must be a DataFrame") + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + labels = np.array(returns.columns.tolist()) + + # Calculating codependence matrix and distance metric + if codependence in {"pearson", "spearman"}: + codep = returns.corr(method=codependence) + dist = np.sqrt(np.clip((1 - codep) / 2, a_min=0.0, a_max=1.0)) + elif codependence in {"abs_pearson", "abs_spearman"}: + codep = np.abs(returns.corr(method=codependence[4:])) + dist = np.sqrt(np.clip((1 - codep), a_min=0.0, a_max=1.0)) + elif codependence in {"distance"}: + codep = af.dcorr_matrix(returns).astype(float) + dist = np.sqrt(np.clip((1 - codep), a_min=0.0, a_max=1.0)) + elif codependence in {"mutual_info"}: + codep = af.mutual_info_matrix(returns, bins_info).astype(float) + dist = af.var_info_matrix(returns, bins_info).astype(float) + elif codependence in {"tail"}: + codep = af.ltdi_matrix(returns, alpha_tail).astype(float) + dist = -np.log(codep) + elif codependence in {"custom_cov"}: + codep = af.cov2corr(custom_cov).astype(float) + dist = np.sqrt(np.clip((1 - codep) / 2, a_min=0.0, a_max=1.0)) + + # Hierarchcial clustering + dist = dist.to_numpy() + dist = pd.DataFrame(dist, columns=codep.columns, index=codep.index) + if linkage == "DBHT": + # different choices for D, S give different outputs! + D = dist.to_numpy() # dissimilatity matrix + if codependence in {"pearson", "spearman", "custom_cov"}: + S = (1 - dist**2).to_numpy() + else: + S = codep.copy().to_numpy() # similarity matrix + (_, _, _, _, _, clustering) = db.DBHTs( + D, S, leaf_order=leaf_order + ) # DBHT clustering + else: + p_dist = squareform(dist, checks=False) + clustering = hr.linkage(p_dist, method=linkage, optimal_ordering=leaf_order) + + # Ordering clusterings + permutation = hr.leaves_list(clustering) + permutation = permutation.tolist() + + # optimal number of clusters + if k is None: + k = af.two_diff_gap_stat(codep, dist, clustering, max_k) + + root, nodes = hr.to_tree(clustering, rd=True) + nodes = [i.dist for i in nodes] + nodes.sort() + nodes = nodes[::-1][: k - 1] + color_threshold = np.min(nodes) + + colors = af.color_list(k) # color list + + hr.set_link_color_palette(colors) + hr.dendrogram( + clustering, color_threshold=color_threshold, above_threshold_color="grey", ax=ax + ) + hr.set_link_color_palette(None) + + ax.xaxis.set_major_locator(mticker.FixedLocator(np.arange(codep.shape[0]))) + ax.set_xticklabels(labels[permutation], rotation=90, ha="center") + + i = 0 + for coll in ax.collections[:-1]: # the last collection is the ungrouped level + xmin, xmax = np.inf, -np.inf + ymax = -np.inf + for p in coll.get_paths(): + (x0, _), (x1, y1) = p.get_extents().get_points() + xmin = min(xmin, x0) + xmax = max(xmax, x1) + ymax = max(ymax, y1) + rec = plt.Rectangle( + (xmin - 4, 0), + xmax - xmin + 8, + ymax * 1.05, + facecolor=colors[i], # coll.get_color()[0], + alpha=0.2, + edgecolor="none", + ) + ax.add_patch(rec) + i += 1 + + ax.set_yticks([]) + ax.set_yticklabels([]) + for i in {"right", "left", "top", "bottom"}: + side = ax.spines[i] + side.set_visible(False) + + if title == "": + title = ( + "Assets Dendrogram (" + + codependence.capitalize() + + " & " + + linkage + + " linkage)" + ) + + ax.set_title(title) + + try: + fig.tight_layout() + except: + pass + + return ax + + +def plot_network( + returns, + custom_cov=None, + codependence="pearson", + linkage="ward", + k=None, + max_k=10, + bins_info="KN", + alpha_tail=0.05, + leaf_order=True, + kind="spring", + seed=0, + node_labels=True, + node_size=1400, + node_alpha=0.7, + font_size=10, + title="", + height=8, + width=10, + ax=None, +): + r""" + Create a network plot. The Planar Maximally Filtered Graph (PMFG) for DBHT + linkage and Minimum Spanning Tree (MST) for other linkage methods. + + Parameters + ---------- + returns : DataFrame + Assets returns. + custom_cov : DataFrame or None, optional + Custom covariance matrix, used when codependence parameter has value + 'custom_cov'. The default is None. + codependence : str, can be {'pearson', 'spearman', 'abs_pearson', 'abs_spearman', 'distance', 'mutual_info', 'tail' or 'custom_cov'} + The codependence or similarity matrix used to build the distance + metric and clusters. The default is 'pearson'. Posible values are: + + - 'pearson': pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{pearson}_{i,j})}`. + - 'spearman': spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{spearman}_{i,j})}`. + - 'abs_pearson': absolute value pearson correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho^{pearson}_{i,j}|)}`. + - 'abs_spearman': absolute value spearman correlation matrix. Distance formula: :math:`D_{i,j} = \sqrt{(1-|\rho^{spearman}_{i,j}|)}`. + - 'distance': distance correlation matrix. Distance formula :math:`D_{i,j} = \sqrt{(1-\rho^{distance}_{i,j})}`. + - 'mutual_info': mutual information matrix. Distance used is variation information matrix. + - 'tail': lower tail dependence index matrix. Dissimilarity formula :math:`D_{i,j} = -\log{\lambda_{i,j}}`. + - 'custom_cov': use custom correlation matrix based on the custom_cov parameter. Distance formula: :math:`D_{i,j} = \sqrt{0.5(1-\rho^{pearson}_{i,j})}`. + + linkage : string, optional + Linkage method of hierarchical clustering, see `linkage `_ for more details. + The default is 'ward'. Posible values are: + + - 'single'. + - 'complete'. + - 'average'. + - 'weighted'. + - 'centroid'. + - 'median'. + - 'ward'. + - 'DBHT': Direct Bubble Hierarchical Tree. + + k : int, optional + Number of clusters. This value is took instead of the optimal number + of clusters calculated with the two difference gap statistic. + The default is None. + max_k : int, optional + Max number of clusters used by the two difference gap statistic + to find the optimal number of clusters. The default is 10. + bins_info: int or str + Number of bins used to calculate variation of information. The default + value is 'KN'. Posible values are: + + - 'KN': Knuth's choice method. See more in `knuth_bin_width `_. + - 'FD': Freedman–Diaconis' choice method. See more in `freedman_bin_width `_. + - 'SC': Scotts' choice method. See more in `scott_bin_width `_. + - 'HGR': Hacine-Gharbi and Ravier' choice method. + - int: integer value choice by user. + + alpha_tail : float, optional + Significance level for lower tail dependence index. The default is 0.05. + leaf_order : bool, optional + Indicates if the cluster are ordered so that the distance between + successive leaves is minimal. The default is True. + kind : str, optional + Kind of networkx layout. The default value is 'spring'. Posible values + are: + + - 'spring': networkx spring_layout. + - 'planar'. networkx planar_layout. + - 'circular'. networkx circular_layout. + - 'kamada'. networkx kamada_kawai_layout. Only available for positive codependence metrics. Not pearson or spearman except when linkage is DBHT. + + seed : int, optional + Seed for networkx spring layout. The default value is 0. + node_labels : bool, optional + Specify if node lables are visible. The default value is True. + node_size : float, optional + Size of the nodes. The default value is 1600. + node_alpha : float, optional + Alpha parameter or transparency of nodes. The default value is 0.7. + font_size : float, optional + Font size of node labels. The default value is 12. + title : str, optional + Title of the chart. The default is "". + height : float, optional + Height of the image in inches. The default is 5. + width : float, optional + Width of the image in inches. The default is 12. + ax : matplotlib axis, optional + If provided, plot on this axis. The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + ax : matplotlib axis + Returns the Axes object with the plot for further tweaking. + + Example + ------- + :: + + ax = rp.plot_network(returns=Y, codependence="pearson", + linkage="ward", k=None, max_k=10, + alpha_tail=0.05, leaf_order=True, + kind='spring', ax=None) + + + .. image:: images/Assets_Network.png + + + """ + if not isinstance(returns, pd.DataFrame): + raise ValueError("returns must be a DataFrame") + + if ax is None: + fig = plt.gcf() + ax = fig.gca() + fig.set_figwidth(width) + fig.set_figheight(height) + else: + fig = ax.get_figure() + + labels = np.array(returns.columns.tolist()) + + # Calculating codependence matrix and distance metric + if codependence in {"pearson", "spearman"}: + codep = returns.corr(method=codependence) + dist = np.sqrt(np.clip((1 - codep) / 2, a_min=0.0, a_max=1.0)) + elif codependence in {"abs_pearson", "abs_spearman"}: + codep = np.abs(returns.corr(method=codependence[4:])) + dist = np.sqrt(np.clip((1 - codep), a_min=0.0, a_max=1.0)) + elif codependence in {"distance"}: + codep = af.dcorr_matrix(returns).astype(float) + dist = np.sqrt(np.clip((1 - codep), a_min=0.0, a_max=1.0)) + elif codependence in {"mutual_info"}: + codep = af.mutual_info_matrix(returns, bins_info).astype(float) + dist = af.var_info_matrix(returns, bins_info).astype(float) + elif codependence in {"tail"}: + codep = af.ltdi_matrix(returns, alpha_tail).astype(float) + dist = -np.log(codep) + elif codependence in {"custom_cov"}: + codep = af.cov2corr(custom_cov).astype(float) + dist = np.sqrt(np.clip((1 - codep) / 2, a_min=0.0, a_max=1.0)) + + # Hierarchcial clustering + dist = dist.to_numpy() + dist = pd.DataFrame(dist, columns=codep.columns, index=codep.index) + if linkage == "DBHT": + # different choices for D, S give different outputs! + D = dist.to_numpy() # dissimilatity matrix + if codependence in {"pearson", "spearman", "custom_cov"}: + S = (1 - dist**2).to_numpy() + else: + S = codep.copy().to_numpy() # similarity matrix + (_, Rpm, _, _, _, clustering) = db.DBHTs( + D, S, leaf_order=leaf_order + ) # DBHT clustering + MAdj = pd.DataFrame(Rpm, index=labels, columns=labels) + G = nx.from_pandas_adjacency(MAdj) + else: + p_dist = squareform(dist, checks=False) + clustering = hr.linkage(p_dist, method=linkage, optimal_ordering=leaf_order) + T = nx.from_pandas_adjacency(codep) # create a graph G from a numpy matrix + G = nx.minimum_spanning_tree(T) + + # optimal number of clusters + if k is None: + k = af.two_diff_gap_stat(codep, dist, clustering, max_k) + + clustering_inds = hr.fcluster(clustering, k, criterion="maxclust") + clusters = {i: [] for i in range(min(clustering_inds), max(clustering_inds) + 1)} + for i, v in enumerate(clustering_inds): + clusters[v].append(labels[i]) + + # Layout options + node_options = { + "node_size": node_size, + "alpha": node_alpha, + } + font_options = { + "font_size": font_size, + "font_color": "k", + } + + label_options = {"ec": "k", "fc": "white", "alpha": 0.7} + + if kind == "spring": + pos = nx.spring_layout(G, seed=seed) + elif kind == "planar": + pos = nx.planar_layout(G) + elif kind == "circular": + pos = nx.circular_layout(G) + elif kind == "kamada": + if codependence in {"pearson", "spearman"} and linkage != "DBHT": + raise NameError( + "kamada layout only works with positive codependence measures except when linkage is DBHT." + ) + pos = nx.kamada_kawai_layout(G) + + # Plotting + nx.draw_networkx_edges(G, pos=pos, ax=ax, edge_color="grey") + + if node_labels == True: + nx.draw_networkx_labels(G, pos=pos, ax=ax, bbox=label_options, **font_options) + + colors = af.color_list(k) + + for i, color in zip(clusters.keys(), colors): + nx.draw_networkx_nodes( + G, pos=pos, nodelist=clusters[i], node_color=color, ax=ax, **node_options + ) + + ax.set_yticks([]) + ax.set_yticklabels([]) + for i in {"right", "left", "top", "bottom"}: + side = ax.spines[i] + side.set_visible(False) + + if title == "": + if linkage == "DBHT": + title = ( + "Planar Maximally Filtered Graph (" + + codependence.capitalize() + + ", " + + linkage + + " linkage & " + + kind + + " layout)" + ) + else: + title = ( + "Minimun Spanning Tree (" + + codependence.capitalize() + + ", " + + linkage + + " linkage & " + + kind + + " layout)" + ) + + ax.set_title(title) + + try: + fig.tight_layout() + except: + pass + + return ax diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/Portfolio.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/Portfolio.py new file mode 100644 index 00000000000..673321f0366 --- /dev/null +++ b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/Portfolio.py @@ -0,0 +1,3391 @@ +"""""" # +""" +Copyright (c) 2020-2022, Dany Cajas +All rights reserved. +This work is licensed under BSD 3-Clause "New" or "Revised" License. +License available at https://github.com/dcajasn/Riskfolio-Lib/blob/master/LICENSE.txt +""" + +import numpy as np +import pandas as pd +import cvxpy as cv +import scipy.stats as st +from scipy.linalg import sqrtm +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.RiskFunctions as rk +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.ParamsEstimation as pe +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.AuxFunctions as af +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.OwaWeights as owa + + +class Portfolio(object): + r""" + Class that creates a portfolio object with all properties needed to + calculate optimal portfolios. + + Parameters + ---------- + returns : DataFrame, optional + A dataframe that containts the returns of the assets. + The default is None. + sht : bool, optional + Indicate if the portfolio consider short positions (negative weights). + The default is False. + uppersht : float, optional + Indicate the maximum value of the sum of absolute values of short + positions (negative weights). The default is 0.2. + upperlng : float, optional + Indicate the maximum value of the sum of long positions (positive + weights). When sht=True, the difference between upperlng and uppersht + must be equal to the budget (upperlng - uppersht = budget) + The default is 1. + budget : float, optional + Indicate the maximum value of the sum of long positions (positive + weights) and short positions (negative weights). The default is 1. + nea : int, optional + Indicate the minimum number of effective assets (NEA) used in + portfolio. This value is the inverse of Herfindahl-Hirschman index of + portfolio's weights. The default is None. + card : int, optional + Indicate the maximum number of assets used in portfolio. It requires + a solver that supports Mixed Integer Programs (MIP), see `Solvers `_ for more details. + This constraint is based on :cite:`a-YUE2014949`. The default is None. + factors : DataFrame, optional + A dataframe that containts the returns of the factors. + The default is None. + B : DataFrame, optional + A dataframe that containts the loadings matrix. + The default is None. + alpha : float, optional + Significance level of CVaR, EVaR, CDaR, EDaR and Tail Gini of losses. The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of CVaR and Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + kindbench : bool, optional + True if the benchmark is a portfolio with detailed weights and False if + the benchmark is an index. The default is True. + allowTO : bool, optional + Indicate if there is turnover constraints. The default is False. + turnover : float, optional + The maximum limit of turnover deviatons. The default is 0.05. + allowTE : bool, optional + Indicate if there is tracking error constraints.. The default is False. + TE : float, optional + The maximum limit of tracking error deviatons. The default is 0.05. + benchindex : DataFrame, optional + A dataframe that containts the returns of an index. If kindbench is + False the tracking error constraints are calculated respect to this + index. The default is None. + benchweights : DataFrame, optional + A dataframe that containts the weights of an index. The default is the + equally weighted portfolio 1/N. + ainequality : nd-array, optional + The matrix :math:`A` of the linear constraint :math:`A \geq B`. + The default is None. + binequality : 1d-array, optional + The matrix :math:`B` of the linear constraint :math:`A \geq B`. + The default is None. + lowerret : float, optional + Constraint on min level of expected return. The default is None. + upperdev : float, optional + Constraint on max level of standard deviation. The default is None. + uppermad : float, optional + Constraint on max level of MAD. The default is None. + uppergmd : float, optional + Constraint on max level of GMD. The default is None. + uppersdev : float, optional + Constraint on max level of semi standard deviation. The default is None. + upperflpm : float, optional + Constraint on max level of first lower partial moment. + The default is None. + upperslpm : float, optional + Constraint on max level of second lower partial moment. + The default is None. + upperCVaR : float, optional + Constraint on max level of CVaR. The default is None. + uppertg : float, optional + Constraint on max level of Tail Gini. The default is None. + upperEVaR : float, optional + Constraint on max level of EVaR. The default is None. + upperwr : float, optional + Constraint on max level of worst realization. The default is None. + upperrg : float, optional + Constraint on max level of range. The default is None. + uppercvrg : float, optional + Constraint on max level of CVaR range. The default is None. + uppertgrg : float, optional + Constraint on max level of Tail Gini range. The default is None. + uppermdd : float, optional + Constraint on max level of maximum drawdown of uncompounded cumulative + returns. The default is None. + upperadd : float, optional + Constraint on max level of average drawdown of uncompounded cumulative + returns. The default is None. + upperCDaR : float, optional + Constraint on max level of conditional drawdown at risk (CDaR) of + uncompounded cumulative returns. The default is None. + upperEDaR : float, optional + Constraint on max level of entropic drawdown at risk (EDaR) of + uncompounded cumulative returns. The default is None. + upperuci : float, optional + Constraint on max level of ulcer index (UCI) of + uncompounded cumulative returns. The default is None. + """ + + def __init__( + self, + returns=None, + sht=False, + uppersht=0.2, + upperlng=1, + budget=1, + nea=None, + card=None, + factors=None, + B=None, + alpha=0.05, + a_sim=100, + beta=None, + b_sim=None, + kindbench=True, + allowTO=False, + turnover=0.05, + allowTE=False, + TE=0.05, + benchindex=None, + benchweights=None, + ainequality=None, + binequality=None, + lowerret=None, + upperdev=None, + uppermad=None, + uppersdev=None, + upperflpm=None, + upperslpm=None, + upperCVaR=None, + upperEVaR=None, + upperwr=None, + uppermdd=None, + upperadd=None, + upperCDaR=None, + upperEDaR=None, + upperuci=None, + uppergmd=None, + uppertg=None, + upperrg=None, + uppercvrg=None, + uppertgrg=None, + ): + + # Optimization Models Options + + self._returns = returns + self.sht = sht + self.uppersht = uppersht + self.upperlng = upperlng + self.budget = budget + self.nea = nea + self.card = card + self._factors = factors + self.alpha = alpha + self.a_sim = a_sim + self.beta = beta + self.b_sim = b_sim + self.kindbench = kindbench + self.benchindex = benchindex + self._benchweights = benchweights + self._ainequality = ainequality + self._binequality = binequality + self.lowerret = lowerret + self.upperdev = upperdev + self.uppermad = uppermad + self.uppersdev = uppersdev + self.upperCVaR = upperCVaR + self.upperEVaR = upperEVaR + self.upperwr = upperwr + self.uppermdd = uppermdd + self.upperadd = upperadd + self.upperCDaR = upperCDaR + self.upperEDaR = upperEDaR + self.upperflpm = upperflpm + self.upperslpm = upperslpm + self.upperuci = upperuci + self.uppergmd = uppergmd + self.uppertg = uppertg + self.upperrg = upperrg + self.uppercvrg = uppercvrg + self.uppertgrg = uppertgrg + + self.allowTO = allowTO + self.turnover = turnover + self.allowTE = allowTE + self.TE = TE + + # Inputs of Optimization Models + + self.mu = None + self.cov = None + self.mu_f = None + self.cov_f = None + self._B = None + self.mu_fm = None + self.cov_fm = None + self.mu_bl = None + self.cov_bl = None + self.mu_bl_fm = None + self.cov_bl_fm = None + self.returns_fm = None + self.nav_fm = None + self.z_EVaR = None + self.z_EDaR = None + + # Inputs of Worst Case Optimization Models + + self.cov_l = None + self.cov_u = None + self.cov_mu = None + self.cov_sigma = None + self.d_mu = None + self.k_mu = None + self.k_sigma = None + + # Optimal portfolios + self.optimal = None + self.rp_optimal = None + self.wc_optimal = None + self.limits = None + self.frontier = None + + # Solver params + + self.solvers = ["ECOS", "SCS", "OSQP", "CVXOPT"] + self.sol_params = { + # 'ECOS': {"max_iters": 500, "abstol": 1e-8}, + # 'SCS': {"max_iters": 2500, "eps": 1e-5}, + # 'OSQP': {"max_iter": 10000, "eps_abs": 1e-8}, + # 'CVXOPT': {"max_iters": 500, "abstol": 1e-8}, + } + + @property + def returns(self): + if self._returns is not None and isinstance(self._returns, pd.DataFrame): + return self._returns + else: + raise NameError("returns must be a DataFrame") + + @returns.setter + def returns(self, value): + if value is not None and isinstance(value, pd.DataFrame): + self._returns = value + else: + raise NameError("returns must be a DataFrame") + + @property + def nav(self): + if self._returns is not None and isinstance(self._returns, pd.DataFrame): + return self._returns.cumsum() + + @property + def assetslist(self): + if self._returns is not None and isinstance(self._returns, pd.DataFrame): + return self._returns.columns.tolist() + elif self._returns is None: + return None + + @property + def numassets(self): + if self._returns is not None and isinstance(self._returns, pd.DataFrame): + return self._returns.shape[1] + + @property + def factors(self): + return self._factors + + @factors.setter + def factors(self, value): + a = value + if a is not None and isinstance(a, pd.DataFrame): + if self.returns.index.equals(a.index): + self._factors = a + else: + raise NameError("factors must be a DataFrame") + + @property + def factorslist(self): + if self._factors is not None and isinstance(self._factors, pd.DataFrame): + return self._factors.columns.tolist() + elif self._factors is None: + return None + + @property + def B(self): + return self._B + + @B.setter + def B(self, value): + a = value + if a is not None and isinstance(a, pd.DataFrame): + self._B = a + else: + raise NameError("loadings matrix must be a DataFrame") + + @property + def benchweights(self): + n = self.numassets + if self._benchweights is not None: + if self._benchweights.shape[0] == n and self._benchweights.shape[1] == 1: + a = self._benchweights + else: + raise NameError("Weights must have a size of shape (n_assets,1)") + else: + a = np.array(np.ones((n, 1)) / n) + return a + + @benchweights.setter + def benchweights(self, value): + a = value + n = self.numassets + if a is not None: + if a.shape[0] == n and a.shape[1] == 1: + a = a + else: + raise NameError("Weights must have a size of shape (n_assets,1)") + else: + a = np.array(np.ones((n, 1)) / n) + self._benchweights = a + + @property + def ainequality(self): + a = self._ainequality + if a is not None: + if a.shape[1] == self.numassets: + a = a + else: + raise NameError( + "The array ainequality must have the same number of columns that assets' number" + ) + return a + + @ainequality.setter + def ainequality(self, value): + a = value + if a is not None: + if a.shape[1] == self.numassets: + a = a + else: + raise NameError( + "The matrix ainequality must have the same number of columns that assets' number" + ) + self._ainequality = a + + @property + def binequality(self): + a = self._binequality + if a is not None: + if a.shape[1] == 1: + a = a + else: + raise NameError("The matrix binequality must have one column") + return a + + @binequality.setter + def binequality(self, value): + a = value + if a is not None: + if a.shape[1] == 1: + a = a + else: + raise NameError("The matrix binequality must have one column") + self._binequality = a + + def assets_stats(self, method_mu="hist", method_cov="hist", d=0.94, **kwargs): + r""" + Calculate the inputs that will be used by the optimization method when + we select the input model='Classic'. + + Parameters + ---------- + method_mu : str, optional + The method used to estimate the expected returns. + The default value is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1'': use ewma with adjust=True, see `EWM `_ for more details. + - 'ewma2': use ewma with adjust=False, see `EWM `_ for more details. + + method_cov : str, optional + The method used to estimate the covariance matrix: + The default is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1'': use ewma with adjust=True, see `EWM `_ for more details. + - 'ewma2': use ewma with adjust=False, see `EWM `_ for more details. + - 'ledoit': use the Ledoit and Wolf Shrinkage method. + - 'oas': use the Oracle Approximation Shrinkage method. + - 'shrunk': use the basic Shrunk Covariance method. + - 'gl': use the basic Graphical Lasso Covariance method. + - 'jlogo': use the j-LoGo Covariance method. For more information see: :cite:`a-jLogo`. + - 'fixed': denoise using fixed method. For more information see chapter 2 of :cite:`a-MLforAM`. + - 'spectral': denoise using spectral method. For more information see chapter 2 of :cite:`a-MLforAM`. + - 'shrink': denoise using shrink method. For more information see chapter 2 of :cite:`a-MLforAM`. + + **kwargs : dict + All aditional parameters of mean_vector and covar_matrix functions. + + See Also + -------- + riskfolio.ParamsEstimation.mean_vector + riskfolio.ParamsEstimation.covar_matrix + + """ + + self.mu = pe.mean_vector(self.returns, method=method_mu, d=d) + self.cov = pe.covar_matrix(self.returns, method=method_cov, **kwargs) + value = af.is_pos_def(self.cov, threshold=1e-8) + if value == False: + try: + self.cov = af.cov_fix(self.cov, method="clipped", threshold=1e-5) + value = af.is_pos_def(self.cov, threshold=1e-8) + if value == False: + print("You must convert self.cov to a positive definite matrix") + except: + print("You must convert self.cov to a positive definite matrix") + + def blacklitterman_stats( + self, + P, + Q, + rf=0, + w=None, + delta=None, + eq=True, + method_mu="hist", + method_cov="hist", + **kwargs, + ): + r""" + Calculate the inputs that will be used by the optimization method when + we select the input model='BL'. + + Parameters + ---------- + P : DataFrame of shape (n_views, n_assets) + Analyst's views matrix, can be relative or absolute. + Q: DataFrame of shape (n_views, 1) + Expected returns of analyst's views. + delta: float + Risk aversion factor. The default value is 1. + rf: scalar, optional + Risk free rate. The default is 0. + w : DataFrame of shape (n_assets, 1) + Weights matrix, where n_assets is the number of assets. + The default is None. + eq: bool, optional + Indicates if use equilibrum or historical excess returns. + The default is True. + method_mu : str, optional + The method used to estimate the expected returns. + The default value is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1'': use ewma with adjust=True, see `EWM `_ for more details. + - 'ewma2': use ewma with adjust=False, see `EWM `_ for more details. + + method_cov : str, optional + The method used to estimate the covariance matrix: + The default is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1'': use ewma with adjust=True, see `EWM `_ for more details. + - 'ewma2': use ewma with adjust=False, see `EWM `_ for more details. + - 'ledoit': use the Ledoit and Wolf Shrinkage method. + - 'oas': use the Oracle Approximation Shrinkage method. + - 'shrunk': use the basic Shrunk Covariance method. + - 'gl': use the basic Graphical Lasso Covariance method. + - 'jlogo': use the j-LoGo Covariance method. For more information see: :cite:`a-jLogo`. + - 'fixed': denoise using fixed method. For more information see chapter 2 of :cite:`a-MLforAM`. + - 'spectral': denoise using spectral method. For more information see chapter 2 of :cite:`a-MLforAM`. + - 'shrink': denoise using shrink method. For more information see chapter 2 of :cite:`a-MLforAM`. + + **kwargs : dict + Other variables related to the covariance estimation. + + See Also + -------- + riskfolio.ParamsEstimation.black_litterman + + """ + X = self.returns + if w is None: + w = np.array(self.benchweights, ndmin=2) + + if delta is None: + a = np.array(self.mu, ndmin=2) @ np.array(w, ndmin=2) + delta = (a - rf) / ( + np.array(w, ndmin=2).T + @ np.array(self.cov, ndmin=2) + @ np.array(w, ndmin=2) + ) + delta = delta.item() + + mu, cov, w = pe.black_litterman( + X=X, + w=w, + P=P, + Q=Q, + delta=delta, + rf=rf, + eq=eq, + method_mu=method_mu, + method_cov=method_cov, + **kwargs, + ) + self.mu_bl = mu + self.cov_bl = cov + + value = af.is_pos_def(self.cov_bl, threshold=1e-8) + if value == False: + try: + self.cov_bl = af.cov_fix(self.cov_bl, method="clipped", threshold=1e-5) + value = af.is_pos_def(self.cov_bl, threshold=1e-8) + if value == False: + print("You must convert self.cov_bl to a positive definite matrix") + except: + print("You must convert self.cov_bl to a positive definite matrix") + + def factors_stats( + self, + method_mu="hist", + method_cov="hist", + d=0.94, + B=None, + dict_cov={}, + dict_risk={}, + ): + r""" + Calculate the inputs that will be used by the optimization method when + we select the input model='FM'. + + Parameters + ---------- + method_mu : str, optional + The method used to estimate the expected returns. + The default value is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1'': use ewma with adjust=True, see `EWM `_ for more details. + - 'ewma2': use ewma with adjust=False, see `EWM `_ for more details. + + method_cov : str, optional + The method used to estimate the covariance matrix: + The default is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1'': use ewma with adjust=True, see `EWM `_ for more details. + - 'ewma2': use ewma with adjust=False, see `EWM `_ for more details. + - 'ledoit': use the Ledoit and Wolf Shrinkage method. + - 'oas': use the Oracle Approximation Shrinkage method. + - 'shrunk': use the basic Shrunk Covariance method. + - 'gl': use the basic Graphical Lasso Covariance method. + - 'jlogo': use the j-LoGo Covariance method. For more information see: :cite:`a-jLogo`. + - 'fixed': denoise using fixed method. For more information see chapter 2 of :cite:`a-MLforAM`. + - 'spectral': denoise using spectral method. For more information see chapter 2 of :cite:`a-MLforAM`. + - 'shrink': denoise using shrink method. For more information see chapter 2 of :cite:`a-MLforAM`. + + dict_cov : dict + Other variables related to the covariance estimation. + dict_risk : dict + Other variables related of risk_factors function. + + See Also + -------- + riskfolio.ParamsEstimation.forward_regression + riskfolio.ParamsEstimation.backward_regression + riskfolio.ParamsEstimation.loadings_matrix + riskfolio.ParamsEstimation.risk_factors + + """ + X = self.factors + Y = self.returns + + mu_f = pe.mean_vector(self.returns, method=method_mu, d=d) + cov_f = pe.covar_matrix(self.returns, method=method_cov, d=d, **dict_cov) + + self.mu_f = mu_f + self.cov_f = cov_f + + value = af.is_pos_def(self.cov_f, threshold=1e-8) + if value == False: + try: + self.cov = af.cov_fix(self.cov, method="clipped", threshold=1e-5) + value = af.is_pos_def(self.cov, threshold=1e-8) + if value == False: + print("You must convert self.cov to a positive definite matrix") + except: + print("You must convert self.cov to a positive definite matrix") + + mu, cov, returns, nav = pe.risk_factors( + X, Y, B=B, method_mu=method_mu, method_cov=method_cov, **dict_risk + ) + + self.mu_fm = mu + self.cov_fm = cov + self.returns_fm = returns + self.nav_fm = nav + + value = af.is_pos_def(self.cov_fm, threshold=1e-8) + if value == False: + try: + self.cov_fm = af.cov_fix(self.cov_fm, method="clipped", threshold=1e-5) + value = af.is_pos_def(self.cov_fm, threshold=1e-8) + if value == False: + print("You must convert self.cov_fm to a positive definite matrix") + except: + print("You must convert self.cov_fm to a positive definite matrix") + + def blfactors_stats( + self, + flavor="BLB", + B=None, + P=None, + Q=None, + P_f=None, + Q_f=None, + rf=0, + w=None, + delta=None, + eq=True, + const=False, + diag=False, + method_mu="hist", + method_cov="hist", + kwargs_1=None, + kwargs_2=None, + ): + r""" + Calculate the inputs that will be used by the optimization method when + we select the input model='BL'. + + Parameters + ---------- + flavor : str + Model used, can be 'BLB' for Black Litterman Bayesian or 'ABL' for + Augmented Black Litterman. The default value is 'BLB'. + B : DataFrame of shape (n_assets, n_features) + Loadings matrix. The default value is None. + P : DataFrame of shape (n_views, n_assets) + Analyst's views matrix, can be relative or absolute. + Q: DataFrame of shape (n_views, 1) + Expected returns of analyst's views. + P_f : DataFrame of shape (n_views, n_assets) + Analyst's factors views matrix, can be relative or absolute. + Q_f: DataFrame of shape (n_views, 1) + Expected returns of analyst's factors views. + delta: float + Risk aversion factor. The default value is 1. + rf: scalar, optional + Risk free rate. The default is 0. + w : DataFrame of shape (n_assets, 1) + Weights matrix, where n_assets is the number of assets. + The default is None. + eq: bool, optional + Indicates if use equilibrum or historical excess returns. + The default is True. + const : bool, optional + Indicate if the loadings matrix has a constant. + The default is False. + diag : bool, optional + Indicate if we use the diagonal matrix to calculate covariance matrix + of factor model, only useful when we work with a factor model based on + a regresion model (only equity portfolio). + The default is False. + method_mu : str, optional + The method used to estimate the expected returns. + The default value is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1'': use ewma with adjust=True, see `EWM `_ for more details. + - 'ewma2': use ewma with adjust=False, see `EWM `_ for more details. + + method_cov : str, optional + The method used to estimate the covariance matrix: + The default is 'hist'. Posible values are: + + - 'hist': use historical estimates. + - 'ewma1'': use ewma with adjust=True, see `EWM `_ for more details. + - 'ewma2': use ewma with adjust=False, see `EWM `_ for more details. + - 'ledoit': use the Ledoit and Wolf Shrinkage method. + - 'oas': use the Oracle Approximation Shrinkage method. + - 'shrunk': use the basic Shrunk Covariance method. + - 'gl': use the basic Graphical Lasso Covariance method. + - 'jlogo': use the j-LoGo Covariance method. For more information see: :cite:`a-jLogo`. + - 'fixed': denoise using fixed method. For more information see chapter 2 of :cite:`a-MLforAM`. + - 'spectral': denoise using spectral method. For more information see chapter 2 of :cite:`a-MLforAM`. + - 'shrink': denoise using shrink method. For more information see chapter 2 of :cite:`a-MLforAM`. + + kwargs_1 : dict + Other variables related to the loadings matrix estimation. + kwargs_2 : dict + Other variables related to the factors Black Litterman model selected. + + See Also + -------- + riskfolio.ParamsEstimation.augmented_black_litterman + riskfolio.ParamsEstimation.black_litterman_bayesian + + """ + X = self.returns + F = self.factors + + if w is None: + w = np.array(self.benchweights, ndmin=2) + + if delta is None: + a = np.array(self.mu, ndmin=2) @ np.array(w, ndmin=2) + delta = (a - rf) / ( + np.array(w, ndmin=2).T + @ np.array(self.cov, ndmin=2) + @ np.array(w, ndmin=2) + ) + delta = delta.item() + + if B is None: + if self.B is None: + self.B = pe.loadings_matrix(X=F, Y=X, **kwargs_1) + const = True + elif self.B is not None: + pass + elif B is not None: + self.B = B + + if flavor == "BLB": + if isinstance(kwargs_1, dict): + mu, cov, w = pe.black_litterman_bayesian( + X=X, + F=F, + B=self.B, + P_f=P_f, + Q_f=Q_f, + delta=delta, + rf=rf, + eq=eq, + const=const, + diag=diag, + method_mu=method_mu, + method_cov=method_cov, + **kwargs_2, + ) + else: + mu, cov, w = pe.black_litterman_bayesian( + X=X, + F=F, + B=self.B, + P_f=P_f, + Q_f=Q_f, + delta=delta, + rf=rf, + eq=eq, + const=const, + diag=diag, + method_mu=method_mu, + method_cov=method_cov, + ) + + elif flavor == "ABL": + if isinstance(kwargs_1, dict): + mu, cov, w = pe.augmented_black_litterman( + X=X, + w=w, + F=F, + B=self.B, + P=P, + Q=Q, + P_f=P_f, + Q_f=Q_f, + delta=delta, + rf=rf, + eq=eq, + const=const, + method_mu=method_mu, + method_cov=method_cov, + **kwargs_2, + ) + else: + mu, cov, w = pe.augmented_black_litterman( + X=X, + w=w, + F=F, + B=self.B, + P=P, + Q=Q, + P_f=P_f, + Q_f=Q_f, + delta=delta, + rf=rf, + eq=eq, + const=const, + method_mu=method_mu, + method_cov=method_cov, + ) + + self.mu_bl_fm = mu + self.cov_bl_fm = cov + + value = af.is_pos_def(self.cov_bl_fm, threshold=1e-8) + if value == False: + try: + self.cov_bl_fm = af.cov_fix( + self.cov_bl_fm, method="clipped", threshold=1e-5 + ) + value = af.is_pos_def(self.cov_bl_fm, threshold=1e-8) + if value == False: + print( + "You must convert self.cov_bl_fm to a positive definite matrix" + ) + except: + print("You must convert self.cov_bl_fm to a positive definite matrix") + + def wc_stats( + self, + box="s", + ellip="s", + q=0.05, + n_sim=3000, + window=3, + dmu=0.1, + dcov=0.1, + seed=0, + ): + r""" + Calculate the inputs that will be used by the wc_optimization method. + + Parameters + ---------- + box : string + The method used to estimate the box uncertainty sets. The default is 's'. Posible values are: + + - 's': stationary bootstrapping method. + - 'c': circular bootstrapping method. + - 'm': moving bootstrapping method. + - 'n': assuming normal returns to calculate confidence levels. + - 'd': delta method, this method increase and decrease by a percentage. + + ellip : string + The method used to estimate the elliptical uncertainty sets. The default is 's'. Posible values are: + + - 's': stationary bootstrapping method. + - 'c': circular bootstrapping method. + - 'm': moving bootstrapping method. + - 'n': assuming normal returns to calculate confidence levels. + + q : scalar + Significance level of the selected bootstrapping method. + The default is 0.05. + n_sim : scalar + Number of simulations of the bootstrapping method. + The default is 3000. + window: + Block size of the bootstrapping method. Must be greather than 1 + and lower than the n_samples - n_features + 1 + The default is 3. + dmu : scalar + Percentage used by delta method to increase and decrease the mean vector in box constraints. + The default is 0.1. + dcov : scalar + Percentage used by delta method to increase and decrease the covariance matrix in box constraints. + The default is 0.1. + + See Also + -------- + riskfolio.ParamsEstimation.bootstrapping + + """ + + if box not in list("scmdn"): + raise ValueError("box only can be 's', 'c', 'm', 'd' or 'n'") + if ellip not in list("scmn"): + raise ValueError("box only can be 's', 'c', 'm' or 'n'") + + X = self.returns + cols = X.columns.tolist() + cols_2 = [i + "-" + j for i in cols for j in cols] + (n, m) = X.shape + mu = X.mean().to_frame().T + cov = X.cov() + + if box == "s": + mu_l, mu_u, cov_l, cov_u, _, _ = pe.bootstrapping( + X, kind="stationary", q=q, n_sim=n_sim, window=window, seed=seed + ) + d_mu = (mu_u - mu_l) / 2 + elif box == "c": + mu_l, mu_u, cov_l, cov_u, _, _ = pe.bootstrapping( + X, kind="circular", q=q, n_sim=n_sim, window=window, seed=seed + ) + d_mu = (mu_u - mu_l) / 2 + elif box == "m": + mu_l, mu_u, cov_l, cov_u, _, _ = pe.bootstrapping( + X, kind="moving", q=q, n_sim=n_sim, window=window, seed=seed + ) + d_mu = (mu_u - mu_l) / 2 + elif box == "n": + # Defining confidence level of mean vector assuming normal returns + mu_u = mu + st.norm.ppf(1 - q / 2) * np.diag(cov) / n**2 + mu_l = mu - st.norm.ppf(1 - q / 2) * np.diag(cov) / n**2 + d_mu = (mu_u - mu_l) / 2 + d_mu = pd.DataFrame(d_mu, index=[0], columns=cols) + + # Defining confidence level of covariance matrix assuming normal returns + rs = np.random.RandomState(seed=seed) + A = st.wishart.rvs(n, cov / n, size=10000, random_state=rs) + cov_l = np.percentile(A, q=q, axis=0) + cov_u = np.percentile(A, q=1 - q / 2, axis=0) + + cov_l = pd.DataFrame(cov_l, index=cols, columns=cols) + cov_u = pd.DataFrame(cov_u, index=cols, columns=cols) + + if af.is_pos_def(cov_l) == False: + cov_l = af.cov_fix(cov_l, method="clipped", threshold=1e-3) + + if af.is_pos_def(cov_u) == False: + cov_u = af.cov_fix(cov_u, method="clipped", threshold=1e-3) + + elif box == "d": + d_mu = dmu * np.abs(mu) + cov_l = cov - dcov * np.abs(cov) + cov_u = cov + dcov * np.abs(cov) + d_mu = pd.DataFrame(d_mu, index=[0], columns=cols) + cov_l = pd.DataFrame(cov_l, index=cols, columns=cols) + cov_u = pd.DataFrame(cov_u, index=cols, columns=cols) + + if ellip == "s": + _, _, _, _, cov_mu, cov_sigma = pe.bootstrapping( + X, kind="stationary", q=q, n_sim=n_sim, window=window, seed=seed + ) + elif ellip == "c": + _, _, _, _, cov_mu, cov_sigma = pe.bootstrapping( + X, kind="circular", q=q, n_sim=n_sim, window=window, seed=seed + ) + elif ellip == "m": + _, _, _, _, cov_mu, cov_sigma = pe.bootstrapping( + X, kind="moving", q=q, n_sim=n_sim, window=window, seed=seed + ) + elif ellip == "n": + # Covariance of mean returns + cov_mu = cov / n + cov_mu = np.diag(np.diag(cov_mu)) + cov_mu = pd.DataFrame(cov_mu, index=cols, columns=cols) + # Covariance of covariance matrix + K = af.commutation_matrix(cov) + I = np.identity(m * m) + cov_sigma = n * (I + K) @ np.kron(cov_mu, cov_mu) + cov_sigma = np.diag(np.diag(cov_sigma)) + cov_sigma = pd.DataFrame(cov_sigma, index=cols_2, columns=cols_2) + + k_mu = st.chi2.ppf(1 - q, df=m) ** 0.5 + k_sigma = st.chi2.ppf(1 - q, df=m * m) ** 0.5 + + self.cov_l = cov_l + self.cov_u = cov_u + self.cov_mu = cov_mu + self.cov_sigma = cov_sigma + self.d_mu = d_mu + self.k_mu = k_mu + self.k_sigma = k_sigma + + def optimization( + self, model="Classic", rm="MV", obj="Sharpe", kelly=False, rf=0, l=2, hist=True + ): + r""" + This method that calculates the optimal portfolio according to the + optimization model selected by the user. The general problem that + solves is: + + .. math:: + \begin{align} + &\underset{w}{\text{optimize}} & & F(w)\\ + &\text{s. t.} & & Aw \geq B\\ + & & & \phi_{i}(w) \leq c_{i}\\ + \end{align} + + Where: + + :math:`F(w)` is the objective function. + + :math:`Aw \geq B` is a set of linear constraints. + + :math:`\phi_{i}(w) \leq c_{i}` are constraints on maximum values of + several risk measures. + + Parameters + ---------- + model : str can be {'Classic', 'BL', 'FM' or 'BLFM'} + The model used for optimize the portfolio. + The default is 'Classic'. Posible values are: + + - 'Classic': use estimates of expected return vector and covariance matrix that depends on historical data. + - 'BL': use estimates of expected return vector and covariance matrix based on the Black Litterman model. + - 'FM': use estimates of expected return vector and covariance matrix based on a Risk Factor model specified by the user. + - 'BLFM': use estimates of expected return vector and covariance matrix based on Black Litterman applied to a Risk Factor model specified by the user. + + rm : str, optional + The risk measure used to optimze the portfolio. + The default is 'MV'. Posible values are: + + - 'MV': Standard Deviation. + - 'MAD': Mean Absolute Deviation. + - 'GMD': Gini Mean Difference. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'WR': Worst Realization (Minimax). + - 'RG': Range of returns. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'MDD': Maximum Drawdown of uncompounded cumulative returns (Calmar Ratio). + - 'ADD': Average Drawdown of uncompounded cumulative returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'EDaR': Entropic Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + + obj : str can be {'MinRisk', 'Utility', 'Sharpe' or 'MaxRet'}. + Objective function of the optimization model. + The default is 'Sharpe'. Posible values are: + + - 'MinRisk': Minimize the selected risk measure. + - 'Utility': Maximize the Utility function :math:`\mu w - l \phi_{i}(w)`. + - 'Sharpe': Maximize the risk adjusted return ratio based on the selected risk measure. + - 'MaxRet': Maximize the expected return of the portfolio. + + kelly : str, optional + Method used to calculate mean return. Posible values are False for + arithmetic mean return, "approx" for approximate mean logarithmic + return using first and second moment and "exact" for mean logarithmic + return. The default is False. + rf : float, optional + Risk free rate, must be in the same period of assets returns. + The default is 0. + l : scalar, optional + Risk aversion factor of the 'Utility' objective function. + The default is 2. + hist : bool, optional + Indicate what kind of returns are used to calculate risk measures + that depends on scenarios (All except 'MV' risk measure). + If model = 'BL', True means historical covariance and returns and + False Black Litterman covariance and historical returns. + If model = 'FM', True means historical covariance and returns and + False Risk Factor model for covariance and returns. + If model = 'BL_FM', True means historical covariance and returns, + False Black Litteram with Risk Factor model for covariance and + Risk Factor model for returns, and '2' Risk Factor model for + covariance and returns. The default is True. + + Returns + ------- + w : DataFrame + The weights of optimal portfolio. + + """ + + # General model Variables + + mu = None + sigma = None + returns = None + if model == "Classic": + mu = np.array(self.mu, ndmin=2) + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif model == "FM": + mu = np.array(self.mu_fm, ndmin=2) + if hist == False: + sigma = np.array(self.cov_fm, ndmin=2) + returns = np.array(self.returns_fm, ndmin=2) + nav = np.array(self.nav_fm, ndmin=2) + elif hist == True: + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif model == "BL": + mu = np.array(self.mu_bl, ndmin=2) + if hist == False: + sigma = np.array(self.cov_bl, ndmin=2) + elif hist == True: + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif model == "BL_FM": + mu = np.array(self.mu_bl_fm, ndmin=2) + if hist == False: + sigma = np.array(self.cov_bl_fm, ndmin=2) + returns = np.array(self.returns_fm, ndmin=2) + nav = np.array(self.nav_fm, ndmin=2) + elif hist == True: + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif hist == 2: + sigma = np.array(self.cov_fm, ndmin=2) + returns = np.array(self.returns_fm, ndmin=2) + nav = np.array(self.nav_fm, ndmin=2) + + # General Model Variables + + returns = np.array(returns, ndmin=2) + w = cv.Variable((mu.shape[1], 1)) + k = cv.Variable((1, 1)) + rf0 = rf + n = returns.shape[0] + gr = cv.Variable((n, 1)) + + # MV Model Variables + + g = cv.Variable(nonneg=True) + G = sqrtm(sigma) + risk1 = g**2 + devconstraints = [cv.SOC(g, G.T @ w)] + + # Return Variables + + if model == "Classic": + if kelly == "exact": + if obj == "Sharpe": + ret = 1 / n * cv.sum(gr) - rf0 * k + else: + ret = 1 / n * cv.sum(cv.log(1 + returns @ w)) + elif kelly == "approx": + if obj == "Sharpe": + ret = mu @ w - 0.5 * cv.quad_over_lin(g, k) + else: + ret = mu @ w - 0.5 * g**2 + elif kelly == False: + ret = mu @ w + else: + ret = mu @ w + + # MAD Model Variables + + madmodel = False + Y = cv.Variable((returns.shape[0], 1)) + u = np.ones((returns.shape[0], 1)) * mu + a = returns - u + risk2 = cv.sum(Y) / n + # madconstraints=[a @ w >= -Y, a @ w <= Y, Y >= 0] + madconstraints = [a @ w >= -Y, Y >= 0] + + # Semi Variance Model Variables + + risk3 = cv.norm(Y, "fro") / cv.sqrt(n - 1) + + # CVaR Model Variables + + VaR = cv.Variable((1, 1)) + alpha = self.alpha + X = returns @ w + Z = cv.Variable((returns.shape[0], 1)) + risk4 = VaR + 1 / (alpha * n) * cv.sum(Z) + cvarconstraints = [Z >= 0, Z >= -X - VaR] + + # Worst Realization (Minimax) Model Variables + + M = cv.Variable((1, 1)) + risk5 = M + wrconstraints = [-X <= M] + + # Lower Partial Moment Variables + + lpmmodel = False + lpm = cv.Variable((returns.shape[0], 1)) + lpmconstraints = [lpm >= 0] + + if obj == "Sharpe": + lpmconstraints += [lpm >= rf0 * k - X] + else: + lpmconstraints += [lpm >= rf0 - X] + + # First Lower Partial Moment (Omega) Model Variables + + risk6 = cv.sum(lpm) / n + + # Second Lower Partial Moment (Sortino) Model Variables + + risk7 = cv.norm(lpm, "fro") / cv.sqrt(n - 1) + + # Drawdown Model Variables + + drawdown = False + if obj == "Sharpe": + X1 = k + nav @ w + else: + X1 = 1 + nav @ w + + U = cv.Variable((nav.shape[0] + 1, 1)) + ddconstraints = [U[1:] * 1000 >= X1 * 1000, U[1:] * 1000 >= U[:-1] * 1000] + + if obj == "Sharpe": + ddconstraints += [U[1:] * 1000 >= k * 1000, U[0] * 1000 == k * 1000] + else: + ddconstraints += [U[1:] * 1000 >= 1 * 1000, U[0] * 1000 == 1 * 1000] + + # Maximum Drawdown Model Variables + + MDD = cv.Variable((1, 1)) + risk8 = MDD + mddconstraints = [MDD >= U[1:] - X1] + + # Average Drawdown Model Variables + + risk9 = 1 / n * cv.sum(U[1:] - X1) + + # Conditional Drawdown Model Variables + + CDaR = cv.Variable((1, 1)) + Zd = cv.Variable((nav.shape[0], 1)) + risk10 = CDaR + 1 / (alpha * n) * cv.sum(Zd) + cdarconstraints = [ + Zd * 1000 >= U[1:] * 1000 - X1 * 1000 - CDaR * 1000, + Zd * 1000 >= 0, + ] + + # Ulcer Index Model Variables + + risk11 = cv.norm(U[1:] * 1000 - X1 * 1000, "fro") / np.sqrt(n) + + # Entropic Value at Risk Model Variables + + t1 = cv.Variable((1, 1)) + s1 = cv.Variable((1, 1), nonneg=True) + ui = cv.Variable((n, 1)) + risk12 = t1 + s1 * np.log(1 / (alpha * n)) + + if obj == "Sharpe": + evarconstraints = [cv.sum(ui) * 1000 <= s1 * 1000] + evarconstraints += [ + cv.constraints.ExpCone( + -X * 1000 - t1 * 1000, np.ones((n, 1)) @ s1 * 1000, ui * 1000 + ) + ] + else: + evarconstraints = [cv.sum(ui) <= s1] + evarconstraints += [ + cv.constraints.ExpCone(-X - t1, np.ones((n, 1)) @ s1, ui) + ] + + # Entropic Drawdown at Risk Model Variables + + t2 = cv.Variable((1, 1)) + s2 = cv.Variable((1, 1), nonneg=True) + uj = cv.Variable((n, 1)) + risk13 = t2 + s2 * np.log(1 / (alpha * n)) + + if obj == "Sharpe": + edarconstraints = [cv.sum(uj) * 1000 <= s2 * 1000] + edarconstraints += [ + cv.constraints.ExpCone( + U[1:] * 1000 - X1 * 1000 - t2 * 1000, + np.ones((n, 1)) @ s2 * 1000, + uj * 1000, + ) + ] + else: + edarconstraints = [cv.sum(uj) <= s2] + edarconstraints += [ + cv.constraints.ExpCone(U[1:] - X1 - t2, np.ones((n, 1)) @ s2, uj) + ] + + # Gini Mean Difference Model Variables + + owamodel = False + a1 = cv.Variable((n, 1)) + b1 = cv.Variable((n, 1)) + y = cv.Variable((n, 1)) + risk14 = cv.sum(a1 + b1) + + owaconstraints = [returns @ w == y] + gmd_w = owa.owa_gmd(n) / 2 + onesvec = np.ones((n, 1)) + gmdconstraints = [y @ gmd_w.T <= onesvec @ a1.T + b1 @ onesvec.T] + + # Tail Gini Model Variables + + a2 = cv.Variable((n, 1)) + b2 = cv.Variable((n, 1)) + risk15 = cv.sum(a2 + b2) + a_sim = self.a_sim + + tg_w = owa.owa_tg(n, alpha=alpha) + tgconstraints = [y @ tg_w.T <= onesvec @ a2.T + b2 @ onesvec.T] + + # Range Model Variables + + a3 = cv.Variable((n, 1)) + b3 = cv.Variable((n, 1)) + risk16 = cv.sum(a3 + b3) + + rg_w = owa.owa_rg(n) + rgconstraints = [y @ rg_w.T <= onesvec @ a3.T + b3 @ onesvec.T] + + # CVaR Range Model Variables + + a4 = cv.Variable((n, 1)) + b4 = cv.Variable((n, 1)) + risk17 = cv.sum(a4 + b4) + + if self.beta is None: + beta = alpha + else: + beta = self.beta + + cvrg_w = owa.owa_cvrg(n, alpha=alpha, beta=beta) + cvrgconstraints = [y @ cvrg_w.T <= onesvec @ a4.T + b4 @ onesvec.T] + + # Tail Gini Range Model Variables + + a5 = cv.Variable((n, 1)) + b5 = cv.Variable((n, 1)) + risk18 = cv.sum(a5 + b5) + + if self.b_sim is None: + b_sim = a_sim + else: + b_sim = self.b_sim + + tgrg_w = owa.owa_tgrg(n, alpha=alpha, a_sim=a_sim, beta=beta, b_sim=b_sim) + tgrgconstraints = [y @ tgrg_w.T <= onesvec @ a5.T + b5 @ onesvec.T] + + # Cardinal Boolean Variables + + if self.card is not None: + if obj == "Sharpe": + e = cv.Variable((mu.shape[1], 1), boolean=True) + e1 = cv.Variable((mu.shape[1], 1)) + else: + e = cv.Variable((mu.shape[1], 1), boolean=True) + + # Problem Weight Constraints + + if obj == "Sharpe": + constraints = [cv.sum(w) == self.budget * k, k * 1000 >= 0] + if self.sht == False: + constraints += [w <= self.upperlng * k, w * 1000 >= 0] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + e1 <= k, + e1 >= 0, + e1 <= 100000 * e, + e1 >= k - 100000 * (1 - e), + w <= self.upperlng * e1, + ] + elif self.sht == True: + constraints += [ + cv.sum(cv.pos(w)) * 1000 <= self.upperlng * k * 1000, + cv.sum(cv.neg(w)) * 1000 <= self.uppersht * k * 1000, + ] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + e1 <= k, + e1 >= 0, + e1 <= 100000 * e, + e1 >= k - 100000 * (1 - e), + w >= -self.uppersht * e1, + w <= self.upperlng * e1, + ] + + else: + constraints = [cv.sum(w) == self.budget] + if self.sht == False: + constraints += [w <= self.upperlng, w * 1000 >= 0] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + w <= self.upperlng * e, + ] + + elif self.sht == True: + constraints += [ + cv.sum(cv.pos(w)) * 1000 <= self.upperlng * 1000, + cv.sum(cv.neg(w)) * 1000 <= self.uppersht * 1000, + ] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + w >= -self.uppersht * e, + w <= self.upperlng * e, + ] + + # Problem Linear Constraints + + if self.ainequality is not None and self.binequality is not None: + A = np.array(self.ainequality, ndmin=2) * 1000 + B = np.array(self.binequality, ndmin=2) * 1000 + if obj == "Sharpe": + constraints += [A @ w - B @ k >= 0] + else: + constraints += [A @ w - B >= 0] + + # Number of Effective Assets Constraints + + if self.nea is not None: + if obj == "Sharpe": + constraints += [cv.norm(w, "fro") <= 1 / self.nea**0.5 * k] + else: + constraints += [cv.norm(w, "fro") <= 1 / self.nea**0.5] + + # Tracking Error Model Variables + + c = np.array(self.benchweights, ndmin=2) + if self.kindbench == True: + bench = returns @ c + elif self.kindbench == False: + bench = np.array(self.benchindex, ndmin=2) + + # Tracking error Constraints + + if obj == "Sharpe": + if self.allowTE == True: + TE_1 = cv.norm(returns @ w - bench @ k, "fro") / cv.sqrt(n - 1) + constraints += [TE_1 * 1000 <= self.TE * k * 1000] + else: + if self.allowTE == True: + TE_1 = cv.norm(returns @ w - bench, "fro") / cv.sqrt(n - 1) + constraints += [TE_1 * 1000 <= self.TE * 1000] + + # Turnover Constraints + + if obj == "Sharpe": + if self.allowTO == True: + TO_1 = cv.abs(w - c @ k) * 1000 + constraints += [TO_1 <= self.turnover * k * 1000] + else: + if self.allowTO == True: + TO_1 = cv.abs(w - c) * 1000 + constraints += [TO_1 <= self.turnover * 1000] + + # Problem return Constraints + + if self.lowerret is not None: + if obj == "Sharpe": + constraints += [ret >= self.lowerret * k] + else: + constraints += [ret >= self.lowerret] + + # Problem risk Constraints + + if self.upperdev is not None: + if obj == "Sharpe": + constraints += [g <= self.upperdev * k] + else: + constraints += [g <= self.upperdev] + constraints += devconstraints + + if self.uppermad is not None: + if obj == "Sharpe": + constraints += [risk2 <= self.uppermad * k / 2] + else: + constraints += [risk2 <= self.uppermad / 2] + madmodel = True + + if self.uppersdev is not None: + if obj == "Sharpe": + constraints += [risk3 <= self.uppersdev * k] + else: + constraints += [risk3 <= self.uppersdev] + madmodel = True + + if self.upperCVaR is not None: + if obj == "Sharpe": + constraints += [risk4 <= self.upperCVaR * k] + else: + constraints += [risk4 <= self.upperCVaR] + constraints += cvarconstraints + + if self.upperwr is not None: + if obj == "Sharpe": + constraints += [-X <= self.upperwr * k] + else: + constraints += [-X <= self.upperwr] + constraints += wrconstraints + + if self.upperflpm is not None: + if obj == "Sharpe": + constraints += [risk6 <= self.upperflpm * k] + else: + constraints += [risk6 <= self.upperflpm] + lpmmodel = True + + if self.upperslpm is not None: + if obj == "Sharpe": + constraints += [risk7 <= self.upperslpm * k] + else: + constraints += [risk7 <= self.upperslpm] + lpmmodel = True + + if self.uppermdd is not None: + if obj == "Sharpe": + constraints += [U[1:] - X1 <= self.uppermdd * k] + else: + constraints += [U[1:] - X1 <= self.uppermdd] + constraints += mddconstraints + drawdown = True + + if self.upperadd is not None: + if obj == "Sharpe": + constraints += [risk9 <= self.upperadd * k] + else: + constraints += [risk9 <= self.upperadd] + drawdown = True + + if self.upperCDaR is not None: + if obj == "Sharpe": + constraints += [risk10 <= self.upperCDaR * k] + else: + constraints += [risk10 <= self.upperCDaR] + constraints += cdarconstraints + drawdown = True + + if self.upperuci is not None: + if obj == "Sharpe": + constraints += [risk11 <= self.upperuci * 1000 * k] + else: + constraints += [risk11 <= self.upperuci * 1000] + drawdown = True + + if self.upperEVaR is not None: + if obj == "Sharpe": + constraints += [risk12 <= self.upperEVaR * k] + else: + constraints += [risk12 <= self.upperEVaR] + constraints += evarconstraints + + if self.upperEDaR is not None: + if obj == "Sharpe": + constraints += [risk13 <= self.upperEDaR * k] + else: + constraints += [risk13 <= self.upperEDaR] + constraints += edarconstraints + + if self.uppergmd is not None: + if obj == "Sharpe": + constraints += [risk14 <= self.uppergmd * k / 2] + else: + constraints += [risk14 <= self.uppergmd / 2] + constraints += gmdconstraints + owamodel = True + + if self.uppertg is not None: + if obj == "Sharpe": + constraints += [risk15 <= self.uppertg * k] + else: + constraints += [risk15 <= self.uppertg] + constraints += tgconstraints + owamodel = True + + if self.upperrg is not None: + if obj == "Sharpe": + constraints += [risk16 <= self.upperrg * k] + else: + constraints += [risk16 <= self.upperrg] + constraints += rgconstraints + owamodel = True + + if self.uppercvrg is not None: + if obj == "Sharpe": + constraints += [risk17 <= self.uppercvrg * k] + else: + constraints += [risk17 <= self.uppercvrg] + constraints += cvrgconstraints + owamodel = True + + if self.uppertgrg is not None: + if obj == "Sharpe": + constraints += [risk18 <= self.uppertgrg * k] + else: + constraints += [risk18 <= self.uppertgrg] + constraints += tgrgconstraints + owamodel = True + + # Defining risk function + + if rm == "MV": + risk = risk1 + if self.upperdev is None: + constraints += devconstraints + elif rm == "MAD": + risk = risk2 + madmodel = True + elif rm == "MSV": + risk = risk3 + madmodel = True + elif rm == "CVaR": + risk = risk4 + if self.upperCVaR is None: + constraints += cvarconstraints + elif rm == "WR": + risk = risk5 + if self.upperwr is None: + constraints += wrconstraints + elif rm == "FLPM": + risk = risk6 + lpmmodel = True + elif rm == "SLPM": + risk = risk7 + lpmmodel = True + elif rm == "MDD": + risk = risk8 + drawdown = True + if self.uppermdd is None: + constraints += mddconstraints + elif rm == "ADD": + risk = risk9 + drawdown = True + elif rm == "CDaR": + risk = risk10 + drawdown = True + if self.upperCDaR is None: + constraints += cdarconstraints + elif rm == "UCI": + risk = risk11 + drawdown = True + l = l / 1000 + elif rm == "EVaR": + risk = risk12 + if self.upperEVaR is None: + constraints += evarconstraints + elif rm == "EDaR": + risk = risk13 + drawdown = True + if self.upperEDaR is None: + constraints += edarconstraints + elif rm == "GMD": + risk = risk14 + owamodel = True + if self.uppergmd is None: + constraints += gmdconstraints + elif rm == "TG": + risk = risk15 + owamodel = True + if self.uppertg is None: + constraints += tgconstraints + elif rm == "RG": + risk = risk16 + owamodel = True + if self.upperrg is None: + constraints += rgconstraints + elif rm == "CVRG": + risk = risk17 + owamodel = True + if self.uppertgrg is None: + constraints += cvrgconstraints + elif rm == "TGRG": + risk = risk18 + owamodel = True + if self.uppertg is None: + constraints += tgrgconstraints + + if madmodel == True: + constraints += madconstraints + if lpmmodel == True: + constraints += lpmconstraints + if drawdown == True: + constraints += ddconstraints + if owamodel == True: + constraints += owaconstraints + + # Frontier Variables + + portafolio = {} + + for i in self.assetslist: + portafolio.update({i: []}) + + # Optimization Process + + # Defining objective function + if obj == "Sharpe": + if model == "Classic": + if kelly == "exact": + constraints += [risk <= 1] + constraints += [ + cv.constraints.ExpCone(gr, np.ones((n, 1)) @ k, k + returns @ w) + ] + objective = cv.Maximize(ret * 1000) + elif kelly == "approx": + constraints += [risk <= 1] + if rm != "MV": + constraints += devconstraints + objective = cv.Maximize(ret) + elif kelly == False: + constraints += [mu @ w - rf0 * k == 1] + objective = cv.Minimize(risk * 1000) + else: + constraints += [mu @ w - rf0 * k == 1] + objective = cv.Minimize(risk * 1000) + elif obj == "MinRisk": + objective = cv.Minimize(risk * 1000) + elif obj == "Utility": + objective = cv.Maximize(ret - l * risk) + elif obj == "MaxRet": + objective = cv.Maximize(ret * 1000) + + try: + prob = cv.Problem(objective, constraints) + for solver in self.solvers: + try: + if len(self.sol_params) == 0: + prob.solve(solver=solver) + else: + prob.solve(solver=solver, **self.sol_params[solver]) + except: + pass + if w.value is not None: + break + + if obj == "Sharpe": + weights = np.array(w.value / k.value, ndmin=2).T + if rm == "EVaR" or self.upperEVaR is not None: + self.z_EVaR = s1.value / k.value + if rm == "EDaR" or self.upperEDaR is not None: + self.z_EDaR = s2.value / k.value + else: + weights = np.array(w.value, ndmin=2).T + if rm == "EVaR" or self.upperEVaR is not None: + self.z_EVaR = s1.value + if rm == "EDaR" or self.upperEDaR is not None: + self.z_EDaR = s2.value + + if self.sht == False: + weights = np.abs(weights) / np.sum(np.abs(weights)) * self.budget + + for j in self.assetslist: + portafolio[j].append(weights[0, self.assetslist.index(j)]) + + except: + pass + + try: + self.optimal = pd.DataFrame( + portafolio, index=["weights"], dtype=np.float64 + ).T + except: + self.optimal = None + print("The problem doesn't have a solution with actual input parameters") + + return self.optimal + + def rp_optimization(self, model="Classic", rm="MV", rf=0, b=None, hist=True): + r""" + This method that calculates the risk parity portfolio using the risk + budgeting approach :cite:`a-Roncalli` :cite:`a-RichardRoncalli`, + according to the optimization model selected by the user. The general + problem that solves is: + + .. math:: + \begin{aligned} + &\underset{w}{\min} & & \phi(w)\\ + &\text{s.t.} & & b \log(w) \geq c\\ + & & & \mu w \geq \overline{\mu} \\ + & & & Aw \geq B \\ + & & & w \geq 0 \\ + \end{aligned} + + Where: + + :math:`w` are the weights of the portfolio. + + :math:`\mu`: is the vector of expected returns. + + :math:`b` is a vector of risk constraints. + + :math:`Aw \geq B`: is a set of linear constraints. + + :math:`\phi(w)`: is a risk measure. + + :math:`c`: is an arbitrary constant. + + Parameters + ---------- + model : str can be 'Classic' or 'FM' + The model used for optimize the portfolio. + The default is 'Classic'. Posible values are: + + - 'Classic': use estimates of expected return vector and covariance matrix that depends on historical data. + - 'FM': use estimates of expected return vector and covariance matrix based on a Risk Factor model specified by the user. + + rm : str, optional + The risk measure used to optimze the portfolio. + The default is 'MV'. Posible values are: + + - 'MV': Standard Deviation. + - 'MAD': Mean Absolute Deviation. + - 'GMD': Gini Mean Difference. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'EDaR': Entropic Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + + rf : float, optional + Risk free rate, must be in the same period of assets returns. + Used for 'FLPM' and 'SLPM'. + The default is 0. + b : float, optional + The vector of risk constraints per asset. + The default is 1/n (number of assets). + hist : bool, optional + Indicate what kind of returns are used to calculate risk measures + that depends on scenarios (All except 'MV' risk measure). + If model = 'FM', True means historical covariance and returns and + False means Risk Factor model for covariance and returns. The + default is True. + + Returns + ------- + w : DataFrame + The weights of optimal portfolio. + + """ + + # General model Variables + + mu = None + sigma = None + returns = None + if model == "Classic": + mu = np.array(self.mu, ndmin=2) + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif model == "FM": + mu = np.array(self.mu_fm, ndmin=2) + if hist == False: + sigma = np.array(self.cov_fm, ndmin=2) + returns = np.array(self.returns_fm, ndmin=2) + nav = np.array(self.nav_fm, ndmin=2) + elif hist == True: + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + + # General Model Variables + + if b is None: + b = np.ones((1, mu.shape[1])) + b = b / mu.shape[1] + + returns = np.array(returns, ndmin=2) + w = cv.Variable((mu.shape[1], 1)) + k = cv.Variable((1, 1)) + rf0 = rf + n = returns.shape[0] + ret = mu @ w + constraints = [] + + # MV Model Variables + + g = cv.Variable(nonneg=True) + G = sqrtm(sigma) + risk1 = g**2 + devconstraints = [cv.SOC(g, G.T @ w)] + + # MAD Model Variables + + Y = cv.Variable((returns.shape[0], 1)) + u = np.ones((returns.shape[0], 1)) * mu + a = returns - u + risk2 = cv.sum(Y) / n + # madconstraints=[a @ w >= -Y, a @ w <= Y, Y >= 0] + madconstraints = [a @ w >= -Y, Y >= 0] + + # Semi Variance Model Variables + + risk3 = cv.norm(Y, "fro") / cv.sqrt(n - 1) + + # CVaR Model Variables + + VaR = cv.Variable((1, 1)) + alpha = self.alpha + X = returns @ w + Z = cv.Variable((returns.shape[0], 1)) + risk4 = VaR + 1 / (alpha * n) * cv.sum(Z) + cvarconstraints = [Z >= 0, Z >= -X - VaR] + + # Lower Partial Moment Variables + + lpm = cv.Variable((returns.shape[0], 1)) + lpmconstraints = [lpm >= 0, lpm >= rf0 * k - X] + + # First Lower Partial Moment (Omega) Model Variables + + risk6 = cv.sum(lpm) / n + + # Second Lower Partial Moment (Sortino) Model Variables + + risk7 = cv.norm(lpm, "fro") / cv.sqrt(n - 1) + + # Drawdown Model Variables + + X1 = k + nav @ w + U = cv.Variable((nav.shape[0] + 1, 1)) + ddconstraints = [ + U[1:] * 1000 >= X1 * 1000, + U[1:] * 1000 >= U[:-1] * 1000, + U[1:] * 1000 >= 1 * 1000 * k, + U[0] * 1000 == 1 * 1000 * k, + ] + + # Conditional Drawdown Model Variables + + CDaR = cv.Variable((1, 1)) + Zd = cv.Variable((nav.shape[0], 1)) + risk10 = CDaR + 1 / (alpha * n) * cv.sum(Zd) + cdarconstraints = [ + Zd * 1000 >= U[1:] * 1000 - X1 * 1000 - CDaR * 1000, + Zd * 1000 >= 0, + ] + + # Ulcer Index Model Variables + + risk11 = cv.norm(U[1:] - X1, "fro") / np.sqrt(n) + + # Entropic Value at Risk Model Variables + + t1 = cv.Variable((1, 1)) + s1 = cv.Variable((1, 1), nonneg=True) + ui = cv.Variable((n, 1)) + risk12 = t1 + s1 * np.log(1 / (alpha * n)) + evarconstraints = [cv.sum(ui) * 1000 <= s1 * 1000] + evarconstraints += [ + cv.constraints.ExpCone( + -X * 1000 - t1 * 1000, np.ones((n, 1)) @ s1 * 1000, ui * 1000 + ) + ] + + # Entropic Drawdown at Risk Model Variables + + t2 = cv.Variable((1, 1)) + s2 = cv.Variable((1, 1), nonneg=True) + uj = cv.Variable((n, 1)) + risk13 = t2 + s2 * np.log(1 / (alpha * n)) + edarconstraints = [cv.sum(uj) * 1000 <= s2 * 1000] + edarconstraints += [ + cv.constraints.ExpCone( + U[1:] * 1000 - X1 * 1000 - t2 * 1000, + np.ones((n, 1)) @ s2 * 1000, + uj * 1000, + ) + ] + + # Gini Mean Difference Model Variables + + a1 = cv.Variable((n, 1)) + b1 = cv.Variable((n, 1)) + y = cv.Variable((n, 1)) + risk14 = cv.sum(a1 + b1) + + owaconstraints = [returns @ w == y] + gmd_w = owa.owa_gmd(n) / 2 + + onesvec = np.ones((n, 1)) + gmdconstraints = [y @ gmd_w.T <= onesvec @ a1.T + b1 @ onesvec.T] + + # Tail Gini Model Variables + + a2 = cv.Variable((n, 1)) + b2 = cv.Variable((n, 1)) + risk15 = cv.sum(a2 + b2) + a_sim = self.a_sim + + tg_w = owa.owa_tg(n, alpha=alpha, a_sim=a_sim) + tgconstraints = [y @ tg_w.T <= onesvec @ a2.T + b2 @ onesvec.T] + + # CVaR Range Model Variables + + a4 = cv.Variable((n, 1)) + b4 = cv.Variable((n, 1)) + risk17 = cv.sum(a4 + b4) + + if self.beta is None: + beta = alpha + else: + beta = self.beta + + cvrg_w = owa.owa_cvrg(n, alpha=alpha, beta=beta) + cvrgconstraints = [y @ cvrg_w.T <= onesvec @ a4.T + b4 @ onesvec.T] + + # Tail Gini Range Model Variables + + a5 = cv.Variable((n, 1)) + b5 = cv.Variable((n, 1)) + risk18 = cv.sum(a5 + b5) + + if self.b_sim is None: + b_sim = a_sim + else: + b_sim = self.b_sim + + tgrg_w = owa.owa_tgrg(n, alpha=alpha, a_sim=a_sim, beta=beta, b_sim=b_sim) + tgrgconstraints = [y @ tgrg_w.T <= onesvec @ a5.T + b5 @ onesvec.T] + + # Problem Linear Constraints + + if self.ainequality is not None and self.binequality is not None: + A = np.array(self.ainequality, ndmin=2) * 1000 + B = np.array(self.binequality, ndmin=2) * 1000 + constraints += [A @ w - B @ k >= 0] + + # Problem Return Constraint + + if self.lowerret is not None: + constraints += [ret >= self.lowerret * k] + + # Defining risk function + + if rm == "MV": + risk = risk1 + constraints += devconstraints + elif rm == "MAD": + risk = risk2 + constraints += madconstraints + elif rm == "MSV": + risk = risk3 + constraints += madconstraints + elif rm == "CVaR": + risk = risk4 + constraints += cvarconstraints + elif rm == "FLPM": + risk = risk6 + constraints += lpmconstraints + elif rm == "SLPM": + risk = risk7 + constraints += lpmconstraints + elif rm == "CDaR": + risk = risk10 + constraints += ddconstraints + constraints += cdarconstraints + elif rm == "UCI": + risk = risk11 + constraints += ddconstraints + elif rm == "EVaR": + risk = risk12 + constraints += evarconstraints + elif rm == "EDaR": + risk = risk13 + constraints += ddconstraints + constraints += edarconstraints + elif rm == "GMD": + risk = risk14 + constraints += owaconstraints + constraints += gmdconstraints + elif rm == "TG": + risk = risk15 + constraints += owaconstraints + constraints += tgconstraints + elif rm == "CVRG": + risk = risk17 + constraints += owaconstraints + constraints += cvrgconstraints + elif rm == "TGRG": + risk = risk18 + constraints += owaconstraints + constraints += tgrgconstraints + + # Risk budgeting constraint + + constraints += [ + b @ cv.log(w) >= 1, + w * 1000 >= 0, + cv.sum(w) == k, + ] + + # Frontier Variables + + portafolio = {} + + for i in self.assetslist: + portafolio.update({i: []}) + + # Optimization Process + + # Defining objective function + + objective = cv.Minimize(risk * 1000) + + try: + prob = cv.Problem(objective, constraints) + for solver in self.solvers: + try: + if len(self.sol_params) == 0: + prob.solve(solver=solver) + else: + prob.solve(solver=solver, **self.sol_params[solver]) + except: + pass + if w.value is not None: + break + + weights = np.array(w.value, ndmin=2).T + weights = np.abs(weights) / np.sum(np.abs(weights)) + + for j in self.assetslist: + portafolio[j].append(weights[0, self.assetslist.index(j)]) + + except: + pass + + try: + self.rp_optimal = pd.DataFrame( + portafolio, index=["weights"], dtype=np.float64 + ).T + except: + self.rp_optimal = None + print("The problem doesn't have a solution with actual input parameters") + + return self.rp_optimal + + def rrp_optimization(self, model="Classic", version="A", l=1, b=None, hist=True): + r""" + This method that calculates the relaxed risk parity portfolio according + to the optimization model and version selected by the user + :cite:`a-RichardRoncalli`. The general problem that solves is: + + .. math:: + \begin{aligned} + &\underset{w}{\min} & & \psi - \gamma & \\ + &\text{s.t.} & & \zeta = \Sigma w \\ + & & & w^{T} \Sigma w \leq N \left ( \psi^{2} - \rho^{2} \right ) & \\ + & & & w_{i} \zeta_{i} \geq \gamma^{2} & \forall i=1 , \ldots , N \\ + & & & \lambda x^{T} \Theta x \leq \rho^{2} & \\ + & & & \mu w \geq \overline{\mu} & \\ + & & & Aw \geq B & \\ + & & & \sum^{N}_{i=1} w_{i} = 1 & \\ + & & & \psi, \gamma, \rho, w \geq 0 & \\ + \end{aligned} + + Where: + + :math:`w`: is the vector of weights of the optimum portfolio. + + :math:`\mu`: is the vector of expected returns. + + :math:`\Sigma`: is the covariance matrix of assets returns. + + :math:`\psi`: is the average risk of the portfolio. + + :math:`\gamma`: is the lower bound of each asset risk constribution. + + :math:`\zeta_{i}`: is the marginal risk of asset :math:`i`. + + :math:`\rho`: is a regularization variable. + + :math:`\lambda`: is a penalty parameter of :math:`\rho`. + + :math:`\Theta = \text{diag}(\Sigma)` + + :math:`Aw \geq B`: is a set of linear constraints. + + Parameters + ---------- + model : str can be 'Classic' or 'FM' + The model used for optimize the portfolio. + The default is 'Classic'. Posible values are: + + - 'Classic': use estimates of expected return vector and covariance matrix that depends on historical data. + - 'FM': use estimates of expected return vector and covariance matrix based on a Risk Factor model specified by the user. + + version : str can be 'A', 'B' or 'C' + Relaxed risk parity model version proposed in :cite:`a-RichardRoncalli`. + The default is 'A'. Posible values are: + + - 'A': without regularization and penalization constraints. + - 'B': with regularization constraint but without penalization constraint. + - 'C': with regularization and penalization constraints. + + l : float, optional + The penalization factor of penalization constraints. Only used with + version 'C'. The default is 1. + b : float, optional + The vector of risk constraints per asset. + The default is 1/n (number of assets). + hist : bool, optional + Indicate what kind of covariance matrix is used. + If model = 'FM', True means historical covariance and + False means Risk Factor model for covariance. The default is + True. + + Returns + ------- + w : DataFrame + The weights of optimal portfolio. + + """ + + # General model Variables + + mu = None + sigma = None + returns = None + if model == "Classic": + mu = np.array(self.mu, ndmin=2) + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif model == "FM": + mu = np.array(self.mu_fm, ndmin=2) + if hist == False: + sigma = np.array(self.cov_fm, ndmin=2) + returns = np.array(self.returns_fm, ndmin=2) + nav = np.array(self.nav_fm, ndmin=2) + elif hist == True: + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + + # General Model Variables + + if b is None: + b = np.ones((1, mu.shape[1])) + b = b / mu.shape[1] + + returns = np.array(returns, ndmin=2) + w = cv.Variable((mu.shape[1], 1)) + n = returns.shape[1] + ret = mu @ w + + # RRP Model Variables + + G = sqrtm(sigma) + Theta = np.diag(np.sqrt(np.diag(sigma))) + psi = cv.Variable(nonneg=True) + rho = cv.Variable(nonneg=True) + gamma = cv.Variable(nonneg=True) + zeta = cv.Variable((mu.shape[1], 1)) + risk = psi - gamma + + # General Model Constraints + + constraints = [] + constraints += [ + zeta == sigma @ w, + cv.sum(w) == 1, + gamma >= 0, + psi >= 0, + zeta >= 0, + w >= 0, + ] + + for i in range(mu.shape[1]): + constraints += [ + cv.SOC( + w[i, 0] + zeta[i, 0], cv.vstack([2 * gamma, w[i, 0] - zeta[i, 0]]) + ) + ] + + # Specific Model Constraints + + if version == "A": + constraints += [cv.SOC(n**0.5 * psi, G.T @ w)] + elif version == "B": + constraints += [ + cv.SOC( + 2 * n**0.5 * psi, + cv.vstack([2 * G.T @ w, -2 * n**0.5 * rho * np.ones((1, 1))]), + ) + ] + constraints += [cv.SOC(rho, G.T @ w)] + constraints += [rho >= 0] + elif version == "C": + constraints += [ + cv.SOC( + 2 * n**0.5 * psi, + cv.vstack([2 * G.T @ w, -2 * n**0.5 * rho * np.ones((1, 1))]), + ) + ] + constraints += [cv.SOC(rho, l**0.5 * Theta.T @ w)] + constraints += [rho >= 0] + + # Problem Linear Constraints + + if self.ainequality is not None and self.binequality is not None: + A = np.array(self.ainequality, ndmin=2) * 1000 + B = np.array(self.binequality, ndmin=2) * 1000 + constraints += [A @ w - B >= 0] + + # Problem Return Constraint + + if self.lowerret is not None: + constraints += [ret >= self.lowerret] + + # Frontier Variables + + portafolio = {} + + for i in self.assetslist: + portafolio.update({i: []}) + + # Optimization Process + + # Defining objective function + + objective = cv.Minimize(risk * 1000) + + try: + prob = cv.Problem(objective, constraints) + for solver in self.solvers: + try: + if len(self.sol_params) == 0: + prob.solve(solver=solver) + else: + prob.solve(solver=solver, **self.sol_params[solver]) + except: + pass + if w.value is not None: + break + + weights = np.array(w.value, ndmin=2).T + weights = np.abs(weights) / np.sum(np.abs(weights)) + + for j in self.assetslist: + portafolio[j].append(weights[0, self.assetslist.index(j)]) + + except: + pass + + try: + self.rp_optimal = pd.DataFrame( + portafolio, index=["weights"], dtype=np.float64 + ).T + except: + self.rp_optimal = None + print("The problem doesn't have a solution with actual input parameters") + + return self.rp_optimal + + def wc_optimization(self, obj="Sharpe", rf=0, l=2, Umu="box", Ucov="box"): + r""" + This method that calculates the worst case mean variance portfolio + according to the objective function and uncertainty sets selected by + the user. + + Parameters + ---------- + obj : str can be {'MinRisk', 'Utility', 'Sharpe' or 'MaxRet'}. + Objective function of the optimization model. + The default is 'Sharpe'. Posible values are: + + - 'MinRisk': Minimize the worst case formulation of the selected risk measure. + - 'Utility': Maximize the worst case formulation of the Utility function :math:`\mu w - l \phi_{i}(w)`. + - 'Sharpe': Maximize the worst case formulation of the risk adjusted return ratio based on the selected risk measure. + - 'MaxRet': Maximize the worst case formulation of the expected return of the portfolio. + + rf : float, optional + Risk free rate, must be in the same period of assets returns. + The default is 0. + l : scalar, optional + Risk aversion factor of the 'Utility' objective function. + The default is 2. + Umu : str, optional + The type of uncertainty set for the mean vector used in the model. + The default is 'box'. Posible values are: + + - 'box': Use a box uncertainty set for the mean vector. + - 'ellip': Use a elliptical uncertainty set for the mean vector. + - None: Don't use an uncertainty set for mean vector. + + Ucov : str, optional + The type of uncertainty set for the covariance matrix used in the model. + The default is 'box'. Posible values are: + + - 'box': Use a box uncertainty set for the covariance matrix. + - 'ellip': Use a elliptical uncertainty set for the covariance matrix. + - None: Don't use an uncertainty set for covariance matrix. + + Returns + ------- + w : DataFrame + The weights of optimal portfolio. + + """ + + # General model Variables + + mu = self.mu.to_numpy() + sigma = self.cov.to_numpy() + returns = self.returns.to_numpy() + + cov_l = self.cov_l.to_numpy() + cov_u = self.cov_u.to_numpy() + cov_mu = self.cov_mu.to_numpy() + cov_sigma = self.cov_sigma.to_numpy() + d_mu = self.d_mu.to_numpy() + k_mu = self.k_mu + k_sigma = self.k_sigma + + n = mu.shape[1] + w = cv.Variable((n, 1)) + Au = cv.Variable((n, n), symmetric=True) + Al = cv.Variable((n, n), symmetric=True) + X = cv.Variable((n, n), symmetric=True) + Z = cv.Variable((n, n), symmetric=True) + + k = cv.Variable((1, 1)) + rf0 = rf + g = cv.Variable(nonneg=True) + + constraints = [] + + # Uncertainty Sets for Mean Vector + + if Umu == "box": + if obj == "Sharpe": + constraints += [mu @ w - d_mu @ cv.abs(w) - rf0 * k >= 1] + else: + ret = mu @ w - d_mu @ cv.abs(w) + elif Umu == "ellip": + if obj == "Sharpe": + constraints += [ + mu @ w - k_mu * cv.pnorm(sqrtm(cov_mu) @ w, 2) - rf0 * k >= 1 + ] + else: + ret = mu @ w - k_mu * cv.pnorm(sqrtm(cov_mu) @ w, 2) + else: + if obj == "Sharpe": + constraints += [mu @ w - rf0 * k >= 1] + else: + ret = mu @ w + + # Uncertainty Sets for Covariance Matrix + + if Ucov == "box": + M1 = cv.vstack([Au - Al, w.T]) + if obj == "Sharpe": + M2 = cv.vstack([w, k]) + else: + M2 = cv.vstack([w, np.ones((1, 1))]) + M = cv.hstack([M1, M2]) + risk = cv.trace(Au @ cov_u) - cv.trace(Al @ cov_l) + constraints += [M >> 0, Au >= 0, Al >= 0] + elif Ucov == "ellip": + M1 = cv.vstack([X, w.T]) + if obj == "Sharpe": + M2 = cv.vstack([w, k]) + else: + M2 = cv.vstack([w, np.ones((1, 1))]) + M = cv.hstack([M1, M2]) + risk = cv.trace(sigma @ (X + Z)) + risk += k_sigma * cv.pnorm(sqrtm(cov_sigma) @ (cv.vec(X) + cv.vec(Z)), 2) + constraints += [M >> 0, Z >> 0] + else: + G = sqrtm(sigma) + risk = g**2 + constraints += [cv.SOC(g, G.T @ w)] + + # Cardinal Boolean Variables + + if self.card is not None: + if obj == "Sharpe": + e = cv.Variable((mu.shape[1], 1), boolean=True) + e1 = cv.Variable((mu.shape[1], 1)) + else: + e = cv.Variable((mu.shape[1], 1), boolean=True) + + # Problem Weight Constraints + + if obj == "Sharpe": + constraints += [cv.sum(w) == self.budget * k, k >= 0] + if self.sht == False: + constraints += [w <= self.upperlng * k, w >= 0] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + e1 <= k, + e1 >= 0, + e1 <= 100000 * e, + e1 >= k - 100000 * (1 - e), + w <= self.upperlng * e1, + ] + elif self.sht == True: + constraints += [ + cv.sum(cv.pos(w)) * 1000 <= self.upperlng * k * 1000, + cv.sum(cv.neg(w)) * 1000 <= self.uppersht * k * 1000, + ] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + e1 <= k, + e1 >= 0, + e1 <= 100000 * e, + e1 >= k - 100000 * (1 - e), + w >= -self.uppersht * e1, + w <= self.upperlng * e1, + ] + + else: + constraints += [cv.sum(w) == self.budget] + if self.sht == False: + constraints += [w <= self.upperlng, w >= 0] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + w <= self.upperlng * e, + ] + + elif self.sht == True: + constraints += [ + cv.sum(cv.pos(w)) * 1000 <= self.upperlng * 1000, + cv.sum(cv.neg(w)) * 1000 <= self.uppersht * 1000, + ] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + w >= -self.uppersht * e, + w <= self.upperlng * e, + ] + + # Number of effective assets constraints + + if self.nea is not None: + if obj == "Sharpe": + constraints += [cv.sum_squares(w) * 1000 <= 1 / self.nea * k * 1000] + else: + constraints += [cv.sum_squares(w) * 1000 <= 1 / self.nea * 1000] + + # Tracking Error Model Variables + + c = np.array(self.benchweights, ndmin=2) + if self.kindbench == True: + bench = returns @ c + elif self.kindbench == False: + bench = np.array(self.benchindex, ndmin=2) + + # Problem aditional linear constraints + + if self.ainequality is not None and self.binequality is not None: + A = np.array(self.ainequality, ndmin=2) * 1000 + B = np.array(self.binequality, ndmin=2) * 1000 + if obj == "Sharpe": + constraints += [A @ w - B @ k >= 0] + else: + constraints += [A @ w - B >= 0] + + # Tracking error Constraints + + if obj == "Sharpe": + if self.allowTE == True: + TE_1 = cv.norm(returns @ w - bench @ k, "fro") / np.sqrt(n - 1) + constraints += [TE_1 <= self.TE * k] + else: + if self.allowTE == True: + TE_1 = cv.norm(returns @ w - bench, "fro") / np.sqrt(n - 1) + constraints += [TE_1 <= self.TE] + + # Turnover Constraints + + if obj == "Sharpe": + if self.allowTO == True: + TO_1 = cv.abs(w - c @ k) * 1000 + constraints += [TO_1 <= self.turnover * k * 1000] + else: + if self.allowTO == True: + TO_1 = cv.abs(w - c) * 1000 + constraints += [TO_1 <= self.turnover * 1000] + + # Frontier Variables + + portafolio = {} + + for i in self.assetslist: + portafolio.update({i: []}) + + # Optimization Process + + # Defining objective function + if obj == "Sharpe": + objective = cv.Minimize(risk) + elif obj == "MinRisk": + objective = cv.Minimize(risk) + elif obj == "Utility": + objective = cv.Maximize(ret - l * risk) + elif obj == "MaxRet": + objective = cv.Maximize(ret) + + try: + prob = cv.Problem(objective, constraints) + for solver in self.solvers: + try: + if len(self.sol_params) == 0: + prob.solve(solver=solver) + else: + prob.solve(solver=solver, **self.sol_params[solver]) + except: + pass + if w.value is not None: + break + + if obj == "Sharpe": + weights = np.array(w.value / k.value, ndmin=2).T + else: + weights = np.array(w.value, ndmin=2).T + + if self.sht == False: + weights = np.abs(weights) / np.sum(np.abs(weights)) * self.budget + + for j in self.assetslist: + portafolio[j].append(weights[0, self.assetslist.index(j)]) + + except: + pass + + try: + self.wc_optimal = pd.DataFrame( + portafolio, index=["weights"], dtype=np.float64 + ).T + except: + self.wc_optimal = None + print("The problem doesn't have a solution with actual input parameters") + + return self.wc_optimal + + def owa_optimization(self, obj="Sharpe", owa_w=None, kelly=False, rf=0, l=2): + r""" + This method that calculates the owa optimal portfolio according to the + weight vector given by the user. The general problem that + solves is: + + .. math:: + \begin{align} + &\underset{w}{\text{optimize}} & & F(w)\\ + &\text{s. t.} & & Aw \geq B\\ + \end{align} + + Where: + + :math:`F(w)` is the objective function based on an owa risk measure. + + :math:`Aw \geq B` is a set of linear constraints. + + Parameters + ---------- + obj : str can be {'MinRisk', 'Utility', 'Sharpe' or 'MaxRet'}. + Objective function of the optimization model. + The default is 'Sharpe'. Posible values are: + + - 'MinRisk': Minimize the selected risk measure. + - 'Utility': Maximize the Utility function :math:`\mu w - l \phi_{i}(w)`. + - 'Sharpe': Maximize the risk adjusted return ratio based on the selected risk measure. + + owa_w : 1darray, optional + The owa weight used to define the owa risk measure. + The default is 'MV'. Posible values are: + kelly : str, optional + Method used to calculate mean return. Posible values are False for + arithmetic mean return, "approx" for approximate mean logarithmic + return using first and second moment and "exact" for mean logarithmic + return. The default is False. + rf : float, optional + Risk free rate, must be in the same period of assets returns. + The default is 0. + l : scalar, optional + Risk aversion factor of the 'Utility' objective function. + The default is 2. + + Returns + ------- + w : DataFrame + The weights of optimal portfolio. + + """ + + # General model Variables + + mu = self.mu.to_numpy() + sigma = self.cov.to_numpy() + returns = self.returns.to_numpy() + w = cv.Variable((mu.shape[1], 1)) + k = cv.Variable((1, 1)) + rf0 = rf + n = returns.shape[0] + gr = cv.Variable((n, 1)) + + # MV Model Variables (for approx log returns) + + g = cv.Variable(nonneg=True) + G = sqrtm(sigma) + devconstraints = [cv.SOC(g, G.T @ w)] + + # Return Variables + + if kelly == "exact": + if obj == "Sharpe": + ret = 1 / n * cv.sum(gr) - rf0 * k + else: + ret = 1 / n * cv.sum(cv.log(1 + returns @ w)) + elif kelly == "approx": + if obj == "Sharpe": + ret = mu @ w - 0.5 * cv.quad_over_lin(g, k) + else: + ret = mu @ w - 0.5 * g**2 + elif kelly == False: + ret = mu @ w + + # OWA Model Variables + + a = cv.Variable((n, 1)) + b = cv.Variable((n, 1)) + y = cv.Variable((n, 1)) + risk = cv.sum(a + b) + + constraints = [] + constraints += [returns @ w == y] + if owa_w is None: + owa_w = owa.owa_gmd(n) / 2 + + onesvec = np.ones((n, 1)) + constraints += [y @ owa_w.T <= onesvec @ a.T + b @ onesvec.T] + + # Cardinal Boolean Variables + + if self.card is not None: + if obj == "Sharpe": + e = cv.Variable((mu.shape[1], 1), boolean=True) + e1 = cv.Variable((mu.shape[1], 1)) + else: + e = cv.Variable((mu.shape[1], 1), boolean=True) + + # Problem Weight Constraints + + if obj == "Sharpe": + constraints += [cv.sum(w) == self.budget * k, k * 1000 >= 0] + if self.sht == False: + constraints += [w <= self.upperlng * k, w * 1000 >= 0] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + e1 <= k, + e1 >= 0, + e1 <= 100000 * e, + e1 >= k - 100000 * (1 - e), + w <= self.upperlng * e1, + ] + elif self.sht == True: + constraints += [ + cv.sum(cv.pos(w)) * 1000 <= self.upperlng * k * 1000, + cv.sum(cv.neg(w)) * 1000 <= self.uppersht * k * 1000, + ] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + e1 <= k, + e1 >= 0, + e1 <= 100000 * e, + e1 >= k - 100000 * (1 - e), + w >= -self.uppersht * e1, + w <= self.upperlng * e1, + ] + + else: + constraints += [cv.sum(w) == self.budget] + if self.sht == False: + constraints += [w <= self.upperlng, w * 1000 >= 0] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + w <= self.upperlng * e, + ] + + elif self.sht == True: + constraints += [ + cv.sum(cv.pos(w)) * 1000 <= self.upperlng * 1000, + cv.sum(cv.neg(w)) * 1000 <= self.uppersht * 1000, + ] + if self.card is not None: + constraints += [ + cv.sum(e) <= self.card, + w >= -self.uppersht * e, + w <= self.upperlng * e, + ] + + # Problem Linear Constraints + + if self.ainequality is not None and self.binequality is not None: + A = np.array(self.ainequality, ndmin=2) * 1000 + B = np.array(self.binequality, ndmin=2) * 1000 + if obj == "Sharpe": + constraints += [A @ w - B @ k >= 0] + else: + constraints += [A @ w - B >= 0] + + # Number of Effective Assets Constraints + + if self.nea is not None: + if obj == "Sharpe": + constraints += [cv.norm(w, "fro") <= 1 / self.nea**0.5 * k] + else: + constraints += [cv.norm(w, "fro") <= 1 / self.nea**0.5] + + # Tracking Error Model Variables + + c = np.array(self.benchweights, ndmin=2) + if self.kindbench == True: + bench = returns @ c + elif self.kindbench == False: + bench = np.array(self.benchindex, ndmin=2) + + # Tracking error Constraints + + if obj == "Sharpe": + if self.allowTE == True: + TE_1 = cv.norm(returns @ w - bench @ k, "fro") / cv.sqrt(n - 1) + constraints += [TE_1 * 1000 <= self.TE * k * 1000] + else: + if self.allowTE == True: + TE_1 = cv.norm(returns @ w - bench, "fro") / cv.sqrt(n - 1) + constraints += [TE_1 * 1000 <= self.TE * 1000] + + # Turnover Constraints + + if obj == "Sharpe": + if self.allowTO == True: + TO_1 = cv.abs(w - c @ k) * 1000 + constraints += [TO_1 <= self.turnover * k * 1000] + else: + if self.allowTO == True: + TO_1 = cv.abs(w - c) * 1000 + constraints += [TO_1 <= self.turnover * 1000] + + # Problem return Constraints + + if self.lowerret is not None: + if obj == "Sharpe": + constraints += [ret >= self.lowerret * k] + else: + constraints += [ret >= self.lowerret] + + # Frontier Variables + + portafolio = {} + + for i in self.assetslist: + portafolio.update({i: []}) + + # Optimization Process + + # Defining objective function + if obj == "Sharpe": + if kelly == "exact": + constraints += [risk <= 1] + constraints += [ + cv.constraints.ExpCone(gr, np.ones((n, 1)) @ k, k + returns @ w) + ] + objective = cv.Maximize(ret * 1000) + elif kelly == "approx": + constraints += [risk <= 1] + constraints += devconstraints + objective = cv.Maximize(ret) + elif kelly == False: + constraints += [mu @ w - rf0 * k == 1] + objective = cv.Minimize(risk * 1000) + elif obj == "MinRisk": + objective = cv.Minimize(risk * 1000) + elif obj == "Utility": + objective = cv.Maximize(ret - l * risk) + elif obj == "MaxRet": + objective = cv.Maximize(ret * 1000) + + try: + prob = cv.Problem(objective, constraints) + for solver in self.solvers: + try: + if len(self.sol_params) == 0: + prob.solve(solver=solver) + else: + prob.solve(solver=solver, **self.sol_params[solver]) + except: + pass + if w.value is not None: + break + + if obj == "Sharpe": + weights = np.array(w.value / k.value, ndmin=2).T + else: + weights = np.array(w.value, ndmin=2).T + + if self.sht == False: + weights = np.abs(weights) / np.sum(np.abs(weights)) * self.budget + + for j in self.assetslist: + portafolio[j].append(weights[0, self.assetslist.index(j)]) + + except: + pass + + try: + self.owa_optimal = pd.DataFrame( + portafolio, index=["weights"], dtype=np.float64 + ).T + except: + self.owa_optimal = None + print("The problem doesn't have a solution with actual input parameters") + + return self.owa_optimal + + def frontier_limits(self, model="Classic", rm="MV", kelly=False, rf=0, hist=True): + r""" + Method that calculates the minimum risk and maximum return portfolios + available with current assets and constraints. + + Parameters + ---------- + model : str, optional + Methodology used to estimate input parameters. + The default is 'Classic'. + rm : str, optional + The risk measure used to optimze the portfolio. + The default is 'MV'. Posible values are: + + - 'MV': Standard Deviation. + - 'MAD': Mean Absolute Deviation. + - 'GMD': Gini Mean Difference. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'WR': Worst Realization (Minimax). + - 'RG': Range of returns. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'MDD': Maximum Drawdown of uncompounded cumulative returns (Calmar Ratio). + - 'ADD': Average Drawdown of uncompounded cumulative returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'EDaR': Entropic Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + + kelly : str, optional + Method used to calculate mean return. Posible values are False for + arithmetic mean return, "approx" for approximate mean logarithmic + return using first and second moment and "exact" for mean logarithmic + return. The default is False. + rf : scalar, optional + Risk free rate. The default is 0. + hist : bool, optional + Indicate what kind of returns are used to calculate risk measures + that depends on scenarios (All except 'MV' risk measure). + If model = 'BL', True means historical covariance and returns and + False Black Litterman covariance and historical returns. + If model = 'FM', True means historical covariance and returns and + False Risk Factor model for covariance and returns. + If model = 'BL_FM', True means historical covariance and returns, + False Black Litteram with Risk Factor model for covariance and + Risk Factor model for returns, and '2' Risk Factor model for + covariance and returns. The default is True. + + Returns + ------- + limits : DataFrame + A dataframe that containts the weights of the portfolios. + + Notes + ----- + This method is preferable (faster) to use instead of efficient_frontier + method to know the range of expected return and expected risk. + + """ + + w_min = self.optimization( + model=model, rm=rm, obj="MinRisk", kelly=kelly, rf=rf, l=0, hist=hist + ) + w_max = self.optimization( + model=model, rm=rm, obj="MaxRet", kelly=kelly, rf=rf, l=0, hist=hist + ) + + if w_min is not None and w_max is not None: + self.limits = pd.concat([w_min, w_max], axis=1) + self.limits.columns = ["w_min", "w_max"] + return self.limits + else: + raise NameError("The limits of the frontier can't be found") + + def efficient_frontier( + self, model="Classic", rm="MV", kelly=False, points=20, rf=0, hist=True + ): + r""" + Method that calculates several portfolios in the efficient frontier + of the selected risk measure, available with current assets and + constraints. + + Parameters + ---------- + model : str, optional + Methodology used to estimate input parameters. + The default is 'Classic'. + rm : str, optional + The risk measure used to optimze the portfolio. + The default is 'MV'. Posible values are: + + - 'MV': Standard Deviation. + - 'MAD': Mean Absolute Deviation. + - 'GMD': Gini Mean Difference. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'WR': Worst Realization (Minimax). + - 'RG': Range of returns. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'MDD': Maximum Drawdown of uncompounded cumulative returns (Calmar Ratio). + - 'ADD': Average Drawdown of uncompounded cumulative returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'EDaR': Entropic Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + + kelly : str, optional + Method used to calculate mean return. Posible values are False for + arithmetic mean return, "approx" for approximate mean logarithmic + return using first and second moment and "exact" for mean logarithmic + return. The default is False. + points : scalar, optional + Number of point calculated from the efficient frontier. + The default is 50. + rf : scalar, optional + Risk free rate. The default is 0. + hist : bool, optional + Indicate what kind of returns are used to calculate risk measures + that depends on scenarios (All except 'MV' risk measure). + If model = 'BL', True means historical covariance and returns and + False Black Litterman covariance and historical returns. + If model = 'FM', True means historical covariance and returns and + False Risk Factor model for covariance and returns. + If model = 'BL_FM', True means historical covariance and returns, + False Black Litteram with Risk Factor model for covariance and + Risk Factor model for returns, and '2' Risk Factor model for + covariance and returns. The default is True. + + Returns + ------- + frontier : DataFrame + A dataframe that containts the weights of the portfolios. + + Notes + ----- + It's recommendable that don't use this method when there are too many + assets (more than 100) and you are using a scenario based risk measure + (all except standard deviation). It's preferable to use frontier_limits + method (faster) to know the range of expected return and expected risk. + """ + + mu = None + sigma = None + returns = None + if model == "Classic": + mu = np.array(self.mu, ndmin=2) + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif model == "FM": + mu = np.array(self.mu_fm, ndmin=2) + if hist == False: + sigma = np.array(self.cov_fm, ndmin=2) + returns = np.array(self.returns_fm, ndmin=2) + nav = np.array(self.nav_fm, ndmin=2) + elif hist == True: + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif model == "BL": + mu = np.array(self.mu_bl, ndmin=2) + if hist == False: + sigma = np.array(self.cov_bl, ndmin=2) + elif hist == True: + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif model == "BL_FM": + mu = np.array(self.mu_bl_fm, ndmin=2) + if hist == False: + sigma = np.array(self.cov_bl_fm, ndmin=2) + returns = np.array(self.returns_fm, ndmin=2) + nav = np.array(self.nav_fm, ndmin=2) + elif hist == True: + sigma = np.array(self.cov, ndmin=2) + returns = np.array(self.returns, ndmin=2) + nav = np.array(self.nav, ndmin=2) + elif hist == 2: + sigma = np.array(self.cov_fm, ndmin=2) + returns = np.array(self.returns_fm, ndmin=2) + nav = np.array(self.nav_fm, ndmin=2) + + alpha = self.alpha + a_sim = self.a_sim + + if self.beta is None: + beta = alpha + else: + beta = self.beta + + if self.b_sim is None: + b_sim = a_sim + else: + b_sim = self.b_sim + + limits = self.frontier_limits(model=model, rm=rm, kelly=kelly, rf=rf, hist=hist) + + w_min = np.array(limits.iloc[:, 0], ndmin=2).T + w_max = np.array(limits.iloc[:, 1], ndmin=2).T + + ret_min = (mu @ w_min).item() + ret_max = (mu @ w_max).item() + + if rm == "MV": + risk_min = np.sqrt(w_min.T @ sigma @ w_min).item() + risk_max = np.sqrt(w_max.T @ sigma @ w_max).item() + elif rm == "MAD": + risk_min = rk.MAD(returns @ w_min) + risk_max = rk.MAD(returns @ w_max) + elif rm == "MSV": + risk_min = rk.SemiDeviation(returns @ w_min) + risk_max = rk.SemiDeviation(returns @ w_max) + elif rm == "CVaR": + risk_min = rk.CVaR_Hist(returns @ w_min, alpha) + risk_max = rk.CVaR_Hist(returns @ w_max, alpha) + elif rm == "WR": + risk_min = rk.WR(returns @ w_min) + risk_max = rk.WR(returns @ w_max) + elif rm == "FLPM": + risk_min = rk.LPM(returns @ w_min, rf, 1) + risk_max = rk.LPM(returns @ w_max, rf, 1) + elif rm == "SLPM": + risk_min = rk.LPM(returns @ w_min, rf, 2) + risk_max = rk.LPM(returns @ w_max, rf, 2) + elif rm == "MDD": + risk_min = rk.MDD_Abs(returns @ w_min) + risk_max = rk.MDD_Abs(returns @ w_max) + elif rm == "ADD": + risk_min = rk.ADD_Abs(returns @ w_min) + risk_max = rk.ADD_Abs(returns @ w_max) + elif rm == "CDaR": + risk_min = rk.CDaR_Abs(returns @ w_min, alpha) + risk_max = rk.CDaR_Abs(returns @ w_max, alpha) + elif rm == "UCI": + risk_min = rk.UCI_Abs(returns @ w_min) + risk_max = rk.UCI_Abs(returns @ w_max) + elif rm == "EVaR": + risk_min = rk.EVaR_Hist(returns @ w_min, alpha)[0] + risk_max = rk.EVaR_Hist(returns @ w_max, alpha)[0] + elif rm == "EDaR": + risk_min = rk.EDaR_Abs(returns @ w_min, alpha)[0] + risk_max = rk.EDaR_Abs(returns @ w_max, alpha)[0] + elif rm == "GMD": + risk_min = rk.GMD(returns @ w_min) + risk_max = rk.GMD(returns @ w_max) + elif rm == "TG": + risk_min = rk.TG(returns @ w_min, alpha, a_sim) + risk_max = rk.TG(returns @ w_max, alpha, a_sim) + elif rm == "RG": + risk_min = rk.RG(returns @ w_min) + risk_max = rk.RG(returns @ w_max) + elif rm == "CVRG": + risk_min = rk.CVRG(returns @ w_min, alpha, beta) + risk_max = rk.CVRG(returns @ w_max, alpha, beta) + elif rm == "TGRG": + risk_min = rk.TGRG(returns @ w_min, alpha, a_sim, beta, b_sim) + risk_max = rk.TGRG(returns @ w_max, alpha, a_sim, beta, b_sim) + + mus = np.linspace(ret_min, ret_max, points) + + risks = np.linspace(risk_min, risk_max, points) + + risk_lims = [ + "upperdev", + "uppermad", + "uppergmd", + "uppersdev", + "upperCVaR", + "uppertg", + "upperEVaR", + "upperwr", + "upperrg", + "uppercvrg", + "uppertgrg", + "upperflpm", + "upperslpm", + "uppermdd", + "upperadd", + "upperCDaR", + "upperEDaR", + "upperuci", + ] + + risk_names = [ + "MV", + "MAD", + "GMD", + "MSV", + "CVaR", + "TG", + "EVaR", + "WR", + "RG", + "CVRG", + "TGRG", + "FLPM", + "SLPM", + "MDD", + "ADD", + "CDaR", + "EDaR", + "UCI", + ] + + item = risk_names.index(rm) + + frontier = [] + n = 0 + for i in range(len(risks)): + try: + if n == 0: + w = self.optimization( + model=model, + rm=rm, + obj="MinRisk", + kelly=kelly, + rf=rf, + l=0, + hist=hist, + ) + else: + setattr(self, risk_lims[item], risks[i]) + w = self.optimization( + model=model, + rm=rm, + obj="MaxRet", + kelly=kelly, + rf=rf, + l=0, + hist=hist, + ) + if w is not None: + n += 1 + frontier.append(w) + except: + pass + + setattr(self, risk_lims[item], None) + self.frontier = pd.concat(frontier, axis=1) + self.frontier.columns = list(range(n)) + + return self.frontier + + def reset_risk_constraints(self): + r""" + Reset all risk constraints. + + """ + cons = [ + "lowerret", + "upperdev", + "uppermad", + "uppersdev", + "upperCVaR", + "upperEVaR", + "upperwr", + "upperflpm", + "upperslpm", + "uppermdd", + "upperadd", + "upperCDaR", + "upperEDaR", + "upperuci", + "uppergmd", + "uppertg", + "upperrg", + "uppercvrg", + "uppertgrg", + ] + + for i in cons: + setattr(self, i, None) + + def reset_linear_constraints(self): + r""" + Reset all linear constraints. + + """ + + self.ainequality = None + self.binequality = None + + def reset_inputs(self): + r""" + Reset all inputs parameters of optimization models. + + """ + + cons = [ + "mu", + "cov", + "mu_fm", + "cov_fm", + "mu_bl", + "cov_bl", + "mu_bl_fm", + "cov_bl_fm", + "returns_fm", + "nav_fm", + "cov_l", + "cov_u", + "cov_mu", + "cov_sigma", + "d_mu", + "k_mu", + "k_sigma", + ] + + for i in cons: + setattr(self, i, None) + + def reset_all(self): + r""" + Reset portfolio object to defatult values. + + """ + + self.sht = False + self.uppersht = 0.2 + self.upperlng = 1 + self.budget = 1 + self.nea = None + self.card = None + self._factors = None + self.B = None + self.alpha = 0.05 + self.kindbench = True + self.benchindex = None + self._benchweights = None + self.allowTO = False + self.turnover = 0.05 + self.allowTE = False + self.TE = 0.05 + + self.reset_risk_constraints() + self.reset_linear_constraints() + self.reset_inputs() diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/RiskFunctions.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/RiskFunctions.py new file mode 100644 index 00000000000..6327b1bd0ef --- /dev/null +++ b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/RiskFunctions.py @@ -0,0 +1,1726 @@ +"""""" # +""" +Copyright (c) 2020-2022, Dany Cajas +All rights reserved. +This work is licensed under BSD 3-Clause "New" or "Revised" License. +License available at https://github.com/dcajasn/Riskfolio-Lib/blob/master/LICENSE.txt +""" + +import numpy as np +import openbb_terminal.portfolio.portfolio_optimization.riskfolio.OwaWeights as owa +from scipy.optimize import minimize +from scipy.optimize import Bounds + + +__all__ = [ + "MAD", + "SemiDeviation", + "VaR_Hist", + "CVaR_Hist", + "WR", + "LPM", + "Entropic_RM", + "EVaR_Hist", + "MDD_Abs", + "ADD_Abs", + "DaR_Abs", + "CDaR_Abs", + "EDaR_Abs", + "UCI_Abs", + "MDD_Rel", + "ADD_Rel", + "DaR_Rel", + "CDaR_Rel", + "EDaR_Rel", + "UCI_Rel", + "GMD", + "TG", + "RG", + "CVRG", + "TGRG", + "Sharpe_Risk", + "Sharpe", + "Risk_Contribution", +] + + +def MAD(X): + r""" + Calculate the Mean Absolute Deviation (MAD) of a returns series. + + .. math:: + \text{MAD}(X) = \frac{1}{T}\sum_{t=1}^{T} + | X_{t} - \mathbb{E}(X_{t}) | + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Returns + ------- + value : float + MAD of a returns series. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + value = np.mean(np.absolute(a - np.mean(a, axis=0)), axis=0) + value = np.array(value).item() + + return value + + +def SemiDeviation(X): + r""" + Calculate the Semi Deviation of a returns series. + + .. math:: + \text{SemiDev}(X) = \left [ \frac{1}{T-1}\sum_{t=1}^{T} + (X_{t} - \mathbb{E}(X_{t}))^2 \right ]^{1/2} + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Semi Deviation of a returns series. + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + mu = np.mean(a, axis=0) + value = mu - a + n = value.shape[0] + value = np.sum(np.power(value[np.where(value >= 0)], 2)) / (n - 1) + value = np.power(value, 0.5).item() + + return value + + +def VaR_Hist(X, alpha=0.05): + r""" + Calculate the Value at Risk (VaR) of a returns series. + + .. math:: + \text{VaR}_{\alpha}(X) = -\inf_{t \in (0,T)} \left \{ X_{t} \in + \mathbb{R}: F_{X}(X_{t})>\alpha \right \} + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + alpha : float, optional + Significance level of VaR. The default is 0.05. + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + VaR of a returns series. + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + sorted_a = np.sort(a, axis=0) + index = int(np.ceil(alpha * len(sorted_a)) - 1) + value = -sorted_a[index] + value = np.array(value).item() + + return value + + +def CVaR_Hist(X, alpha=0.05): + r""" + Calculate the Conditional Value at Risk (CVaR) of a returns series. + + .. math:: + \text{CVaR}_{\alpha}(X) = \text{VaR}_{\alpha}(X) + + \frac{1}{\alpha T} \sum_{t=1}^{T} \max(-X_{t} - + \text{VaR}_{\alpha}(X), 0) + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + alpha : float, optional + Significance level of CVaR. The default is 0.05. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + CVaR of a returns series. + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + sorted_a = np.sort(a, axis=0) + index = int(np.ceil(alpha * len(sorted_a)) - 1) + sum_var = 0 + for i in range(0, index + 1): + sum_var = sum_var + sorted_a[i] - sorted_a[index] + + value = -sorted_a[index] - sum_var / (alpha * len(sorted_a)) + value = np.array(value).item() + + return value + + +def WR(X): + r""" + Calculate the Worst Realization (WR) or Worst Scenario of a returns series. + + .. math:: + \text{WR}(X) = \max(-X) + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + WR of a returns series. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + sorted_a = np.sort(a, axis=0) + value = -sorted_a[0] + value = np.array(value).item() + + return value + + +def LPM(X, MAR=0, p=1): + r""" + Calculate the First or Second Lower Partial Moment of a returns series. + + .. math:: + \text{LPM}(X, \text{MAR}, 1) &= \frac{1}{T}\sum_{t=1}^{T} + \max(\text{MAR} - X_{t}, 0) \\ + \text{LPM}(X, \text{MAR}, 2) &= \left [ \frac{1}{T-1}\sum_{t=1}^{T} + \max(\text{MAR} - X_{t}, 0)^{2} \right ]^{\frac{1}{2}} \\ + + + Where: + + :math:`\text{MAR}` is the minimum acceptable return. + :math:`p` is the order of the :math:`\text{LPM}`. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + MAR : float, optional + Minimum acceptable return. The default is 0. + p : float, optional can be {1,2} + order of the :math:`\text{LPM}`. The default is 1. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + p-th Lower Partial Moment of a returns series. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + if p not in [1, 2]: + raise ValueError("p can only be 1 or 2") + + value = MAR - a + + if p == 2: + n = value.shape[0] - 1 + else: + n = value.shape[0] + + value = np.sum(np.power(value[np.where(value >= 0)], p)) / n + value = np.power(value, 1 / p).item() + + return value + + +def Entropic_RM(X, z=1, alpha=0.05): + r""" + Calculate the Entropic Risk Measure (ERM) of a returns series. + + .. math:: + \text{ERM}_{\alpha}(X) = z\ln \left (\frac{M_X(z^{-1})}{\alpha} \right ) + + Where: + + :math:`M_X(z)` is the moment generating function of X. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + theta : float, optional + Risk aversion parameter, must be greater than zero. The default is 1. + alpha : float, optional + Significance level of EVaR. The default is 0.05. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + ERM of a returns series. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + value = np.mean(np.exp(-1 / z * a), axis=0) + value = z * (np.log(value) + np.log(1 / alpha)) + value = np.array(value).item() + + return value + + +def _Entropic_RM(z, X, alpha=0.05): + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + value = np.mean(np.exp(-1 / z * a), axis=0) + value = z * (np.log(value) + np.log(1 / alpha)) + value = np.array(value).item() + + return value + + +def EVaR_Hist(X, alpha=0.05): + r""" + Calculate the Entropic Value at Risk (EVaR) of a returns series. + + .. math:: + \text{EVaR}_{\alpha}(X) = \inf_{z>0} \left \{ z + \ln \left (\frac{M_X(z^{-1})}{\alpha} \right ) \right \} + + Where: + + :math:`M_X(t)` is the moment generating function of X. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + alpha : float, optional + Significance level of EVaR. The default is 0.05. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + (value, z) : tuple + EVaR of a returns series and value of z that minimize EVaR. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + bnd = Bounds([1e-12], [np.inf]) + result = minimize( + _Entropic_RM, [1], args=(X, alpha), method="SLSQP", bounds=bnd, tol=1e-12 + ) + t = result.x + t = t.item() + value = _Entropic_RM(t, X, alpha) + return (value, t) + + +def MDD_Abs(X): + r""" + Calculate the Maximum Drawdown (MDD) of a returns series + using uncumpounded cumulative returns. + + .. math:: + \text{MDD}(X) = \max_{j \in (0,T)} \left [\max_{t \in (0,j)} + \left ( \sum_{i=0}^{t}X_{i} \right ) - \sum_{i=0}^{j}X_{i} \right ] + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + MDD of an uncumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + prices = np.insert(np.array(a), 0, 1, axis=0) + NAV = np.cumsum(np.array(prices), axis=0) + value = 0 + peak = -99999 + for i in NAV: + if i > peak: + peak = i + DD = peak - i + if DD > value: + value = DD + + value = np.array(value).item() + + return value + + +def ADD_Abs(X): + r""" + Calculate the Average Drawdown (ADD) of a returns series + using uncumpounded cumulative returns. + + .. math:: + \text{ADD}(X) = \frac{1}{T}\sum_{j=0}^{T}\left [ \max_{t \in (0,j)} + \left ( \sum_{i=0}^{t}X_{i} \right ) - \sum_{i=0}^{j}X_{i} \right ] + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + ADD of an uncumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + prices = np.insert(np.array(a), 0, 1, axis=0) + NAV = np.cumsum(np.array(prices), axis=0) + value = 0 + peak = -99999 + n = 0 + for i in NAV: + if i > peak: + peak = i + DD = peak - i + if DD > 0: + value += DD + n += 1 + if n == 0: + value = 0 + else: + value = value / (n - 1) + + value = np.array(value).item() + + return value + + +def DaR_Abs(X, alpha=0.05): + r""" + Calculate the Drawdown at Risk (DaR) of a returns series + using uncumpounded cumulative returns. + + .. math:: + \text{DaR}_{\alpha}(X) & = \max_{j \in (0,T)} \left \{ \text{DD}(X,j) + \in \mathbb{R}: F_{\text{DD}} \left ( \text{DD}(X,j) \right )< 1-\alpha + \right \} \\ + \text{DD}(X,j) & = \max_{t \in (0,j)} \left ( \sum_{i=0}^{t}X_{i} + \right )- \sum_{i=0}^{j}X_{i} + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size.. + alpha : float, optional + Significance level of DaR. The default is 0.05. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + DaR of an uncumpounded cumulative returns series. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + prices = np.insert(np.array(a), 0, 1, axis=0) + NAV = np.cumsum(np.array(prices), axis=0) + DD = [] + peak = -99999 + for i in NAV: + if i > peak: + peak = i + DD.append(-(peak - i)) + del DD[0] + sorted_DD = np.sort(np.array(DD), axis=0) + index = int(np.ceil(alpha * len(sorted_DD)) - 1) + value = -sorted_DD[index] + value = np.array(value).item() + + return value + + +def CDaR_Abs(X, alpha=0.05): + r""" + Calculate the Conditional Drawdown at Risk (CDaR) of a returns series + using uncumpounded cumulative returns. + + .. math:: + \text{CDaR}_{\alpha}(X) = \text{DaR}_{\alpha}(X) + \frac{1}{\alpha T} + \sum_{j=0}^{T} \max \left [ \max_{t \in (0,j)} + \left ( \sum_{i=0}^{t}X_{i} \right ) - \sum_{i=0}^{j}X_{i} + - \text{DaR}_{\alpha}(X), 0 \right ] + + Where: + + :math:`\text{DaR}_{\alpha}` is the Drawdown at Risk of an uncumpound + cumulated return series :math:`X`. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size.. + alpha : float, optional + Significance level of CDaR. The default is 0.05. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + CDaR of an uncumpounded cumulative returns series. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + prices = np.insert(np.array(a), 0, 1, axis=0) + NAV = np.cumsum(np.array(prices), axis=0) + DD = [] + peak = -99999 + for i in NAV: + if i > peak: + peak = i + DD.append(-(peak - i)) + del DD[0] + sorted_DD = np.sort(np.array(DD), axis=0) + index = int(np.ceil(alpha * len(sorted_DD)) - 1) + sum_var = 0 + for i in range(0, index + 1): + sum_var = sum_var + sorted_DD[i] - sorted_DD[index] + value = -sorted_DD[index] - sum_var / (alpha * len(sorted_DD)) + value = np.array(value).item() + + return value + + +def EDaR_Abs(X, alpha=0.05): + r""" + Calculate the Entropic Drawdown at Risk (EDaR) of a returns series + using uncumpounded cumulative returns. + + .. math:: + \text{EDaR}_{\alpha}(X) & = \inf_{z>0} \left \{ z + \ln \left (\frac{M_{\text{DD}(X)}(z^{-1})}{\alpha} \right ) \right \} \\ + \text{DD}(X,j) & = \max_{t \in (0,j)} \left ( \sum_{i=0}^{t}X_{i} + \right )- \sum_{i=0}^{j}X_{i} \\ + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size.. + alpha : float, optional + Significance level of EDaR. The default is 0.05. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + (value, z) : tuple + EDaR of an uncumpounded cumulative returns series + and value of z that minimize EDaR. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + prices = np.insert(np.array(a), 0, 1, axis=0) + NAV = np.cumsum(np.array(prices), axis=0) + DD = [] + peak = -99999 + for i in NAV: + if i > peak: + peak = i + DD.append(-(peak - i)) + del DD[0] + + (value, t) = EVaR_Hist(np.array(DD), alpha=alpha) + + return (value, t) + + +def UCI_Abs(X): + r""" + Calculate the Ulcer Index (UCI) of a returns series + using uncumpounded cumulative returns. + + .. math:: + \text{UCI}(X) =\sqrt{\frac{1}{T}\sum_{j=0}^{T} \left [ \max_{t \in + (0,j)} \left ( \sum_{i=0}^{t}X_{i} \right ) - \sum_{i=0}^{j}X_{i} + \right ] ^2} + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Ulcer Index of an uncumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + prices = np.insert(np.array(a), 0, 1, axis=0) + NAV = np.cumsum(np.array(prices), axis=0) + value = 0 + peak = -99999 + n = 0 + for i in NAV: + if i > peak: + peak = i + DD = peak - i + if DD > 0: + value += DD**2 + n += 1 + if n == 0: + value = 0 + else: + value = np.sqrt(value / (n - 1)) + + value = np.array(value).item() + + return value + + +def MDD_Rel(X): + r""" + Calculate the Maximum Drawdown (MDD) of a returns series + using cumpounded cumulative returns. + + .. math:: + \text{MDD}(X) = \max_{j \in (0,T)}\left[\max_{t \in (0,j)} + \left ( \prod_{i=0}^{t}(1+X_{i}) \right ) - \prod_{i=0}^{j}(1+X_{i}) + \right] + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + MDD of a cumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + prices = 1 + np.insert(np.array(a), 0, 0, axis=0) + NAV = np.cumprod(prices, axis=0) + value = 0 + peak = -99999 + for i in NAV: + if i > peak: + peak = i + DD = (peak - i) / peak + if DD > value: + value = DD + + value = np.array(value).item() + + return value + + +def ADD_Rel(X): + r""" + Calculate the Average Drawdown (ADD) of a returns series + using cumpounded cumulative returns. + + .. math:: + \text{ADD}(X) = \frac{1}{T}\sum_{j=0}^{T} \left [ \max_{t \in (0,j)} + \left ( \prod_{i=0}^{t}(1+X_{i}) \right )- \prod_{i=0}^{j}(1+X_{i}) + \right ] + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + ADD of a cumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + prices = 1 + np.insert(np.array(a), 0, 0, axis=0) + NAV = np.cumprod(prices, axis=0) + value = 0 + peak = -99999 + n = 0 + for i in NAV: + if i > peak: + peak = i + DD = (peak - i) / peak + if DD > 0: + value += DD + n += 1 + if n == 0: + value = 0 + else: + value = value / (n - 1) + + value = np.array(value).item() + + return value + + +def DaR_Rel(X, alpha=0.05): + r""" + Calculate the Drawdown at Risk (DaR) of a returns series + using cumpounded cumulative returns. + + .. math:: + \text{DaR}_{\alpha}(X) & = \max_{j \in (0,T)} \left \{ \text{DD}(X,j) + \in \mathbb{R}: F_{\text{DD}} \left ( \text{DD}(X,j) \right )< 1 - \alpha + \right \} \\ + \text{DD}(X,j) & = \max_{t \in (0,j)} \left ( \prod_{i=0}^{t}(1+X_{i}) + \right )- \prod_{i=0}^{j}(1+X_{i}) + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size.. + alpha : float, optional + Significance level of DaR. The default is 0.05. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + DaR of a cumpounded cumulative returns series. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("X must have Tx1 size") + + prices = 1 + np.insert(np.array(a), 0, 0, axis=0) + NAV = np.cumprod(prices, axis=0) + DD = [] + peak = -99999 + for i in NAV: + if i > peak: + peak = i + DD.append(-(peak - i) / peak) + del DD[0] + sorted_DD = np.sort(np.array(DD), axis=0) + index = int(np.ceil(alpha * len(sorted_DD)) - 1) + value = -sorted_DD[index] + value = np.array(value).item() + + return value + + +def CDaR_Rel(X, alpha=0.05): + r""" + Calculate the Conditional Drawdown at Risk (CDaR) of a returns series + using cumpounded cumulative returns. + + .. math:: + \text{CDaR}_{\alpha}(X) = \text{DaR}_{\alpha}(X) + \frac{1}{\alpha T} + \sum_{i=0}^{T} \max \left [ \max_{t \in (0,T)} + \left ( \prod_{i=0}^{t}(1+X_{i}) \right )- \prod_{i=0}^{j}(1+X_{i}) + - \text{DaR}_{\alpha}(X), 0 \right ] + + Where: + + :math:`\text{DaR}_{\alpha}` is the Drawdown at Risk of a cumpound + cumulated return series :math:`X`. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size.. + alpha : float, optional + Significance level of CDaR. The default is 0.05. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + CDaR of a cumpounded cumulative returns series. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("X must have Tx1 size") + + prices = 1 + np.insert(np.array(a), 0, 0, axis=0) + NAV = np.cumprod(prices, axis=0) + DD = [] + peak = -99999 + for i in NAV: + if i > peak: + peak = i + DD.append(-(peak - i) / peak) + del DD[0] + sorted_DD = np.sort(np.array(DD), axis=0) + index = int(np.ceil(alpha * len(sorted_DD)) - 1) + sum_var = 0 + for i in range(0, index + 1): + sum_var = sum_var + sorted_DD[i] - sorted_DD[index] + value = -sorted_DD[index] - sum_var / (alpha * len(sorted_DD)) + value = np.array(value).item() + + return value + + +def EDaR_Rel(X, alpha=0.05): + r""" + Calculate the Entropic Drawdown at Risk (EDaR) of a returns series + using cumpounded cumulative returns. + + .. math:: + \text{EDaR}_{\alpha}(X) & = \inf_{z>0} \left \{ z + \ln \left (\frac{M_{\text{DD}(X)}(z^{-1})}{\alpha} \right ) \right \} \\ + \text{DD}(X,j) & = \max_{t \in (0,j)} \left ( \prod_{i=0}^{t}(1+X_{i}) + \right )- \prod_{i=0}^{j}(1+X_{i}) + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size.. + alpha : float, optional + Significance level of EDaR. The default is 0.05. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + (value, z) : tuple + EDaR of a cumpounded cumulative returns series + and value of z that minimize EDaR. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("X must have Tx1 size") + + prices = 1 + np.insert(np.array(a), 0, 0, axis=0) + NAV = np.cumprod(prices, axis=0) + DD = [] + peak = -99999 + for i in NAV: + if i > peak: + peak = i + DD.append(-(peak - i) / peak) + del DD[0] + + (value, t) = EVaR_Hist(np.array(DD), alpha=alpha) + + return (value, t) + + +def UCI_Rel(X): + r""" + Calculate the Ulcer Index (UCI) of a returns series + using cumpounded cumulative returns. + + .. math:: + \text{UCI}(X) =\sqrt{\frac{1}{T}\sum_{j=0}^{T} \left [ \max_{t \in + (0,j)} \left ( \prod_{i=0}^{t}(1+X_{i}) \right )- \prod_{i=0}^{j} + (1+X_{i}) \right ] ^2} + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Ulcer Index of a cumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + prices = 1 + np.insert(np.array(a), 0, 0, axis=0) + NAV = np.cumprod(prices, axis=0) + value = 0 + peak = -99999 + n = 0 + for i in NAV: + if i > peak: + peak = i + DD = (peak - i) / peak + if DD > 0: + value += DD**2 + n += 1 + if n == 0: + value = 0 + else: + value = np.sqrt(value / (n - 1)) + + value = np.array(value).item() + + return value + + +def GMD(X): + r""" + Calculate the Gini Mean Difference (GMD) of a returns series. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Ulcer Index of a cumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + T = a.shape[0] + w_ = owa.owa_gmd(T) + value = (w_.T @ np.sort(a, axis=0)).item() + + return value + + +def TG(X, alpha=0.05, a_sim=100): + r""" + Calculate the Tail Gini of a returns series. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + alpha : float, optional + Significance level of Tail Gini. The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini. The default is 100. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Ulcer Index of a cumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + T = a.shape[0] + w_ = owa.owa_tg(T, alpha, a_sim) + value = (w_.T @ np.sort(a, axis=0)).item() + + return value + + +def RG(X): + r""" + Calculate the range of a returns series. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Ulcer Index of a cumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + T = a.shape[0] + w_ = owa.owa_rg(T) + value = (w_.T @ np.sort(a, axis=0)).item() + + return value + + +def CVRG(X, alpha=0.05, beta=None): + r""" + Calculate the CVaR range of a returns series. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + alpha : float, optional + Significance level of CVaR of losses. The default is 0.05. + beta : float, optional + Significance level of CVaR of gains. If None it duplicates alpha value. + The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Ulcer Index of a cumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + T = a.shape[0] + w_ = owa.owa_cvrg(T, alpha=alpha, beta=beta) + value = (w_.T @ np.sort(a, axis=0)).item() + + return value + + +def TGRG(X, alpha=0.05, a_sim=100, beta=None, b_sim=None): + r""" + Calculate the Tail Gini range of a returns series. + + Parameters + ---------- + X : 1d-array + Returns series, must have Tx1 size. + alpha : float, optional + Significance level of Tail Gini of losses. The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Ulcer Index of a cumpounded cumulative returns. + + """ + + a = np.array(X, ndmin=2) + if a.shape[0] == 1 and a.shape[1] > 1: + a = a.T + if a.shape[0] > 1 and a.shape[1] > 1: + raise ValueError("returns must have Tx1 size") + + T = a.shape[0] + w_ = owa.owa_tgrg(T, alpha=alpha, a_sim=a_sim, beta=beta, b_sim=b_sim) + value = (w_.T @ np.sort(a, axis=0)).item() + + return value + + +############################################################################### +# Risk Adjusted Return Ratios +############################################################################### + + +def Sharpe_Risk( + w, + cov=None, + returns=None, + rm="MV", + rf=0, + alpha=0.05, + a_sim=100, + beta=None, + b_sim=None, +): + r""" + Calculate the risk measure available on the Sharpe function. + + Parameters + ---------- + w : DataFrame or 1d-array of shape (n_assets, 1) + Weights matrix, where n_assets is the number of assets. + cov : DataFrame or nd-array of shape (n_features, n_features) + Covariance matrix, where n_features is the number of features. + returns : DataFrame or nd-array of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + rm : str, optional + Risk measure used in the denominator of the ratio. The default is + 'MV'. Posible values are: + + - 'MV': Standard Deviation. + - 'MAD': Mean Absolute Deviation. + - 'GMD': Gini Mean Difference. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'VaR': Value at Risk. + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'WR': Worst Realization (Minimax). + - 'RG': Range of returns. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'MDD': Maximum Drawdown of uncompounded cumulative returns (Calmar Ratio). + - 'ADD': Average Drawdown of uncompounded cumulative returns. + - 'DaR': Drawdown at Risk of uncompounded cumulative returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'EDaR': Entropic Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + - 'MDD_Rel': Maximum Drawdown of compounded cumulative returns (Calmar Ratio). + - 'ADD_Rel': Average Drawdown of compounded cumulative returns. + - 'CDaR_Rel': Conditional Drawdown at Risk of compounded cumulative returns. + - 'EDaR_Rel': Entropic Drawdown at Risk of compounded cumulative returns. + - 'UCI_Rel': Ulcer Index of compounded cumulative returns. + + rf : float, optional + Risk free rate. The default is 0. + alpha : float, optional + Significance level of VaR, CVaR, EDaR, DaR, CDaR, EDaR, Tail Gini of losses. + The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of CVaR and Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Risk measure of the portfolio. + + """ + + w_ = np.array(w, ndmin=2) + if w_.shape[0] == 1 and w_.shape[1] > 1: + w_ = w_.T + if w_.shape[0] > 1 and w_.shape[1] > 1: + raise ValueError("weights must have n_assets x 1 size") + + if cov is not None: + cov_ = np.array(cov, ndmin=2) + if returns is not None: + returns_ = np.array(returns, ndmin=2) + + a = returns_ @ w_ + if rm == "MV": + risk = w_.T @ cov_ @ w_ + risk = np.sqrt(risk.item()) + elif rm == "MAD": + risk = MAD(a) + elif rm == "GMD": + risk = GMD(a) + elif rm == "MSV": + risk = SemiDeviation(a) + elif rm == "FLPM": + risk = LPM(a, MAR=rf, p=1) + elif rm == "SLPM": + risk = LPM(a, MAR=rf, p=2) + elif rm == "VaR": + risk = VaR_Hist(a, alpha=alpha) + elif rm == "CVaR": + risk = CVaR_Hist(a, alpha=alpha) + elif rm == "TG": + risk = TG(a, alpha=alpha, a_sim=a_sim) + elif rm == "EVaR": + risk = EVaR_Hist(a, alpha=alpha)[0] + elif rm == "WR": + risk = WR(a) + elif rm == "RG": + risk = RG(a) + elif rm == "CVRG": + risk = CVRG(a, alpha=alpha, beta=beta) + elif rm == "TGRG": + risk = TGRG(a, alpha=alpha, a_sim=a_sim, beta=beta, b_sim=b_sim) + elif rm == "MDD": + risk = MDD_Abs(a) + elif rm == "ADD": + risk = ADD_Abs(a) + elif rm == "DaR": + risk = DaR_Abs(a, alpha=alpha) + elif rm == "CDaR": + risk = CDaR_Abs(a, alpha=alpha) + elif rm == "EDaR": + risk = EDaR_Abs(a, alpha=alpha)[0] + elif rm == "UCI": + risk = UCI_Abs(a) + elif rm == "MDD_Rel": + risk = MDD_Rel(a) + elif rm == "ADD_Rel": + risk = ADD_Rel(a) + elif rm == "DaR_Rel": + risk = DaR_Rel(a, alpha=alpha) + elif rm == "CDaR_Rel": + risk = CDaR_Rel(a, alpha=alpha) + elif rm == "EDaR_Rel": + risk = EDaR_Rel(a, alpha=alpha)[0] + elif rm == "UCI_Rel": + risk = UCI_Rel(a) + + value = risk + + return value + + +def Sharpe( + w, + mu, + cov=None, + returns=None, + rm="MV", + rf=0, + alpha=0.05, + a_sim=100, + beta=None, + b_sim=None, +): + r""" + Calculate the Risk Adjusted Return Ratio from a portfolio returns series. + + .. math:: + \text{Sharpe}(X) = \frac{\mathbb{E}(X) - + r_{f}}{\phi(X)} + + Where: + + :math:`X` is the vector of portfolio returns. + + :math:`r_{f}` is the risk free rate, when the risk measure is + + :math:`\text{LPM}` uses instead of :math:`r_{f}` the :math:`\text{MAR}`. + + :math:`\phi(X)` is a convex risk measure. The risk measures availabe are: + + Parameters + ---------- + + w : DataFrame or 1d-array of shape (n_assets, 1) + Weights matrix, where n_assets is the number of assets. + mu : DataFrame or nd-array of shape (1, n_assets) + Vector of expected returns, where n_assets is the number of assets. + cov : DataFrame or nd-array of shape (n_features, n_features) + Covariance matrix, where n_features is the number of features. + returns : DataFrame or nd-array of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + rm : str, optional + Risk measure used in the denominator of the ratio. The default is + 'MV'. Posible values are: + + - 'MV': Standard Deviation. + - 'MAD': Mean Absolute Deviation. + - 'GMD': Gini Mean Difference. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'VaR': Value at Risk. + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'WR': Worst Realization (Minimax). + - 'RG': Range of returns. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'MDD': Maximum Drawdown of uncompounded cumulative returns (Calmar Ratio). + - 'ADD': Average Drawdown of uncompounded cumulative returns. + - 'DaR': Drawdown at Risk of uncompounded cumulative returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'EDaR': Entropic Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + - 'MDD_Rel': Maximum Drawdown of compounded cumulative returns (Calmar Ratio). + - 'ADD_Rel': Average Drawdown of compounded cumulative returns. + - 'CDaR_Rel': Conditional Drawdown at Risk of compounded cumulative returns. + - 'EDaR_Rel': Entropic Drawdown at Risk of compounded cumulative returns. + - 'UCI_Rel': Ulcer Index of compounded cumulative returns. + + rf : float, optional + Risk free rate. The default is 0. + alpha : float, optional + Significance level of VaR, CVaR, EDaR, DaR, CDaR, EDaR, Tail Gini of losses. + The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of CVaR and Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Risk adjusted return ratio of :math:`X`. + + """ + + w_ = np.array(w, ndmin=2) + if w_.shape[0] == 1 and w_.shape[1] > 1: + w_ = w_.T + if w_.shape[0] > 1 and w_.shape[1] > 1: + raise ValueError("weights must have n_assets x 1 size") + + if cov is None and rm == "MV": + raise ValueError("covariance matrix is necessary to calculate the sharpe ratio") + elif returns is None and rm != "MV": + raise ValueError( + "returns scenarios are necessary to calculate the sharpe ratio" + ) + + mu_ = np.array(mu, ndmin=2) + + if cov is not None: + cov_ = np.array(cov, ndmin=2) + if returns is not None: + returns_ = np.array(returns, ndmin=2) + + ret = mu_ @ w_ + ret = ret.item() + + risk = Sharpe_Risk( + w, + cov=cov_, + returns=returns_, + rm=rm, + rf=rf, + alpha=alpha, + a_sim=a_sim, + beta=beta, + b_sim=b_sim, + ) + + value = (ret - rf) / risk + + return value + + +############################################################################### +# Risk Contribution Vectors +############################################################################### + + +def Risk_Contribution( + w, + cov=None, + returns=None, + rm="MV", + rf=0, + alpha=0.05, + a_sim=100, + beta=None, + b_sim=None, +): + r""" + Calculate the risk contribution for each asset based on the risk measure + selected. + + Parameters + ---------- + w : DataFrame or 1d-array of shape (n_assets, 1) + Weights matrix, where n_assets is the number of assets. + cov : DataFrame or nd-array of shape (n_features, n_features) + Covariance matrix, where n_features is the number of features. + returns : DataFrame or nd-array of shape (n_samples, n_features) + Features matrix, where n_samples is the number of samples and + n_features is the number of features. + rm : str, optional + Risk measure used in the denominator of the ratio. The default is + 'MV'. Posible values are: + + - 'MV': Standard Deviation. + - 'MAD': Mean Absolute Deviation. + - 'GMD': Gini Mean Difference. + - 'MSV': Semi Standard Deviation. + - 'FLPM': First Lower Partial Moment (Omega Ratio). + - 'SLPM': Second Lower Partial Moment (Sortino Ratio). + - 'VaR': Value at Risk. + - 'CVaR': Conditional Value at Risk. + - 'TG': Tail Gini. + - 'EVaR': Entropic Value at Risk. + - 'WR': Worst Realization (Minimax). + - 'RG': Range of returns. + - 'CVRG': CVaR range of returns. + - 'TGRG': Tail Gini range of returns. + - 'MDD': Maximum Drawdown of uncompounded cumulative returns (Calmar Ratio). + - 'ADD': Average Drawdown of uncompounded cumulative returns. + - 'DaR': Drawdown at Risk of uncompounded cumulative returns. + - 'CDaR': Conditional Drawdown at Risk of uncompounded cumulative returns. + - 'EDaR': Entropic Drawdown at Risk of uncompounded cumulative returns. + - 'UCI': Ulcer Index of uncompounded cumulative returns. + - 'MDD_Rel': Maximum Drawdown of compounded cumulative returns (Calmar Ratio). + - 'ADD_Rel': Average Drawdown of compounded cumulative returns. + - 'CDaR_Rel': Conditional Drawdown at Risk of compounded cumulative returns. + - 'EDaR_Rel': Entropic Drawdown at Risk of compounded cumulative returns. + - 'UCI_Rel': Ulcer Index of compounded cumulative returns. + + rf : float, optional + Risk free rate. The default is 0. + alpha : float, optional + Significance level of VaR, CVaR, EDaR, DaR, CDaR, EDaR, Tail Gini of losses. + The default is 0.05. + a_sim : float, optional + Number of CVaRs used to approximate Tail Gini of losses. The default is 100. + beta : float, optional + Significance level of CVaR and Tail Gini of gains. If None it duplicates alpha value. + The default is None. + b_sim : float, optional + Number of CVaRs used to approximate Tail Gini of gains. If None it duplicates a_sim value. + The default is None. + + Raises + ------ + ValueError + When the value cannot be calculated. + + Returns + ------- + value : float + Risk measure of the portfolio. + + """ + + w_ = np.array(w, ndmin=2) + if w_.shape[0] == 1 and w_.shape[1] > 1: + w_ = w_.T + if w_.shape[0] > 1 and w_.shape[1] > 1: + raise ValueError("weights must have n_assets x 1 size") + + if cov is not None: + cov_ = np.array(cov, ndmin=2) + if returns is not None: + returns_ = np.array(returns, ndmin=2) + + RC = [] + d_i = 0.0000001 + + for i in range(0, w_.shape[0]): + delta = np.zeros((w_.shape[0], 1)) + delta[i, 0] = d_i + w_1 = w_ + delta + w_2 = w_ - delta + a_1 = returns_ @ w_1 + a_2 = returns_ @ w_2 + if rm == "MV": + risk_1 = w_1.T @ cov_ @ w_1 + risk_1 = np.sqrt(risk_1.item()) + risk_2 = w_2.T @ cov_ @ w_2 + risk_2 = np.sqrt(risk_2.item()) + elif rm == "MAD": + risk_1 = MAD(a_1) + risk_2 = MAD(a_2) + elif rm == "GMD": + risk_1 = GMD(a_1) + risk_2 = GMD(a_2) + elif rm == "MSV": + risk_1 = SemiDeviation(a_1) + risk_2 = SemiDeviation(a_2) + elif rm == "FLPM": + risk_1 = LPM(a_1, MAR=rf, p=1) + risk_2 = LPM(a_2, MAR=rf, p=1) + elif rm == "SLPM": + risk_1 = LPM(a_1, MAR=rf, p=2) + risk_2 = LPM(a_2, MAR=rf, p=2) + elif rm == "VaR": + risk_1 = VaR_Hist(a_1, alpha=alpha) + risk_2 = VaR_Hist(a_2, alpha=alpha) + elif rm == "CVaR": + risk_1 = CVaR_Hist(a_1, alpha=alpha) + risk_2 = CVaR_Hist(a_2, alpha=alpha) + elif rm == "TG": + risk_1 = TG(a_1, alpha=alpha, a_sim=a_sim) + risk_2 = TG(a_2, alpha=alpha, a_sim=a_sim) + elif rm == "EVaR": + risk_1 = EVaR_Hist(a_1, alpha=alpha)[0] + risk_2 = EVaR_Hist(a_2, alpha=alpha)[0] + elif rm == "WR": + risk_1 = WR(a_1) + risk_2 = WR(a_2) + elif rm == "RG": + risk_1 = RG(a_1) + risk_2 = RG(a_2) + elif rm == "CVRG": + risk_1 = CVRG(a_1, alpha=alpha, beta=beta) + risk_2 = CVRG(a_2, alpha=alpha, beta=beta) + elif rm == "TGRG": + risk_1 = TGRG(a_1, alpha=alpha, a_sim=a_sim, beta=beta, b_sim=b_sim) + risk_2 = TGRG(a_2, alpha=alpha, a_sim=a_sim, beta=beta, b_sim=b_sim) + elif rm == "MDD": + risk_1 = MDD_Abs(a_1) + risk_2 = MDD_Abs(a_2) + elif rm == "ADD": + risk_1 = ADD_Abs(a_1) + risk_2 = ADD_Abs(a_2) + elif rm == "DaR": + risk_1 = DaR_Abs(a_1, alpha=alpha) + risk_2 = DaR_Abs(a_2, alpha=alpha) + elif rm == "CDaR": + risk_1 = CDaR_Abs(a_1, alpha=alpha) + risk_2 = CDaR_Abs(a_2, alpha=alpha) + elif rm == "EDaR": + risk_1 = EDaR_Abs(a_1, alpha=alpha)[0] + risk_2 = EDaR_Abs(a_2, alpha=alpha)[0] + elif rm == "UCI": + risk_1 = UCI_Abs(a_1) + risk_2 = UCI_Abs(a_2) + elif rm == "MDD_Rel": + risk_1 = MDD_Rel(a_1) + risk_2 = MDD_Rel(a_2) + elif rm == "ADD_Rel": + risk_1 = ADD_Rel(a_1) + risk_2 = ADD_Rel(a_2) + elif rm == "DaR_Rel": + risk_1 = DaR_Rel(a_1, alpha=alpha) + risk_2 = DaR_Rel(a_2, alpha=alpha) + elif rm == "CDaR_Rel": + risk_1 = CDaR_Rel(a_1, alpha=alpha) + risk_2 = CDaR_Rel(a_2, alpha=alpha) + elif rm == "EDaR_Rel": + risk_1 = EDaR_Rel(a_1, alpha=alpha)[0] + risk_2 = EDaR_Rel(a_2, alpha=alpha)[0] + elif rm == "UCI_Rel": + risk_1 = UCI_Rel(a_1) + risk_2 = UCI_Rel(a_2) + + RC_i = (risk_1 - risk_2) / (2 * d_i) * w_[i, 0] + RC.append(RC_i) + + RC = np.array(RC, ndmin=1) + + return RC diff --git a/openbb_terminal/portfolio/portfolio_optimization/riskfolio/__init__.py b/openbb_terminal/portfolio/portfolio_optimization/riskfolio/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/requirements-full.txt b/requirements-full.txt index 3793c7e3d97..eb73ff957da 100644 --- a/requirements-full.txt +++ b/requirements-full.txt @@ -238,7 +238,6 @@ reportlab==3.6.9; python_version >= "3.7" and python_version < "4" requests-oauthlib==1.3.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" requests==2.27.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0") rich==10.16.2; python_full_version >= "3.6.2" and python_full_version < "4.0.0" -riskfolio-lib==3.0.2; python_full_version >= "3.7" robin-stocks==2.1.0; python_version >= "3" rsa==4.8; python_version >= "3.6" and python_version < "4" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") scikit-learn==1.0.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7" diff --git a/requirements.txt b/requirements.txt index 5c099864ff2..e6e8c5ae318 100644 --- a/requirements.txt +++ b/requirements.txt @@ -190,7 +190,6 @@ reportlab==3.6.9; python_version >= "3.7" and python_version < "4" requests-oauthlib==1.3.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" requests==2.27.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0") rich==10.16.2; python_full_version >= "3.6.2" and python_full_version < "4.0.0" -riskfolio-lib==3.0.2; python_full_version >= "3.7" robin-stocks==2.1.0; python_version >= "3" scikit-learn==1.0.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7" scipy==1.8.0; python_version >= "3.8" and python_version < "3.11" and python_full_version >= "3.7.1" and python_full_version < "4.0.0" and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7")