class MeanVarianceOptimisation: # pylint: disable=too-many-instance-attributes """ This class implements some classic mean-variance optimisation techniques for calculating the efficient frontier solutions. With the help of quadratic optimisers, users can generate optimal portfolios for different objective functions. Currently solutions to the following portfolios can be generated: 1. Inverse Variance 2. Maximum Sharpe 3. Minimum Volatility 4. Efficient Risk 5. Maximum Return - Minimum Volatility 6. Efficient Return 7. Maximum Diversification 8. Maximum Decorrelation 9. Custom Objective Function """ def __init__(self, calculate_expected_returns='mean', risk_free_rate=0.03): """ Constructor. :param calculate_expected_returns: (str) The method to use for calculation of expected returns. Currently supports: ``mean``, ``exponential``. """ self.weights = list() self.asset_names = None self.num_assets = None self.portfolio_risk = None self.portfolio_return = None self.portfolio_sharpe_ratio = None self.calculate_expected_returns = calculate_expected_returns self.returns_estimator = ReturnsEstimators() self.risk_estimators = RiskEstimators() self.weight_bounds = (0, 1) self.risk_free_rate = risk_free_rate def allocate(self, asset_names=None, asset_prices=None, expected_asset_returns=None, covariance_matrix=None, solution='inverse_variance', target_return=0.2, target_risk=0.01, risk_aversion=10, weight_bounds=None): # pylint: disable=invalid-name, too-many-branches """ Calculate the portfolio asset allocations using the method specified. :param asset_names: (list) A list of strings containing the asset names. :param asset_prices: (pd.DataFrame) A dataframe of historical asset prices (daily close). :param expected_asset_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). :param covariance_matrix: (pd.DataFrame/numpy matrix) User supplied covariance matrix of asset returns (sigma). :param solution: (str) The type of solution/algorithm to use to calculate the weights. Supported solution strings - ``inverse_variance``, ``min_volatility``, ``max_sharpe``, ``efficient_risk``, ``max_return_min_volatility``, ``max_diversification``, ``efficient_return`` and ``max_decorrelation``. :param target_return: (float) Target return of the portfolio. :param target_risk: (float) Target risk of the portfolio. :param risk_aversion: (float) Quantifies the risk averse nature of the investor - a higher value means more risk averse and vice-versa. :param weight_bounds: (dict/tuple) Can be either a single tuple of upper and lower bounds for all portfolio weights or a list of strings with each string representing an inequality on the weights. For e.g. to bound the weight of the 3rd asset pass the following weight bounds: ['weights[2] <= 0.3', 'weights[2] >= 0.1']. """ self._error_checks(asset_names, asset_prices, expected_asset_returns, covariance_matrix, solution) # Weight bounds if weight_bounds is not None: self.weight_bounds = weight_bounds # Calculate the expected asset returns and covariance matrix if not given by the user expected_asset_returns, covariance = self._calculate_estimators( asset_prices, expected_asset_returns, covariance_matrix) if solution == 'inverse_variance': self._inverse_variance(covariance=covariance, expected_returns=expected_asset_returns) elif solution == 'min_volatility': self._min_volatility(covariance=covariance, expected_returns=expected_asset_returns) elif solution == 'max_return_min_volatility': self._max_return_min_volatility( covariance=covariance, expected_returns=expected_asset_returns, risk_aversion=risk_aversion) elif solution == 'max_sharpe': self._max_sharpe(covariance=covariance, expected_returns=expected_asset_returns) elif solution == 'efficient_risk': self._min_volatility_for_target_return( covariance=covariance, expected_returns=expected_asset_returns, target_return=target_return) elif solution == 'efficient_return': self._max_return_for_target_risk( covariance=covariance, expected_returns=expected_asset_returns, target_risk=target_risk) elif solution == 'max_diversification': self._max_diversification(covariance=covariance, expected_returns=expected_asset_returns) else: self._max_decorrelation(covariance=covariance, expected_returns=expected_asset_returns) # Calculate the portfolio sharpe ratio self.portfolio_sharpe_ratio = ( (self.portfolio_return - self.risk_free_rate) / (self.portfolio_risk**0.5)) # Do some post-processing of the weights self._post_process_weights() def allocate_custom_objective(self, non_cvxpy_variables, cvxpy_variables, objective_function, constraints=None): # pylint: disable=eval-used, exec-used """ Create a portfolio using custom objective and constraints. :param non_cvxpy_variables: (dict) A dictionary of variables to be used for providing the required input matrices and other inputs required by the user. The key of dictionary will be the variable name while the value can be anything ranging from a numpy matrix, list, dataframe or number. :param cvxpy_variables: (list) This is a list of cvxpy specific variables that will be initialised in the format required by cvxpy. For e.g. ["risk = cp.quad_form(weights, covariance)"] where you are initialising a variable named "risk" using cvxpy. Note that cvxpy is being imported as "cp", so be sure to refer to cvxpy as cp. :param custom_objective: (str) A custom objective function. You need to write it in the form expected by cvxpy. The objective will be a single string, e.g. 'cp.Maximise( expected_asset_returns)'. :param constraints: (list) a list of strings containing the optimisation constraints. For e.g. ['weights >= 0', 'weights <= 1'] """ # Initialise the non-cvxpy variables locals_ptr = locals() for variable_name, variable_value in non_cvxpy_variables.items(): exec(variable_name + " = None") locals_ptr[variable_name] = variable_value self.num_assets = locals_ptr['num_assets'] self.asset_names = list(map(str, range(self.num_assets))) if 'asset_names' in locals_ptr: self.asset_names = locals_ptr['asset_names'] # Optimisation weights weights = cp.Variable(self.num_assets) weights.value = np.array([1 / self.num_assets] * self.num_assets) # Initialise cvxpy specific variables for variable in cvxpy_variables: exec(variable) # Optimisation objective and constraints allocation_objective = eval(objective_function) allocation_constraints = [] for constraint in constraints: allocation_constraints.append(eval(constraint)) # Define and solve the problem problem = cp.Problem(objective=allocation_objective, constraints=allocation_constraints) problem.solve(warm_start=True) if weights.value is None: raise ValueError('No optimal set of weights found.') self.weights = weights.value # Calculate portfolio metrics if 'risk' in locals_ptr: self.portfolio_risk = locals_ptr['risk'].value if 'portfolio_return' in locals_ptr: self.portfolio_return = locals_ptr['portfolio_return'].value # Do some post-processing of the weights self._post_process_weights() def get_portfolio_metrics(self): """ Prints the portfolio metrics - return, risk and Sharpe Ratio. """ print("Portfolio Return = %s" % self.portfolio_return) print("Portfolio Risk = %s" % self.portfolio_risk) print("Portfolio Sharpe Ratio = %s" % self.portfolio_sharpe_ratio) def plot_efficient_frontier(self, covariance, expected_asset_returns, min_return=0, max_return=0.4, risk_free_rate=0.05): # pylint: disable=broad-except """ Plot the Markowitz efficient frontier. :param covariance: (pd.DataFrame) Covariance dataframe of asset returns. :param expected_asset_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). :param min_return: (float) Minimum target return. :param max_return: (float) Maximum target return. :param risk_free_rate: (float) The rate of return for a risk-free asset. """ expected_returns = np.array(expected_asset_returns).reshape( (len(expected_asset_returns), 1)) volatilities = [] returns = [] sharpe_ratios = [] for portfolio_return in np.linspace(min_return, max_return, 100): try: self.allocate(covariance_matrix=covariance, expected_asset_returns=expected_returns, solution='efficient_risk', target_return=portfolio_return) volatilities.append(self.portfolio_risk**0.5) returns.append(portfolio_return) sharpe_ratios.append((portfolio_return - risk_free_rate) / (self.portfolio_risk**0.5 + 1e-16)) except Exception: continue max_sharpe_ratio_index = sharpe_ratios.index(max(sharpe_ratios)) min_volatility_index = volatilities.index(min(volatilities)) figure = plt.scatter(volatilities, returns, c=sharpe_ratios, cmap='viridis') plt.colorbar(label='Sharpe Ratio') plt.scatter(volatilities[max_sharpe_ratio_index], returns[max_sharpe_ratio_index], marker='*', color='g', s=400, label='Maximum Sharpe Ratio') plt.scatter(volatilities[min_volatility_index], returns[min_volatility_index], marker='*', color='r', s=400, label='Minimum Volatility') plt.xlabel('Volatility') plt.ylabel('Return') plt.legend(loc='upper left') return figure def _error_checks(self, asset_names, asset_prices, expected_asset_returns, covariance_matrix, solution=None): """ Some initial error checks on the inputs. :param asset_names: (list) A list of strings containing the asset names. :param asset_prices: (pd.DataFrame) A dataframe of historical asset prices (daily close). :param expected_asset_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). :param covariance_matrix: (pd.DataFrame/numpy matrix) User supplied covariance matrix of asset returns (sigma). :param solution: (str) The type of solution/algorithm to use to calculate the weights. Currently supported solution strings - inverse_variance, min_volatility, max_sharpe, efficient_risk, max_return_min_volatility, max_diversification, efficient_return and max_decorrelation. """ if asset_prices is None and (expected_asset_returns is None or covariance_matrix is None): raise ValueError( "You need to supply either raw prices or expected returns " "and a covariance matrix of asset returns") if asset_prices is not None: if not isinstance(asset_prices, pd.DataFrame): raise ValueError("Asset prices matrix must be a dataframe") if not isinstance(asset_prices.index, pd.DatetimeIndex): raise ValueError( "Asset prices dataframe must be indexed by date.") if solution is not None and solution not in { "inverse_variance", "min_volatility", "max_sharpe", "efficient_risk", "max_return_min_volatility", "max_diversification", "efficient_return", "max_decorrelation" }: raise ValueError( "Unknown solution string specified. Supported solutions - " "inverse_variance, min_volatility, max_sharpe, efficient_risk" "max_return_min_volatility, max_diversification, efficient_return and max_decorrelation" ) if asset_names is None: if asset_prices is not None: asset_names = asset_prices.columns elif covariance_matrix is not None and isinstance( covariance_matrix, pd.DataFrame): asset_names = covariance_matrix.columns else: raise ValueError("Please provide a list of asset names") self.asset_names = asset_names self.num_assets = len(asset_names) def _calculate_estimators(self, asset_prices, expected_asset_returns, covariance_matrix): """ Calculate the expected returns and covariance matrix of assets in the portfolio. :param asset_prices: (pd.DataFrame) A dataframe of historical asset prices (daily close). :param expected_asset_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). :param covariance_matrix: (pd.DataFrame/numpy matrix) User supplied covariance matrix of asset returns (sigma). :return: (np.array, pd.DataFrame) Expected asset returns and covariance matrix. """ # Calculate the expected returns if the user does not supply any returns if expected_asset_returns is None: if self.calculate_expected_returns == "mean": expected_asset_returns = self.returns_estimator.calculate_mean_historical_returns( asset_prices=asset_prices) elif self.calculate_expected_returns == "exponential": expected_asset_returns = self.returns_estimator.calculate_exponential_historical_returns( asset_prices=asset_prices) else: raise ValueError( "Unknown returns specified. Supported returns - mean, exponential" ) expected_asset_returns = np.array(expected_asset_returns).reshape( (len(expected_asset_returns), 1)) # Calculate covariance of returns or use the user specified covariance matrix if covariance_matrix is None: returns = self.returns_estimator.calculate_returns( asset_prices=asset_prices) covariance_matrix = returns.cov() cov = pd.DataFrame(covariance_matrix, index=self.asset_names, columns=self.asset_names) return expected_asset_returns, cov def _post_process_weights(self): """ Check weights for very small numbers and numbers close to 1. A final post-processing of weights produced by the optimisation procedures. """ # Round weights which are very very small negative numbers (e.g. -4.7e-16) to 0 self.weights[self.weights < 0] = 0 # If any of the weights is very close to one, we convert it to 1 and set the other asset weights to 0. if True in set(np.isclose(self.weights, 1)): almost_one_index = np.isclose(self.weights, 1) self.weights[almost_one_index] = 1 self.weights[np.logical_not(almost_one_index)] = 0 self.weights = pd.DataFrame(self.weights) self.weights.index = self.asset_names self.weights = self.weights.T def _inverse_variance(self, covariance, expected_returns): """ Calculate weights using inverse-variance allocation. :param covariance: (pd.DataFrame) Covariance dataframe of asset returns. :param expected_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). """ ivp = 1. / np.diag(covariance) ivp /= ivp.sum() self.weights = ivp self.portfolio_risk = np.dot(self.weights, np.dot(covariance.values, self.weights.T)) self.portfolio_return = np.dot(self.weights, expected_returns)[0] def _min_volatility(self, covariance, expected_returns): # pylint: disable=eval-used """ Compute minimum volatility portfolio allocation. :param covariance: (pd.DataFrame) Covariance dataframe of asset returns. :param expected_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). """ weights = cp.Variable(self.num_assets) weights.value = np.array([1 / self.num_assets] * self.num_assets) risk = cp.quad_form(weights, covariance) portfolio_return = cp.matmul(weights, expected_returns) # Optimisation objective and constraints allocation_objective = cp.Minimize(0.5 * risk) allocation_constraints = [ cp.sum(weights) == 1, ] if isinstance(self.weight_bounds, tuple): allocation_constraints.extend([ weights >= self.weight_bounds[0], weights <= min(self.weight_bounds[1], 1) ]) else: for inequality in self.weight_bounds: allocation_constraints.append(eval(inequality)) # Add the hard-boundaries for weights. allocation_constraints.extend([weights <= 1, weights >= 0]) # Define and solve the problem problem = cp.Problem(objective=allocation_objective, constraints=allocation_constraints) problem.solve(warm_start=True) if weights.value is None: raise ValueError('No optimal set of weights found.') self.weights = weights.value self.portfolio_risk = risk.value self.portfolio_return = portfolio_return.value[0] def _max_return_min_volatility(self, covariance, expected_returns, risk_aversion): # pylint: disable=eval-used """ Calculate maximum return-minimum volatility portfolio allocation. :param covariance: (pd.DataFrame) Covariance dataframe of asset returns. :param expected_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). :param risk_aversion: (float) Quantifies the risk-averse nature of the investor - a higher value means more risk averse and vice-versa. """ weights = cp.Variable(self.num_assets) weights.value = np.array([1 / self.num_assets] * self.num_assets) portfolio_return = cp.matmul(weights, expected_returns) risk = cp.quad_form(weights, covariance) # Optimisation objective and constraints allocation_objective = cp.Minimize(risk_aversion * risk - portfolio_return) allocation_constraints = [cp.sum(weights) == 1] if isinstance(self.weight_bounds, tuple): allocation_constraints.extend([ weights >= self.weight_bounds[0], weights <= min(self.weight_bounds[1], 1) ]) else: for inequality in self.weight_bounds: allocation_constraints.append(eval(inequality)) # Add the hard-boundaries for weights. allocation_constraints.extend([weights <= 1, weights >= 0]) # Define and solve the problem problem = cp.Problem(objective=allocation_objective, constraints=allocation_constraints) problem.solve(warm_start=True) if weights.value is None: raise ValueError('No optimal set of weights found.') self.weights = weights.value self.portfolio_risk = risk.value self.portfolio_return = portfolio_return.value[0] def _max_sharpe(self, covariance, expected_returns): # pylint: disable=invalid-name, eval-used """ Compute maximum Sharpe portfolio allocation. :param covariance: (pd.DataFrame) Covariance dataframe of asset returns. :param expected_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). """ y = cp.Variable(self.num_assets) y.value = np.array([1 / self.num_assets] * self.num_assets) kappa = cp.Variable(1) risk = cp.quad_form(y, covariance) weights = y / kappa portfolio_return = cp.matmul(weights, expected_returns) # Optimisation objective and constraints allocation_objective = cp.Minimize(risk) allocation_constraints = [ cp.sum((expected_returns - self.risk_free_rate).T @ y) == 1, cp.sum(y) == kappa, kappa >= 0 ] if isinstance(self.weight_bounds, tuple): allocation_constraints.extend([ y >= kappa * self.weight_bounds[0], y <= kappa * self.weight_bounds[1] ]) else: for inequality in self.weight_bounds: allocation_constraints.append(eval(inequality)) # Add the hard-boundaries for weights. allocation_constraints.extend([y <= kappa, y >= 0]) # Define and solve the problem problem = cp.Problem(objective=allocation_objective, constraints=allocation_constraints) problem.solve(warm_start=True) if y.value is None or kappa.value is None: raise ValueError('No optimal set of weights found.') self.weights = weights.value self.portfolio_risk = risk.value self.portfolio_return = portfolio_return.value[0] def _min_volatility_for_target_return(self, covariance, expected_returns, target_return): # pylint: disable=eval-used """ Calculate minimum volatility portfolio for a given target return. :param covariance: (pd.DataFrame) Covariance dataframe of asset returns. :param expected_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). :param target_return: (float) Target return of the portfolio. """ weights = cp.Variable(self.num_assets) weights.value = np.array([1 / self.num_assets] * self.num_assets) risk = cp.quad_form(weights, covariance) portfolio_return = cp.matmul(weights, expected_returns) # Optimisation objective and constraints allocation_objective = cp.Minimize(risk) allocation_constraints = [ cp.sum(weights) == 1, portfolio_return >= target_return, ] if isinstance(self.weight_bounds, tuple): allocation_constraints.extend([ weights >= self.weight_bounds[0], weights <= min(self.weight_bounds[1], 1) ]) else: for inequality in self.weight_bounds: allocation_constraints.append(eval(inequality)) # Add the hard-boundaries for weights. allocation_constraints.extend([weights <= 1, weights >= 0]) # Define and solve the problem problem = cp.Problem(objective=allocation_objective, constraints=allocation_constraints) problem.solve() if weights.value is None: raise ValueError('No optimal set of weights found.') self.weights = weights.value self.portfolio_risk = risk.value self.portfolio_return = target_return def _max_return_for_target_risk(self, covariance, expected_returns, target_risk): # pylint: disable=eval-used """ Calculate maximum return for a given target volatility/risk. :param covariance: (pd.DataFrame) Covariance dataframe of asset returns. :param expected_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). :param target_risk: (float) Target risk of the portfolio. """ weights = cp.Variable(self.num_assets) weights.value = np.array([1 / self.num_assets] * self.num_assets) portfolio_return = cp.matmul(weights, expected_returns) risk = cp.quad_form(weights, covariance) # Optimisation objective and constraints allocation_objective = cp.Maximize(portfolio_return) allocation_constraints = [cp.sum(weights) == 1, risk <= target_risk] if isinstance(self.weight_bounds, tuple): allocation_constraints.extend([ weights >= self.weight_bounds[0], weights <= min(self.weight_bounds[1], 1) ]) else: for inequality in self.weight_bounds: allocation_constraints.append(eval(inequality)) # Add the hard-boundaries for weights. allocation_constraints.extend([weights <= 1, weights >= 0]) # Define and solve the problem problem = cp.Problem(objective=allocation_objective, constraints=allocation_constraints) problem.solve() if weights.value is None: raise ValueError('No optimal set of weights found.') self.weights = weights.value self.portfolio_risk = target_risk self.portfolio_return = portfolio_return.value[0] def _max_diversification(self, covariance, expected_returns): """ Calculate the maximum diversified portfolio. :param covariance: (pd.DataFrame) Covariance dataframe of asset returns. :param expected_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). """ self._max_decorrelation(covariance, expected_returns) # Divide weights by individual asset volatilities self.weights /= np.diag(covariance) # Standardize weights self.weights /= np.sum(self.weights) portfolio_return = np.dot(expected_returns.T, self.weights)[0] risk = np.dot(self.weights, np.dot(covariance, self.weights.T)) self.portfolio_risk = risk self.portfolio_return = portfolio_return def _max_decorrelation(self, covariance, expected_returns): # pylint: disable=eval-used """ Calculate the maximum decorrelated portfolio. :param covariance: (pd.DataFrame) Covariance dataframe of asset returns. :param expected_returns: (list/np.array/pd.dataframe) A list of mean stock returns (mu). """ weights = cp.Variable(self.num_assets) weights.value = np.array([1 / self.num_assets] * self.num_assets) risk = cp.quad_form(weights, covariance) portfolio_return = cp.matmul(weights, expected_returns) corr = self.risk_estimators.cov_to_corr(covariance) portfolio_correlation = cp.quad_form(weights, corr) # Optimisation objective and constraints allocation_objective = cp.Minimize(portfolio_correlation) allocation_constraints = [cp.sum(weights) == 1] if isinstance(self.weight_bounds, tuple): allocation_constraints.extend([ weights >= self.weight_bounds[0], weights <= min(self.weight_bounds[1], 1) ]) else: for inequality in self.weight_bounds: allocation_constraints.append(eval(inequality)) # Add the hard-boundaries for weights. allocation_constraints.extend([weights <= 1, weights >= 0]) # Define and solve the problem problem = cp.Problem(objective=allocation_objective, constraints=allocation_constraints) problem.solve(warm_start=True) if weights.value is None: raise ValueError('No optimal set of weights found.') self.weights = weights.value self.portfolio_risk = risk.value self.portfolio_return = portfolio_return.value[0]
class CriticalLineAlgorithm: # pylint: disable=too-many-instance-attributes """ This class implements the famous Critical Line Algorithm (CLA) for mean-variance portfolio optimisation. It is reproduced with modification from the following paper: `D.H. Bailey and M.L. Prado “An Open-Source Implementation of the Critical- Line Algorithm for Portfolio Optimization”,Algorithms, 6 (2013), 169-196. <http://dx.doi.org/10.3390/a6010169>`_. The Critical Line Algorithm is a famous portfolio optimisation algorithm used for calculating the optimal allocation weights for a given portfolio. It solves the optimisation problem with optimisation constraints on each weight - lower and upper bounds on the weight value. This class can compute multiple types of solutions: 1. CLA Turning Points 2. Minimum Variance 3. Maximum Sharpe 4. Efficient Frontier Allocations """ def __init__(self, weight_bounds=(0, 1), calculate_expected_returns="mean"): """ Initialise the storage arrays and some preprocessing. :param weight_bounds: (tuple) A tuple specifying the lower and upper bound ranges for the portfolio weights. :param calculate_expected_returns: (str) The method to use for calculation of expected returns. Currently supports ``mean`` and ``exponential`` """ self.weight_bounds = weight_bounds self.calculate_expected_returns = calculate_expected_returns self.weights = list() self.lambdas = list() self.gammas = list() self.free_weights = list() self.max_sharpe = None self.min_var = None self.efficient_frontier_means = None self.efficient_frontier_sigma = None self.returns_estimator = ReturnsEstimators() self.risk_estimators = RiskEstimators() def allocate(self, asset_names=None, asset_prices=None, expected_asset_returns=None, covariance_matrix=None, solution="cla_turning_points", resample_by=None): # pylint: disable=consider-using-enumerate,too-many-locals,too-many-branches,too-many-statements,bad-continuation """ Calculate the portfolio asset allocations using the method specified. :param asset_names: (list) List of strings containing the asset names. :param asset_prices: (pd.Dataframe) Dataframe of historical asset prices (adj closed). :param expected_asset_returns: (list) List of mean asset returns (mu). :param covariance_matrix: (pd.Dataframe/numpy matrix) User supplied covariance matrix of asset returns. :param solution: (str) Specifies the type of solution to compute. Supported strings: ``cla_turning_points``, ``max_sharpe``, ``min_volatility``, ``efficient_frontier``. :param resample_by: (str) Specifies how to resample the prices - weekly, daily, monthly etc.. Defaults to None for no resampling. """ # Initial checks if asset_prices is None and (expected_asset_returns is None or covariance_matrix is None): raise ValueError( "You need to supply either raw prices or expected returns " "and a covariance matrix of asset returns") if asset_prices is not None: if not isinstance(asset_prices, pd.DataFrame): raise ValueError("Asset prices matrix must be a dataframe") if not isinstance(asset_prices.index, pd.DatetimeIndex): raise ValueError( "Asset prices dataframe must be indexed by date.") if asset_names is None: if asset_prices is not None: asset_names = asset_prices.columns else: raise ValueError("Please provide a list of asset names") # Some initial steps before the algorithm runs cov_matrix, expected_returns, lower_bounds, upper_bounds = self._initialise( asset_prices=asset_prices, resample_by=resample_by, expected_asset_returns=expected_asset_returns, covariance_matrix=covariance_matrix) # Compute the turning points, free sets and weights free_weights, weights = self._init_algo(expected_returns, lower_bounds, upper_bounds) self.weights.append(np.copy(weights)) # store solution self.lambdas.append(None) self.gammas.append(None) self.free_weights.append(free_weights[:]) while True: # 1) Bound one free weight lambda_in, i_in, bi_in = self._bound_free_weight( cov_matrix, expected_returns, lower_bounds, upper_bounds, free_weights) # 2) Free one bounded weight lambda_out, i_out = self._free_bound_weight( cov_matrix, expected_returns, free_weights) # 3) Compute minimum variance solution if (lambda_in is None or lambda_in < 0) and (lambda_out is None or lambda_out < 0): self.lambdas.append(0) covar_f, covar_fb, mean_f, w_b = self._get_matrices( cov_matrix, expected_returns, free_weights) covar_f_inv = np.linalg.inv(covar_f) mean_f = np.zeros(mean_f.shape) # 4) Decide whether to free a bounded weight or bound a free weight else: if self._infnone(lambda_in) > self._infnone(lambda_out): self.lambdas.append(lambda_in) free_weights.remove(i_in) weights[i_in] = bi_in # set value at the correct boundary else: self.lambdas.append(lambda_out) free_weights.append(i_out) covar_f, covar_fb, mean_f, w_b = self._get_matrices( cov_matrix, expected_returns, free_weights) covar_f_inv = np.linalg.inv(covar_f) # 5) Compute solution vector w_f, gamma = self._compute_w(covar_f_inv, covar_fb, mean_f, w_b) for i in range(len(free_weights)): weights[free_weights[i]] = w_f[i] self.weights.append(np.copy(weights)) # store solution self.gammas.append(gamma) self.free_weights.append(free_weights[:]) if self.lambdas[-1] == 0: break # 6) Purge turning points self._purge_num_err(lower_bounds, upper_bounds, 10e-10) self._purge_excess(expected_returns) # Compute the specified solution self._compute_solution(asset_names, solution, cov_matrix, expected_returns) def _initialise(self, asset_prices, expected_asset_returns, covariance_matrix, resample_by): # pylint: disable=invalid-name, too-many-branches, bad-continuation """ Initialise covariances, upper-bounds, lower-bounds and storage buffers. :param asset_prices: (pd.Dataframe) Dataframe of asset prices indexed by date. :param expected_asset_returns: (list) A list of mean stock returns (mu). :param covariance_matrix: (pd.Dataframe) User supplied dataframe of asset returns indexed by date. Used for calculation of covariance matrix. :param resample_by: (str) Specifies how to resample the prices - weekly, daily, monthly etc.. Defaults to 'B' meaning daily business days which is equivalent to no resampling. :return: (Numpy matrix, Numpy array, Numpy array, Numpy array) Data matrices and bounds arrays. """ # Calculate the returns if the user does not supply a returns matrix expected_returns = expected_asset_returns if expected_asset_returns is None: if self.calculate_expected_returns == "mean": expected_returns = self.returns_estimator.calculate_mean_historical_returns( asset_prices=asset_prices, resample_by=resample_by) elif self.calculate_expected_returns == "exponential": expected_returns = self.returns_estimator.calculate_exponential_historical_returns( asset_prices=asset_prices, resample_by=resample_by) else: raise ValueError( "Unknown returns specified. Supported returns - mean, exponential" ) expected_returns = np.array(expected_returns).reshape( (len(expected_returns), 1)) if (expected_returns == np.ones(expected_returns.shape) * expected_returns.mean()).all(): expected_returns[-1, 0] += 1e-5 # Calculate the covariance matrix if covariance_matrix is None: returns = self.returns_estimator.calculate_returns( asset_prices=asset_prices, resample_by=resample_by) covariance_matrix = returns.cov() cov_matrix = np.asarray(covariance_matrix) # Intialise lower bounds if isinstance(self.weight_bounds[0], numbers.Real): lower_bounds = np.ones( expected_returns.shape) * self.weight_bounds[0] else: lower_bounds = np.array( self.weight_bounds[0]).astype(float).reshape( expected_returns.shape) # Intialise upper bounds if isinstance(self.weight_bounds[1], numbers.Real): upper_bounds = np.ones( expected_returns.shape) * self.weight_bounds[1] else: upper_bounds = np.array( self.weight_bounds[1]).astype(float).reshape( expected_returns.shape) return cov_matrix, expected_returns, lower_bounds, upper_bounds @staticmethod def _infnone(number): """ Converts a Nonetype object to inf. :param number: (int/float/None) Number. :return: (float) -inf or number. """ return float("-inf") if number is None else number @staticmethod def _init_algo(expected_returns, lower_bounds, upper_bounds): """ Initial setting up of the algorithm. Calculates the first free weight of the first turning point. :param expected_asset_returns: (Numpy array) A list of mean stock returns (mu). :param lower_bounds: (Numpy array) An array containing lower bound values for the weights. :param upper_bounds: (Numpy array) An array containing upper bound values for the weights. :return: (list, list) asset index and the corresponding free weight value. """ # Form structured array structured_array = np.zeros((expected_returns.shape[0]), dtype=[("id", int), ("mu", float)]) expected_returns_ = [ expected_returns[i][0] for i in range(expected_returns.shape[0]) ] # dump array into list # Fill structured array structured_array[:] = list( zip(list(range(expected_returns.shape[0])), expected_returns_)) # Sort structured array based on increasing return value expected_returns = np.sort(structured_array, order="mu") # First free weight index, weights = expected_returns.shape[0], np.copy(lower_bounds) while np.sum(weights) < 1: index -= 1 # Set weights one by one to the upper bounds weights[expected_returns[index][0]] = upper_bounds[ expected_returns[index][0]] weights[expected_returns[index][0]] += 1 - np.sum(weights) return [expected_returns[index][0]], weights @staticmethod def _compute_bi(c_final, asset_bounds_i): """ Calculates which bound value to assign to a bounded asset - lower bound or upper bound. :param c_final: (float) A value calculated using the covariance matrices of free weights. Refer to https://pdfs.semanticscholar.org/4fb1/2c1129ba5389bafe47b03e595d098d0252b9.pdf for more information. :param asset_bounds_i: (list) A list containing the lower and upper bound values for the ith weight. :return: (float) Bounded weight value. """ if c_final > 0: return asset_bounds_i[1][0] return asset_bounds_i[0][0] def _compute_w(self, covar_f_inv, covar_fb, mean_f, w_b): """ Compute the turning point associated with the current set of free weights F. :param covar_f_inv: (np.array) Inverse of covariance matrix of free assets. :param covar_fb: (np.array) Covariance matrix between free assets and bounded assets. :param mean_f: (np.array) Expected returns of free assets. :param w_b: (np.array) Bounded asset weight values. :return: (array, float) List of turning point weights and gamma value from the lagrange equation. """ # Compute gamma ones_f = np.ones(mean_f.shape) g_1 = np.dot(np.dot(ones_f.T, covar_f_inv), mean_f) g_2 = np.dot(np.dot(ones_f.T, covar_f_inv), ones_f) if w_b is None: g_final, w_1 = float(-self.lambdas[-1] * g_1 / g_2 + 1 / g_2), 0 else: ones_b = np.ones(w_b.shape) g_3 = np.dot(ones_b.T, w_b) g_4 = np.dot(covar_f_inv, covar_fb) w_1 = np.dot(g_4, w_b) g_4 = np.dot(ones_f.T, w_1) g_final = float(-self.lambdas[-1] * g_1 / g_2 + (1 - g_3 + g_4) / g_2) # Compute weights w_2 = np.dot(covar_f_inv, ones_f) w_3 = np.dot(covar_f_inv, mean_f) free_asset_weights = -1 * w_1 + g_final * w_2 + self.lambdas[-1] * w_3 return free_asset_weights, g_final def _compute_lambda(self, covar_f_inv, covar_fb, mean_f, w_b, asset_index, b_i): """ Calculate the lambda value in the lagrange optimsation equation. :param covar_f_inv: (np.array) Inverse of covariance matrix of free assets. :param covar_fb: (np.array) Covariance matrix between free assets and bounded assets. :param mean_f: (np.array) Expected returns of free assets. :param w_b: (np.array) Bounded asset weight values. :param asset_index: (int) Index of the asset in the portfolio. :param b_i: (list) List of upper and lower bounded weight values. :return: (float) Lambda value. """ # Compute C ones_f = np.ones(mean_f.shape) c_1 = np.dot(np.dot(ones_f.T, covar_f_inv), ones_f) c_2 = np.dot(covar_f_inv, mean_f) c_3 = np.dot(np.dot(ones_f.T, covar_f_inv), mean_f) c_4 = np.dot(covar_f_inv, ones_f) c_final = -1 * c_1 * c_2[asset_index] + c_3 * c_4[asset_index] if c_final == 0: return None, None # Compute bi if isinstance(b_i, list): b_i = self._compute_bi(c_final, b_i) # Compute Lambda if w_b is None: # All free assets return float((c_4[asset_index] - c_1 * b_i) / c_final), b_i ones_b = np.ones(w_b.shape) l_1 = np.dot(ones_b.T, w_b) l_2 = np.dot(covar_f_inv, covar_fb) l_3 = np.dot(l_2, w_b) l_2 = np.dot(ones_f.T, l_3) lambda_value = float(((1 - l_1 + l_2) * c_4[asset_index] - c_1 * (b_i + l_3[asset_index])) / c_final) return lambda_value, b_i def _get_matrices(self, cov_matrix, expected_returns, free_weights): """ Calculate the required matrices between free and bounded assets. :param cov_matrix: (Numpy matrix) Covariance matrix of asset returns. :param expected_returns: (Numpy array) Array of mean asset returns (mu). :param free_weights: (list) List of free assets/weights. :return: (tuple of np.array matrices) The corresponding matrices. """ covar_f = self._reduce_matrix(cov_matrix, free_weights, free_weights) mean_f = self._reduce_matrix(expected_returns, free_weights, [0]) bounded_weights = self._get_bounded_weights(expected_returns, free_weights) covar_fb = self._reduce_matrix(cov_matrix, free_weights, bounded_weights) w_b = self._reduce_matrix(self.weights[-1], bounded_weights, [0]) return covar_f, covar_fb, mean_f, w_b def _get_bounded_weights(self, expected_returns, free_weights): """ Compute the list of bounded assets. :param expected_returns: (Numpy array) Array of mean asset returns (mu). :param free_weights: (np.array) List of free weights/assets. :return: (np.array) List of bounded assets/weights. """ return self._diff_lists(list(range(expected_returns.shape[0])), free_weights) @staticmethod def _diff_lists(list_1, list_2): """ Calculate the set difference between two lists. :param list_1: (list) A list of asset indices. :param list_2: (list) Another list of asset indices. :return: (list) Set difference between the two input lists. """ return list(set(list_1) - set(list_2)) @staticmethod def _reduce_matrix(matrix, row_indices, col_indices): """ Reduce a matrix to the provided set of rows and columns. :param matrix: (np.array) A matrix whose subset of rows and columns we need. :param row_indices: (list) List of row indices for the matrix. :param col_indices: (list) List of column indices for the matrix. :return: (np.array) Subset of input matrix. """ return matrix[np.ix_(row_indices, col_indices)] def _purge_num_err(self, lower_bounds, upper_bounds, tol): """ Purge violations of inequality constraints (associated with ill-conditioned cov matrix). :param lower_bounds: (Numpy array) An array containing lower bound values for the weights. :param upper_bounds: (Numpy array) An array containing upper bound values for the weights. :param tol: (float) Tolerance level for purging. """ index_1 = 0 while True: flag = False if index_1 == len(self.weights): break if abs(sum(self.weights[index_1]) - 1) > tol: flag = True else: for index_2 in range(len(self.weights[index_1])): if (self.weights[index_1][index_2] - lower_bounds[index_2] < -tol or self.weights[index_1][index_2] - upper_bounds[index_2] > tol): flag = True break if flag is True: del self.weights[index_1] del self.lambdas[index_1] del self.gammas[index_1] del self.free_weights[index_1] else: index_1 += 1 def _purge_excess(self, expected_returns): """ Remove violations of the convex hull. :param expected_returns: (Numpy array) Array of mean asset returns (mu). """ index_1, repeat = 0, False while True: if repeat is False: index_1 += 1 if index_1 >= len(self.weights) - 1: break weights = self.weights[index_1] mean = np.dot(weights.T, expected_returns)[0, 0] index_2, repeat = index_1 + 1, False while True: if index_2 == len(self.weights): break weights = self.weights[index_2] mean_ = np.dot(weights.T, expected_returns)[0, 0] if mean < mean_: del self.weights[index_1] del self.lambdas[index_1] del self.gammas[index_1] del self.free_weights[index_1] repeat = True break index_2 += 1 @staticmethod def _golden_section(obj, left, right, **kwargs): """ Golden section method. Maximum if kargs['minimum']==False is passed. :param obj: (function) The objective function on which the extreme will be found. :param left: (float) The leftmost extreme of search. :param right: (float) The rightmost extreme of search. """ tol, sign, args = 1.0e-9, -1, None args = kwargs.get("args", None) num_iterations = int(ceil(-2.078087 * log(tol / abs(right - left)))) gs_ratio = 0.618033989 complementary_gs_ratio = 1.0 - gs_ratio # Initialize x_1 = gs_ratio * left + complementary_gs_ratio * right x_2 = complementary_gs_ratio * left + gs_ratio * right f_1 = sign * obj(x_1, *args) f_2 = sign * obj(x_2, *args) # Loop for _ in range(num_iterations): if f_1 > f_2: left = x_1 x_1 = x_2 f_1 = f_2 x_2 = complementary_gs_ratio * left + gs_ratio * right f_2 = sign * obj(x_2, *args) else: right = x_2 x_2 = x_1 f_2 = f_1 x_1 = gs_ratio * left + complementary_gs_ratio * right f_1 = sign * obj(x_1, *args) if f_1 < f_2: return x_1, sign * f_1 return x_2, sign * f_2 @staticmethod def _eval_sr(alpha, cov_matrix, expected_returns, w_0, w_1): """ Evaluate the sharpe ratio of the portfolio within the convex combination. :param alpha: (float) Convex combination value. :param cov_matrix: (Numpy matrix) Covariance matrix of asset returns. :param expected_returns: (Numpy array) Array of mean asset returns (mu). :param w_0: (list) First endpoint of convex combination of weights. :param w_1: (list) Second endpoint of convex combination of weights. :return: """ weights = alpha * w_0 + (1 - alpha) * w_1 returns = np.dot(weights.T, expected_returns)[0, 0] volatility = np.dot(np.dot(weights.T, cov_matrix), weights)[0, 0]**0.5 return returns / volatility def _bound_free_weight(self, cov_matrix, expected_returns, lower_bounds, upper_bounds, free_weights): """ Add a free weight to list of bounded weights. :param cov_matrix: (Numpy matrix) Covariance matrix of asset returns. :param expected_returns: (Numpy array) Array of mean asset returns (mu). :param lower_bounds: (Numpy array) An array containing lower bound values for the weights. :param upper_bounds: (Numpy array) An array containing upper bound values for the weights. :param free_weights: (list) List of free-weight indices. :return: (float, int, int) Lambda value, index of free weight to be bounded, bound weight value. """ lambda_in = None i_in = None bi_in = None if len(free_weights) > 1: covar_f, covar_fb, mean_f, w_b = self._get_matrices( cov_matrix, expected_returns, free_weights) covar_f_inv = np.linalg.inv(covar_f) j = 0 for i in free_weights: lambda_i, b_i = self._compute_lambda( covar_f_inv, covar_fb, mean_f, w_b, j, [lower_bounds[i], upper_bounds[i]]) if self._infnone(lambda_i) > self._infnone(lambda_in): lambda_in, i_in, bi_in = lambda_i, i, b_i j += 1 return lambda_in, i_in, bi_in def _free_bound_weight(self, cov_matrix, expected_returns, free_weights): """ Add a bounded weight to list of free weights. :param cov_matrix: (Numpy matrix) Covariance matrix of asset returns. :param expected_returns: (Numpy array) Array of mean asset returns (mu). :param free_weights: (list) List of free-weight indices. :return: (float, int) Lambda value, index of the bounded weight to be made free. """ lambda_out = None i_out = None if len(free_weights) < expected_returns.shape[0]: bounded_weight_indices = self._get_bounded_weights( expected_returns, free_weights) for i in bounded_weight_indices: covar_f, covar_fb, mean_f, w_b = self._get_matrices( cov_matrix, expected_returns, free_weights + [i]) covar_f_inv = np.linalg.inv(covar_f) lambda_i, _ = self._compute_lambda( covar_f_inv, covar_fb, mean_f, w_b, mean_f.shape[0] - 1, self.weights[-1][i], ) if (self.lambdas[-1] is None or lambda_i < self.lambdas[-1] ) and lambda_i > self._infnone(lambda_out): lambda_out, i_out = lambda_i, i return lambda_out, i_out def _compute_solution(self, assets, solution, covariance_matrix, expected_returns): """ Compute the desired solution to the portfolio optimisation problem. :param assets: (list) A list of asset names. :param solution: (str) Specify the type of solution to compute. Options are: cla_turning_points, max_sharpe, min_volatility, efficient_frontier. :param covariance_matrix: (Numpy matrix) Covariance matrix of asset returns. :param expected_returns: (Numpy array) Array of mean asset returns (mu). """ if solution == "max_sharpe": self.max_sharpe, self.weights = self._max_sharpe( covariance_matrix, expected_returns) self.weights = pd.DataFrame(self.weights) self.weights.index = assets self.weights = self.weights.T elif solution == "min_volatility": self.min_var, self.weights = self._min_volatility( covariance_matrix) self.weights = pd.DataFrame(self.weights) self.weights.index = assets self.weights = self.weights.T elif solution == "efficient_frontier": self.efficient_frontier_means, self.efficient_frontier_sigma, self.weights = self._efficient_frontier( covariance_matrix, expected_returns) weights_copy = self.weights.copy() for i, turning_point in enumerate(weights_copy): self.weights[i] = turning_point.reshape(1, -1)[0] self.weights = pd.DataFrame(self.weights, columns=assets) elif solution == "cla_turning_points": # Reshape the weight matrix weights_copy = self.weights.copy() for i, turning_point in enumerate(weights_copy): self.weights[i] = turning_point.reshape(1, -1)[0] self.weights = pd.DataFrame(self.weights, columns=assets) else: raise ValueError( "Unknown solution string specified. Supported solutions - cla_turning_points, " "efficient_frontier, min_volatility, max_sharpe") def _max_sharpe(self, cov_matrix, expected_returns): """ Compute the maximum sharpe portfolio allocation. :param cov_matrix: (Numpy matrix) Covariance matrix of asset returns. :param expected_returns: (Numpy array) Array of mean asset returns (mu). :return: (float, np.array) Tuple of max. sharpe value and the set of weight allocations. """ # 1) Compute the local max SR portfolio between any two neighbor turning points w_sr, sharpe_ratios = [], [] for i in range(len(self.weights) - 1): w_0 = np.copy(self.weights[i]) w_1 = np.copy(self.weights[i + 1]) kwargs = { "minimum": False, "args": (cov_matrix, expected_returns, w_0, w_1) } alpha, sharpe_ratio = self._golden_section(self._eval_sr, 0, 1, **kwargs) w_sr.append(alpha * w_0 + (1 - alpha) * w_1) sharpe_ratios.append(sharpe_ratio) maximum_sharp_ratio = max(sharpe_ratios) weights_with_max_sharpe_ratio = w_sr[sharpe_ratios.index( maximum_sharp_ratio)] return maximum_sharp_ratio, weights_with_max_sharpe_ratio def _min_volatility(self, cov_matrix): """ Compute minimum volatility portfolio allocation. :param cov_matrix: (Numpy matrix) Covariance matrix of asset returns. :return: (float, np.array) Tuple of minimum variance value and the set of weight allocations. """ var = [] for weights in self.weights: volatility = np.dot(np.dot(weights.T, cov_matrix), weights) var.append(volatility) min_var = min(var) return min_var**.5, self.weights[var.index(min_var)] def _efficient_frontier(self, cov_matrix, expected_returns, points=100): # pylint: disable=invalid-name """ Compute the entire efficient frontier solution. :param cov_matrix: (Numpy matrix) Covariance matrix of asset returns. :param expected_returns: (Numpy array) Array of mean asset returns (mu). :param points: (int) Number of efficient frontier points to be calculated. :return: (tuple) Tuple of mean, variance amd weights of the frontier solutions. """ means, sigma, weights = [], [], [] # remove the 1, to avoid duplications partitions = np.linspace(0, 1, points // len(self.weights))[:-1] b = list(range(len(self.weights) - 1)) for i in b: w_0, w_1 = self.weights[i], self.weights[i + 1] if i == b[-1]: # include the 1 in the last iteration partitions = np.linspace(0, 1, points // len(self.weights)) for j in partitions: w = w_1 * j + (1 - j) * w_0 weights.append(np.copy(w)) means.append(np.dot(w.T, expected_returns)[0, 0]) sigma.append(np.dot(np.dot(w.T, cov_matrix), w)[0, 0]**0.5) return means, sigma, weights