diff --git a/pypfopt/base_optimizer.py b/pypfopt/base_optimizer.py index 01a6e57..bd4566e 100644 --- a/pypfopt/base_optimizer.py +++ b/pypfopt/base_optimizer.py @@ -6,7 +6,7 @@ optimisation. Additionally, we define a general utility function ``portfolio_performance`` to evaluate return and risk for a given set of portfolio weights. """ - +import collections import json import warnings import numpy as np @@ -48,14 +48,25 @@ class BaseOptimizer: # Outputs self.weights = None - def set_weights(self, weights): + def _make_output_weights(self, weights=None): """ - Utility function to set weights. + Utility function to make output weight dict from weight attribute (np.array). If no + arguments passed, use self.tickers and self.weights. If one argument is passed, assume + it is an alternative weight array so use self.tickers and the argument. + """ + if weights is None: + weights = self.weights - :param weights: {ticker: weight} dictionary - :type weights: dict + return collections.OrderedDict(zip(self.tickers, weights)) + + def set_weights(self, input_weights): """ - self.weights = np.array([weights[ticker] for ticker in self.tickers]) + Utility function to set weights attribute (np.array) from user input + + :param input_weights: {ticker: weight} dict + :type input_weights: dict + """ + self.weights = np.array([input_weights[ticker] for ticker in self.tickers]) def clean_weights(self, cutoff=1e-4, rounding=5): """ @@ -68,7 +79,7 @@ class BaseOptimizer: Set to None if rounding is not desired. :type rounding: int, optional :return: asset weights - :rtype: dict + :rtype: OrderedDict """ if self.weights is None: raise AttributeError("Weights not yet computed") @@ -78,7 +89,8 @@ class BaseOptimizer: if not isinstance(rounding, int) or rounding < 1: raise ValueError("rounding must be a positive integer") clean_weights = np.round(clean_weights, rounding) - return dict(zip(self.tickers, clean_weights)) + + return self._make_output_weights(clean_weights) def save_weights_to_file(self, filename="weights.csv"): """ @@ -95,9 +107,11 @@ class BaseOptimizer: elif ext == "json": with open(filename, "w") as fp: json.dump(clean_weights, fp) - else: + elif ext == "txt": with open(filename, "w") as f: - f.write(str(clean_weights)) + f.write(str(dict(clean_weights))) + else: + raise NotImplementedError("Only supports .txt .json .csv") class BaseConvexOptimizer(BaseOptimizer): @@ -206,6 +220,7 @@ class BaseConvexOptimizer(BaseOptimizer): if opt.status != "optimal": raise exceptions.OptimizationError self.weights = self._w.value.round(16) + 0.0 # +0.0 removes signed zero + return self._make_output_weights() def add_objective(self, new_objective, **kwargs): """ @@ -300,7 +315,7 @@ class BaseConvexOptimizer(BaseOptimizer): :type weights_sum_to_one: bool, optional :raises OptimizationError: if the objective is nonconvex or constraints nonlinear. :return: asset weights for the efficient risk portfolio - :rtype: dict + :rtype: OrderedDict """ # custom_objective must have the right signature (w, **kwargs) self._objective = custom_objective(self._w, **kwargs) @@ -311,8 +326,7 @@ class BaseConvexOptimizer(BaseOptimizer): if weights_sum_to_one: self._constraints.append(cp.sum(self._w) == 1) - self._solve_cvxpy_opt_problem() - return dict(zip(self.tickers, self.weights)) + return self._solve_cvxpy_opt_problem() def nonconvex_objective( self, @@ -355,7 +369,7 @@ class BaseConvexOptimizer(BaseOptimizer): User beware: different optimisers require different inputs. :type solver: string :return: asset weights that optimise the custom objective - :rtype: dict + :rtype: OrderedDict """ # Sanitise inputs if not isinstance(objective_args, tuple): @@ -383,7 +397,7 @@ class BaseConvexOptimizer(BaseOptimizer): constraints=final_constraints, ) self.weights = result["x"] - return dict(zip(self.tickers, self.weights)) + return self._make_output_weights() def portfolio_performance( diff --git a/pypfopt/black_litterman.py b/pypfopt/black_litterman.py index ee2d1db..c2e8cb8 100644 --- a/pypfopt/black_litterman.py +++ b/pypfopt/black_litterman.py @@ -426,7 +426,7 @@ class BlackLittermanModel(base_optimizer.BaseOptimizer): :param risk_aversion: risk aversion parameter, defaults to 1 :type risk_aversion: positive float, optional :return: asset weights implied by returns - :rtype: dict + :rtype: OrderedDict """ if risk_aversion is None: risk_aversion = self.risk_aversion @@ -436,7 +436,7 @@ class BlackLittermanModel(base_optimizer.BaseOptimizer): b = self.posterior_rets raw_weights = np.linalg.solve(A, b) self.weights = raw_weights / raw_weights.sum() - return dict(zip(self.tickers, self.weights)) + return self._make_output_weights() def optimize(self, risk_aversion=None): """ diff --git a/pypfopt/cla.py b/pypfopt/cla.py index 98e53aa..58710ae 100644 --- a/pypfopt/cla.py +++ b/pypfopt/cla.py @@ -376,7 +376,7 @@ class CLA(base_optimizer.BaseOptimizer): Maximise the Sharpe ratio. :return: asset weights for the volatility-minimising portfolio - :rtype: dict + :rtype: OrderedDict """ if not self.w: self._solve() @@ -391,14 +391,14 @@ class CLA(base_optimizer.BaseOptimizer): sr.append(b) self.weights = w_sr[sr.index(max(sr))].reshape((self.n_assets,)) - return dict(zip(self.tickers, self.weights)) + return self._make_output_weights() def min_volatility(self): """ Minimise volatility. :return: asset weights for the volatility-minimising portfolio - :rtype: dict + :rtype: OrderedDict """ if not self.w: self._solve() @@ -408,7 +408,7 @@ class CLA(base_optimizer.BaseOptimizer): var.append(a) # return min(var)**.5, self.w[var.index(min(var))] self.weights = self.w[var.index(min(var))].reshape((self.n_assets,)) - return dict(zip(self.tickers, self.weights)) + return self._make_output_weights() def efficient_frontier(self, points=100): """ @@ -441,51 +441,6 @@ class CLA(base_optimizer.BaseOptimizer): self.frontier_values = (mu, sigma, weights) return mu, sigma, weights - def plot_efficient_frontier( - self, points=100, show_assets=True, filename=None, showfig=True - ): - try: - import matplotlib.pyplot as plt - except (ModuleNotFoundError, ImportError): - raise ImportError("Please install matplotlib via pip or poetry") - - warnings.warn( - "This method is deprecated and will be removed in v1.2.0. " - "Please use pypfopt.plotting instead" - ) - optimal_ret, optimal_risk, _ = self.portfolio_performance() - - if self.frontier_values is None: - self.efficient_frontier(points=points) - - mus, sigmas, _ = self.frontier_values - - fig, ax = plt.subplots() - ax.plot(sigmas, mus, label="Efficient frontier") - - if show_assets: - ax.scatter( - np.sqrt(np.diag(self.cov_matrix)), - self.expected_returns, - s=30, - color="k", - label="assets", - ) - - ax.scatter( - optimal_risk, optimal_ret, marker="x", s=100, color="r", label="optimal" - ) - ax.legend() - ax.set_xlabel("Volatility") - ax.set_ylabel("Return") - - if filename: - plt.savefig(fname=filename, dpi=300) - - if showfig: - plt.show() - return ax - def set_weights(self, _): # Overrides parent method since set_weights does nothing. raise NotImplementedError("set_weights does nothing for CLA") diff --git a/pypfopt/discrete_allocation.py b/pypfopt/discrete_allocation.py index 41a419a..3588624 100644 --- a/pypfopt/discrete_allocation.py +++ b/pypfopt/discrete_allocation.py @@ -2,7 +2,7 @@ The ``discrete_allocation`` module contains the ``DiscreteAllocation`` class, which offers multiple methods to generate a discrete portfolio allocation from continuous weights. """ - +import collections import numpy as np import pandas as pd import cvxpy as cp @@ -233,7 +233,7 @@ class DiscreteAllocation: available_funds -= price self.allocation = self._remove_zero_positions( - dict(zip([i[0] for i in self.weights], shares_bought)) + collections.OrderedDict(zip([i[0] for i in self.weights], shares_bought)) ) if verbose: @@ -311,7 +311,7 @@ class DiscreteAllocation: vals = np.rint(x.value) self.allocation = self._remove_zero_positions( - dict(zip([i[0] for i in self.weights], vals)) + collections.OrderedDict(zip([i[0] for i in self.weights], vals)) ) if verbose: diff --git a/pypfopt/efficient_frontier.py b/pypfopt/efficient_frontier.py index ebc66db..bd0d023 100644 --- a/pypfopt/efficient_frontier.py +++ b/pypfopt/efficient_frontier.py @@ -139,7 +139,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): Minimise volatility. :return: asset weights for the volatility-minimising portfolio - :rtype: dict + :rtype: OrderedDict """ self._objective = objective_functions.portfolio_variance( self._w, self.cov_matrix @@ -149,8 +149,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): self._constraints.append(cp.sum(self._w) == 1) - self._solve_cvxpy_opt_problem() - return dict(zip(self.tickers, self.weights)) + return self._solve_cvxpy_opt_problem() def max_sharpe(self, risk_free_rate=0.02): """ @@ -166,7 +165,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): :type risk_free_rate: float, optional :raises ValueError: if ``risk_free_rate`` is non-numeric :return: asset weights for the Sharpe-maximising portfolio - :rtype: dict + :rtype: OrderedDict """ if not isinstance(risk_free_rate, (int, float)): raise ValueError("risk_free_rate should be numeric") @@ -213,7 +212,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): self._solve_cvxpy_opt_problem() # Inverse-transform self.weights = (self._w.value / k.value).round(16) + 0.0 - return dict(zip(self.tickers, self.weights)) + return self._make_output_weights() def max_quadratic_utility(self, risk_aversion=1, market_neutral=False): r""" @@ -230,7 +229,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): defaults to False. Requires negative lower weight bound. :param market_neutral: bool, optional :return: asset weights for the maximum-utility portfolio - :rtype: dict + :rtype: OrderedDict """ if risk_aversion <= 0: raise ValueError("risk aversion coefficient must be greater than zero") @@ -247,8 +246,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): else: self._constraints.append(cp.sum(self._w) == 1) - self._solve_cvxpy_opt_problem() - return dict(zip(self.tickers, self.weights)) + return self._solve_cvxpy_opt_problem() def efficient_risk(self, target_volatility, market_neutral=False): """ @@ -263,7 +261,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): :raises ValueError: if no portfolio can be found with volatility equal to ``target_volatility`` :raises ValueError: if ``risk_free_rate`` is non-numeric :return: asset weights for the efficient risk portfolio - :rtype: dict + :rtype: OrderedDict """ if not isinstance(target_volatility, (float, int)) or target_volatility < 0: raise ValueError("target_volatility should be a positive float") @@ -286,8 +284,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): else: self._constraints.append(cp.sum(self._w) == 1) - self._solve_cvxpy_opt_problem() - return dict(zip(self.tickers, self.weights)) + return self._solve_cvxpy_opt_problem() def efficient_return(self, target_return, market_neutral=False): """ @@ -301,7 +298,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): :raises ValueError: if ``target_return`` is not a positive float :raises ValueError: if no portfolio can be found with return equal to ``target_return`` :return: asset weights for the Markowitz portfolio - :rtype: dict + :rtype: OrderedDict """ if not isinstance(target_return, float) or target_return < 0: raise ValueError("target_return should be a positive float") @@ -333,9 +330,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer): else: self._constraints.append(cp.sum(self._w) == 1) - self._solve_cvxpy_opt_problem() - - return dict(zip(self.tickers, self.weights)) + return self._solve_cvxpy_opt_problem() def portfolio_performance(self, verbose=False, risk_free_rate=0.02): """ diff --git a/pypfopt/hierarchical_portfolio.py b/pypfopt/hierarchical_portfolio.py index 3ec5445..f6611c2 100644 --- a/pypfopt/hierarchical_portfolio.py +++ b/pypfopt/hierarchical_portfolio.py @@ -12,6 +12,7 @@ Currently implemented: permission from Marcos Lopez de Prado (2016). """ +import collections import warnings import numpy as np import pandas as pd @@ -143,7 +144,7 @@ class HRPOpt(base_optimizer.BaseOptimizer): Construct a hierarchical risk parity portfolio :return: weights for the HRP portfolio - :rtype: dict + :rtype: OrderedDict """ if self.returns is None: cov = self.cov_matrix @@ -155,47 +156,17 @@ class HRPOpt(base_optimizer.BaseOptimizer): # per https://stackoverflow.com/questions/18952587/ # this can avoid some nasty floating point issues - matrix = np.sqrt(np.clip((1.0 - corr) / 2., a_min=0.0, a_max=1.0)) + matrix = np.sqrt(np.clip((1.0 - corr) / 2.0, a_min=0.0, a_max=1.0)) dist = ssd.squareform(matrix, checks=False) self.clusters = sch.linkage(dist, "single") sort_ix = HRPOpt._get_quasi_diag(self.clusters) ordered_tickers = corr.index[sort_ix].tolist() hrp = HRPOpt._raw_hrp_allocation(cov, ordered_tickers) - weights = dict(hrp.sort_index()) + weights = collections.OrderedDict(hrp.sort_index()) self.set_weights(weights) return weights - def plot_dendrogram(self, show_tickers=True, filename=None, showfig=True): - try: - import matplotlib.pyplot as plt - except (ModuleNotFoundError, ImportError): - raise ImportError("Please install matplotlib via pip or poetry") - - warnings.warn( - "This method is deprecated and will be removed in v1.2.0. " - "Please use pypfopt.Plotting instead" - ) - - if self.clusters is None: - self.optimize() - - fig, ax = plt.subplots() - if show_tickers: - sch.dendrogram(self.clusters, labels=self.tickers, ax=ax) - plt.xticks(rotation=90) - plt.tight_layout() - else: - sch.dendrogram(self.clusters, ax=ax) - - if filename: - plt.savefig(fname=filename, dpi=300) - - if showfig: - plt.show() - - return ax - def portfolio_performance(self, verbose=False, risk_free_rate=0.02, frequency=252): """ After optimising, calculate (and optionally print) the performance of the optimal diff --git a/pypfopt/risk_models.py b/pypfopt/risk_models.py index 5d770e0..f07906f 100644 --- a/pypfopt/risk_models.py +++ b/pypfopt/risk_models.py @@ -19,7 +19,6 @@ The format of the data input is the same as that in :ref:`expected-returns`. - Oracle Approximating shrinkage - covariance to correlation matrix -- plot of the covariance matrix (deprecated) """ import warnings diff --git a/tests/test_base_optimizer.py b/tests/test_base_optimizer.py index d64fd6c..7be110e 100644 --- a/tests/test_base_optimizer.py +++ b/tests/test_base_optimizer.py @@ -189,6 +189,7 @@ def test_set_weights(): def test_save_weights_to_file(): ef = setup_efficient_frontier() ef.min_volatility() + ef.save_weights_to_file("tests/test.txt") with open("tests/test.txt", "r") as f: file = f.read()