├── .gitignore ├── README.md ├── cptopt ├── __init__.py ├── optimizer.py └── utility.py ├── example.py ├── pyproject.toml └── setup.cfg /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .python-version 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Portfolio Optimization with Cumulative Prospect Theory Utility via Convex Optimization 2 | 3 | This repo accompanies our [paper](https://arxiv.org/abs/2209.03461). 4 | 5 | ## Installation 6 | 7 | The `cptopt` package can be installed using `pip` as follows 8 | 9 | ```python 10 | pip install git+https://github.com/cvxgrp/cptopt.git 11 | ``` 12 | 13 | ## Minimum working example 14 | We are unable to provide the full data set used in the paper for licensing reasons. We, therefore, give a minimum working example using simulated data below. 15 | ```python 16 | import numpy as np 17 | from scipy.stats import multivariate_normal as normal 18 | 19 | from cptopt.optimizer import MinorizationMaximizationOptimizer, ConvexConcaveOptimizer, \ 20 | MeanVarianceFrontierOptimizer, GradientOptimizer 21 | from cptopt.utility import CPTUtility 22 | 23 | # Generate returns 24 | corr = np.array([ 25 | [1, -.2, -.4], 26 | [-.2, 1, .5], 27 | [-.4, .5, 1] 28 | ]) 29 | sd = np.array([.01, .1, .2]) 30 | Sigma = np.diag(sd) @ corr @ np.diag(sd) 31 | 32 | np.random.seed(0) 33 | r = normal.rvs([.03, .1, .193], Sigma, size=100) 34 | 35 | # Define utility function 36 | utility = CPTUtility( 37 | gamma_pos=8.4, gamma_neg=11.4, 38 | delta_pos=.77, delta_neg=.79 39 | ) 40 | 41 | initial_weights = np.array([1/3, 1/3, 1/3]) 42 | 43 | # Optimize 44 | mv = MeanVarianceFrontierOptimizer(utility) 45 | mv.optimize(r, verbose=True) 46 | 47 | mm = MinorizationMaximizationOptimizer(utility) 48 | mm.optimize(r, initial_weights=initial_weights, verbose=True) 49 | 50 | cc = ConvexConcaveOptimizer(utility) 51 | cc.optimize(r, initial_weights=initial_weights, verbose=True) 52 | 53 | ga = GradientOptimizer(utility) 54 | ga.optimize(r, initial_weights=initial_weights, verbose=True) 55 | ``` 56 | The optimal weights can then be accessed via the `weights` property. 57 | ```py 58 | mv.weights 59 | mm.weights 60 | cc.weights 61 | ga.weights 62 | ``` 63 | 64 | ## Citing 65 | If you want to reference our paper in your research, please consider citing us by using the following BibTeX: 66 | 67 | ```BibTeX 68 | @article{luxenberg2024cptopt, 69 | title={Portfolio Optimization with Cumulative Prospect Theory Utility via Convex Optimization}, 70 | author={Luxenberg, Eric and Schiele, Philipp and Boyd, Stephen}, 71 | journal={Computational Economics}, 72 | pages={1--21}, 73 | year={2024}, 74 | doi = {https://doi.org/10.1007/s10614-024-10556-x}, 75 | publisher={Springer}, 76 | url = {https://web.stanford.edu/\%7Eboyd/papers/pdf/cpt_opt.pdf}, 77 | } 78 | ``` 79 | 80 | -------------------------------------------------------------------------------- /cptopt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cvxgrp/cptopt/d6f006744cddca575ac9c1b384eacdc93aad784f/cptopt/__init__.py -------------------------------------------------------------------------------- /cptopt/optimizer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import copy 5 | import time 6 | from abc import abstractmethod 7 | from typing import Tuple, Optional, Union 8 | 9 | import cvxpy as cp 10 | import numpy as np 11 | import torch 12 | 13 | from cptopt.utility import CPTUtility, CPTUtilityMM, CPTUtilityCC, CPTUtilityGA 14 | 15 | 16 | class CPTOptimizer(abc.ABC): 17 | def __init__(self, utility: CPTUtility, max_iter: int = 1000): 18 | utility = copy.deepcopy(utility) 19 | self.utility = utility 20 | self.max_iter = max_iter 21 | self._weights = None 22 | self._weights_history = None 23 | self._wall_time = None 24 | 25 | @abstractmethod 26 | def optimize(self, r: np.array, verbose: bool = False) -> None: 27 | pass 28 | 29 | @property 30 | def weights(self) -> np.array: 31 | assert self._weights is not None 32 | return self._weights 33 | 34 | @property 35 | def weights_history(self) -> np.array: 36 | assert self._weights_history is not None 37 | return self._weights_history 38 | 39 | @property 40 | def wall_time(self) -> np.array: 41 | assert self._wall_time is not None 42 | return self._wall_time - np.min(self._wall_time) 43 | 44 | 45 | class MinorizationMaximizationOptimizer(CPTOptimizer): 46 | def __init__(self, utility: Union[CPTUtility, CPTUtilityMM], max_iter: int = 1000): 47 | super().__init__(utility, max_iter) 48 | if isinstance(self.utility, CPTUtility): 49 | CPTUtilityMM.convert_to_class(self.utility) 50 | 51 | def optimize( 52 | self, 53 | r: np.array, 54 | verbose: bool = False, 55 | solver: Optional[str] = cp.SCS, 56 | initial_weights: Optional[np.array] = None, 57 | eps: float = 1e-4, 58 | max_time: float = np.inf, 59 | ) -> None: 60 | 61 | w_prev = ( 62 | initial_weights if initial_weights is not None else np.ones(r.shape[1]) / r.shape[1] 63 | ) 64 | weight_history = [w_prev] 65 | wall_time = [time.time()] 66 | best_w = w_prev 67 | best_ob, _, _ = self.utility.evaluate(w_prev, r) 68 | 69 | if verbose: 70 | print("#" * 50) 71 | print(" Starting MMOptimizer ".center(50, "#")) 72 | print("#" * 50 + "\n\n") 73 | 74 | for i in range(self.max_iter): 75 | w_new, ob_heuristic = self._get_concave_minorant(r, w_prev, solver) 76 | true_ob, _, _ = self.utility.evaluate(w_new, r) 77 | 78 | if true_ob - best_ob > eps: 79 | best_ob = true_ob 80 | best_w = w_new 81 | w_prev = w_new 82 | weight_history.append(w_new) 83 | wall_time.append(time.time()) 84 | else: 85 | wall_time.append(time.time()) 86 | break 87 | 88 | if verbose: 89 | print(f" iteration {i} ".center(50, "#")) 90 | print( 91 | f"{ob_heuristic=:.3f}, {true_ob=:.3f}, " 92 | f"relative error: {ob_heuristic / true_ob - 1:.3f}" 93 | ) 94 | 95 | if wall_time[-1] - wall_time[0] > max_time: 96 | wall_time.append(time.time()) 97 | break 98 | 99 | if verbose: 100 | print(f"final utility: {true_ob:.3f}\n\n") 101 | 102 | self._weights = best_w 103 | self._weights_history = np.stack(weight_history) 104 | self._wall_time = np.array(wall_time) 105 | 106 | def _get_concave_minorant( 107 | self, r: np.array, w_prev: np.array, solver: Optional[str] 108 | ) -> Tuple[np.array, float]: 109 | """ 110 | Linearizes convex terms to obtain a concave minorant of the utility 111 | """ 112 | 113 | w = cp.Variable(r.shape[1], nonneg=True) 114 | 115 | ob, _, _ = self.utility.get_concave_minorant_expression(r, w, w_prev) 116 | 117 | constraints = [cp.sum(w) == 1] 118 | prob = cp.Problem(cp.Maximize(ob), constraints) 119 | prob.solve(solver=solver) 120 | 121 | return w.value, ob.value 122 | 123 | 124 | class GradientOptimizer(CPTOptimizer): 125 | def __init__( 126 | self, 127 | utility: Union[CPTUtility, CPTUtilityGA], 128 | max_iter: int = 100_000, 129 | gpu: bool = False, 130 | ): 131 | 132 | super().__init__(utility, max_iter) 133 | self.device = ( 134 | torch.device("cuda") if (torch.cuda.is_available() and gpu) else torch.device("cpu") 135 | ) 136 | if isinstance(self.utility, CPTUtility): 137 | CPTUtilityGA.convert_to_class(self.utility) 138 | 139 | # Help linter 140 | assert isinstance(self.utility, CPTUtilityGA) 141 | self.utility = self.utility 142 | self._all_weights_history = None 143 | 144 | @property 145 | def all_weights_history(self) -> np.array: 146 | assert self._all_weights_history is not None 147 | return self._all_weights_history 148 | 149 | def optimize( 150 | self, 151 | returns: torch.Tensor | np.ndarray, 152 | verbose: bool = False, 153 | initial_weights: Optional[Union[torch.tensor, np.ndarray]] = None, 154 | starting_points: Optional[int] = None, 155 | lr: bool = 1e-1, 156 | max_time: float = np.inf, 157 | keep_history: bool = True, 158 | ) -> None: 159 | 160 | if isinstance(returns, np.ndarray): 161 | returns = torch.tensor(returns) 162 | returns = returns.to(self.device) 163 | 164 | if initial_weights is not None: 165 | assert not starting_points 166 | if isinstance(initial_weights, np.ndarray): 167 | if initial_weights.ndim == 1: 168 | initial_weights = np.atleast_2d(initial_weights).T 169 | initial_weights = torch.tensor(initial_weights) 170 | else: 171 | initial_weights = torch.ones(returns.shape[1], dtype=torch.float64) / returns.shape[1] 172 | if starting_points and starting_points > 1: 173 | dire = torch.distributions.dirichlet.Dirichlet(torch.ones(returns.shape[1])) 174 | additional_starting_weights = dire.sample([starting_points - 1]) 175 | initial_weights = torch.vstack([initial_weights, additional_starting_weights]) 176 | initial_weights = torch.atleast_2d(initial_weights).T 177 | 178 | unconstrained_w = ( 179 | torch.log(initial_weights).to(self.device).clone().detach().requires_grad_(True) 180 | ) 181 | optimizer = torch.optim.SGD([unconstrained_w], lr=lr) 182 | 183 | if verbose: 184 | print("#" * 50) 185 | print(" Starting GradientOptimizer ".center(50, "#")) 186 | print("#" * 50 + "\n\n") 187 | 188 | wall_time = [time.time()] 189 | weight_history = [initial_weights.to(self.device)] 190 | for i in range(self.max_iter): 191 | nonneg_weights = torch.exp(unconstrained_w) 192 | weights = nonneg_weights / nonneg_weights.sum(0, keepdim=True) # normalize 193 | util = self.utility.evaluate_with_gradient(weights, returns) 194 | neg_util = -util 195 | neg_util.sum().backward() 196 | optimizer.step() 197 | optimizer.zero_grad() 198 | if keep_history: 199 | weight_history.append(weights) 200 | wall_time.append(time.time()) 201 | 202 | if verbose and i % (self.max_iter // 10) == 0: 203 | print(f" iteration {i} ".center(50, "#")) 204 | print(f"best utility: {util.max():.3f}") 205 | 206 | if wall_time[-1] - wall_time[0] > max_time: 207 | break 208 | 209 | if verbose: 210 | print(f"final utility: {util.max():.3f}\n\n") 211 | 212 | best_weights = util.argmax() 213 | self._weights = weights[:, best_weights].cpu().detach().numpy() 214 | self._wall_time = np.array(wall_time) 215 | 216 | if keep_history: 217 | self._all_weights_history = torch.stack(weight_history).cpu().detach().numpy() 218 | self._weights_history = self._all_weights_history[..., best_weights] 219 | 220 | 221 | class MeanVarianceFrontierOptimizer(CPTOptimizer): 222 | def optimize( 223 | self, 224 | returns: np.array, 225 | verbose: bool = False, 226 | solver: Optional[str] = None, 227 | samples: int = 100, 228 | ) -> None: 229 | 230 | wall_time = [time.time()] 231 | mu = np.mean(returns, axis=0) 232 | Sigma = np.cov(returns, rowvar=False) 233 | 234 | min_vol = np.sqrt(self._get_min_variance(Sigma)) 235 | max_vol = np.sqrt(self._get_max_return_variance(mu)) 236 | 237 | var_target = cp.Parameter() 238 | 239 | best_weights = None 240 | best_cpt_utility = -np.inf 241 | 242 | w = cp.Variable(len(mu), nonneg=True) 243 | objective = cp.Maximize(w @ mu) 244 | constraints = [cp.sum(w) == 1, cp.quad_form(w, Sigma) <= var_target] 245 | problem = cp.Problem(objective, constraints) 246 | 247 | if verbose: 248 | print("#" * 50) 249 | print(" Starting MeanVarianceOptimizer ".center(50, "#")) 250 | print("#" * 50 + "\n\n") 251 | 252 | weight_history = [] 253 | for vol_target_val in np.linspace(min_vol, max_vol, samples): 254 | var_target.value = vol_target_val**2 255 | problem.solve() 256 | cpt_util = self.utility.evaluate(w.value, returns)[0] 257 | wall_time.append(time.time()) 258 | weight_history.append(w.value) 259 | 260 | if verbose: 261 | print(f" volatility target {vol_target_val:.3f} ".center(50, "#")) 262 | print(f"utility: {cpt_util:.3f}") 263 | 264 | if cpt_util > best_cpt_utility: 265 | best_weights = w.value 266 | best_cpt_utility = cpt_util 267 | 268 | if verbose: 269 | print(f"final utility: {best_cpt_utility:.3f}\n\n") 270 | 271 | self._weights = best_weights 272 | self._weights_history = np.stack(weight_history) 273 | self._wall_time = np.array(wall_time) 274 | 275 | @staticmethod 276 | def _get_min_variance(Sigma: np.array) -> np.float: 277 | w = cp.Variable(Sigma.shape[0], nonneg=True) 278 | objective = cp.Minimize(cp.quad_form(w, Sigma)) 279 | constraints = [cp.sum(w) == 1] 280 | problem = cp.Problem(objective, constraints) 281 | problem.solve() 282 | return objective.value 283 | 284 | @staticmethod 285 | def _get_max_return_variance(mu: np.array) -> np.float: 286 | w = cp.Variable(len(mu), nonneg=True) 287 | objective = cp.Maximize(w @ mu) 288 | constraints = [cp.sum(w) == 1] 289 | problem = cp.Problem(objective, constraints) 290 | problem.solve() 291 | return objective.value 292 | 293 | 294 | class ConvexConcaveOptimizer(CPTOptimizer): 295 | def __init__(self, utility: Union[CPTUtility, CPTUtilityCC], max_iter: int = 1000): 296 | super().__init__(utility, max_iter) 297 | if isinstance(self.utility, CPTUtility): 298 | CPTUtilityCC.convert_to_class(self.utility) 299 | 300 | def optimize( 301 | self, 302 | r: np.array, 303 | verbose: bool = False, 304 | solver: Optional[str] = None, 305 | initial_weights: Optional[np.array] = None, 306 | eps: float = 1e-4, 307 | max_time: float = np.inf, 308 | ) -> None: 309 | 310 | w_prev = ( 311 | initial_weights if initial_weights is not None else np.ones(r.shape[1]) / r.shape[1] 312 | ) 313 | 314 | wall_time = [time.time()] 315 | weight_history = [w_prev] 316 | best_w = w_prev 317 | true_ob, _, _ = self.utility.evaluate(w_prev, r) 318 | best_ob = true_ob 319 | 320 | if verbose: 321 | print("#" * 50) 322 | print(" Starting ConvexConcaveOptimizer ".center(50, "#")) 323 | print("#" * 50 + "\n\n") 324 | 325 | for i in range(self.max_iter): 326 | 327 | trust_region = 1.0 328 | smallest_trust = 1e-3 329 | while trust_region > smallest_trust: 330 | 331 | pi = self.get_pi_from_w_prev(r, w_prev) 332 | w_new, ob_heuristic = self._get_approximate_concave_minorant( 333 | r, w_prev, pi, trust_region, solver 334 | ) 335 | true_ob, _, _ = self.utility.evaluate(w_new, r) 336 | 337 | if true_ob - best_ob > eps: 338 | best_ob = true_ob 339 | best_w = w_new 340 | w_prev = w_new 341 | break 342 | else: 343 | trust_region /= 1.5 344 | 345 | if trust_region > smallest_trust: 346 | weight_history.append(w_new) 347 | wall_time.append(time.time()) 348 | else: 349 | wall_time.append(time.time()) 350 | break 351 | 352 | if verbose: 353 | print(f" iteration {i} ".center(50, "#")) 354 | print( 355 | f"{ob_heuristic=:.3f}, {true_ob=:.3f}, " 356 | f"relative error: {ob_heuristic / true_ob - 1:.3f}" 357 | ) 358 | 359 | if wall_time[-1] - wall_time[0] > max_time: 360 | wall_time.append(time.time()) 361 | break 362 | 363 | if verbose: 364 | print(f"final utility: {true_ob:.3f}\n\n") 365 | 366 | self._weights = best_w 367 | self._weights_history = np.stack(weight_history) 368 | self._wall_time = np.array(wall_time) 369 | 370 | def _get_approximate_concave_minorant( 371 | self, 372 | r: np.array, 373 | w_prev: np.array, 374 | pi: np.array, 375 | trust_region: float, 376 | solver: Optional[str], 377 | ) -> Tuple[np.array, float]: 378 | """ 379 | Linearizes convex terms for a pis to obtain an approximate concave minorant of the utility 380 | """ 381 | 382 | w = cp.Variable(r.shape[1], nonneg=True) 383 | 384 | ( 385 | ob, 386 | _, 387 | _, 388 | aux_constraints, 389 | ) = self.utility.get_approximate_concave_minorant_expression(r, w, w_prev, pi) 390 | 391 | constraints = [ 392 | cp.sum(w) == 1, 393 | cp.norm_inf(w - w_prev) <= trust_region, 394 | ] + aux_constraints 395 | prob = cp.Problem(cp.Maximize(ob), constraints) 396 | prob.solve(solver=solver) 397 | 398 | return w.value, ob.value 399 | 400 | def get_pi_from_w_prev(self, r: np.array, w_prev: np.array) -> np.array: 401 | N = r.shape[0] 402 | previous_portfolio_returns = r @ w_prev 403 | 404 | pos_inds = previous_portfolio_returns >= 0 405 | neg_inds = ~pos_inds 406 | 407 | pos_returns = previous_portfolio_returns[pos_inds] 408 | neg_returns = previous_portfolio_returns[neg_inds] 409 | 410 | p_weights = self.utility.cumulative_weights(N, delta=self.utility.delta_pos)[ 411 | -len(pos_returns): 412 | ] 413 | n_weights = self.utility.cumulative_weights(N, delta=self.utility.delta_neg)[ 414 | -len(neg_returns): 415 | ] 416 | 417 | p_weights_sorted = p_weights[pos_returns.argsort().argsort()] 418 | n_weights_sorted = n_weights[(-neg_returns).argsort().argsort()] 419 | 420 | pi = np.zeros(N) 421 | pi[pos_inds] = p_weights_sorted 422 | pi[neg_inds] = n_weights_sorted 423 | return pi 424 | -------------------------------------------------------------------------------- /cptopt/utility.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Tuple, List, Any 2 | 3 | import cvxpy as cp 4 | import numpy as np 5 | import torch 6 | 7 | 8 | class CPTUtility: 9 | """ 10 | A utility function that incorporates features from cumulative prospect theory. 11 | 1. Prospect theory utility ('S-shaped'), parametrized by gamma_pos and gamma_neg 12 | 2. Overweighting of extreme outcomes, parametrized by delta_pos and delta_neg 13 | """ 14 | 15 | def __init__(self, gamma_pos: float, gamma_neg: float, delta_pos: float, delta_neg: float): 16 | 17 | self.gamma_pos = gamma_pos 18 | self.gamma_neg = gamma_neg 19 | 20 | self.delta_pos = delta_pos 21 | self.delta_neg = delta_neg 22 | 23 | self._validate_arguments() 24 | 25 | @staticmethod 26 | def _weight(p: np.array, delta: float) -> np.array: 27 | assert delta >= 0.278, ( 28 | f"[utility] weights are only strictly increasing for delta >= 0.278." 29 | f"{delta=} was passed." 30 | ) 31 | return (p**delta) / ((p**delta + np.maximum((1 - p), 0) ** delta) ** (1 / delta)) 32 | 33 | def cumulative_weights(self, N: int, delta: float) -> np.array: 34 | 35 | pi = -np.diff(self._weight(np.flip(np.cumsum(np.ones(N) / N)), delta)) 36 | pi = np.append(pi, np.array([self._weight(1 / N, delta)])) 37 | 38 | # make monotone 39 | assert np.sum(np.diff(np.diff(pi) > 0)) <= 1, "[utility] probabilities should be unimodal." 40 | idx_min = np.argmin(pi) 41 | pi[:idx_min] = pi[idx_min] 42 | return pi 43 | 44 | def evaluate(self, weights: np.array, returns: np.array) -> Tuple[float, float, float]: 45 | portfolio_returns = returns @ weights 46 | N = len(portfolio_returns) 47 | 48 | p_weights = self.cumulative_weights(N, delta=self.delta_pos) 49 | n_weights = self.cumulative_weights(N, delta=self.delta_neg) 50 | 51 | pos_sort = np.sort(np.maximum(portfolio_returns, 0)) 52 | util_p = p_weights @ self.p_util_expression(pos_sort).value 53 | 54 | neg_sort = np.flip(np.sort(np.minimum(portfolio_returns, 0))) 55 | util_n = n_weights @ self.n_util(neg_sort) 56 | 57 | return util_p - util_n, util_p, util_n 58 | 59 | def _validate_arguments(self) -> None: 60 | assert self.gamma_neg >= self.gamma_pos > 0, ( 61 | f"[utility] Loss aversion implies gamma_neg >= gamma_pos. " 62 | f"Here: {self.gamma_neg=}, {self.gamma_pos=}." 63 | ) 64 | assert self.delta_pos > 0, f"[utility] delta_pos must be positive: {self.delta_pos=}." 65 | assert self.delta_neg > 0, f"[utility] delta_neg must be positive: {self.delta_neg=}." 66 | 67 | def p_util_expression(self, portfolio_returns: Union[np.array, cp.Expression]) -> cp.Expression: 68 | return 1 - cp.exp(-self.gamma_pos * portfolio_returns) 69 | 70 | def n_util(self, portfolio_returns: np.array) -> np.array: 71 | return 1 - np.exp(self.gamma_neg * portfolio_returns) 72 | 73 | 74 | class ConverterMixin: 75 | @classmethod 76 | def convert_to_class(cls: Any, obj: Any) -> None: 77 | obj.__class__ = cls 78 | 79 | 80 | class CPTUtilityGA(ConverterMixin, CPTUtility): 81 | @staticmethod 82 | def utility_with_gradient(portfolio_returns: torch.Tensor, gamma: torch.Tensor) -> torch.Tensor: 83 | return 1 - torch.exp(-gamma * portfolio_returns) 84 | 85 | def evaluate_with_gradient(self, weights: torch.Tensor, returns: torch.Tensor) -> torch.Tensor: 86 | device = weights.device 87 | 88 | R = returns @ weights 89 | N = R.shape[0] 90 | 91 | pos_sort = torch.sort(torch.maximum(R, torch.tensor(0, device=device)), axis=0)[0] 92 | p_weights = torch.tensor(self.cumulative_weights(N, delta=self.delta_pos), device=device) 93 | 94 | positive_contribution = p_weights @ self.utility_with_gradient( 95 | pos_sort, gamma=torch.tensor(self.gamma_pos, device=device) 96 | ) 97 | 98 | neg_sort = torch.sort(-torch.minimum(R, torch.tensor(0, device=device)), axis=0)[0] 99 | n_weights = torch.tensor(self.cumulative_weights(N, delta=self.delta_neg), device=device) 100 | 101 | negative_contribution = n_weights @ self.utility_with_gradient( 102 | neg_sort, gamma=torch.tensor(self.gamma_neg, device=device) 103 | ) 104 | 105 | return positive_contribution - negative_contribution 106 | 107 | 108 | class CPTUtilityMM(ConverterMixin, CPTUtility): 109 | def get_concave_minorant_expression( 110 | self, 111 | returns: np.ndarray, 112 | new_weights: cp.Variable, 113 | previous_weights: np.ndarray, 114 | ) -> Tuple[cp.Expression, cp.Expression, cp.Expression]: 115 | N = returns.shape[0] 116 | previous_portfolio_returns = returns @ previous_weights 117 | new_returns = returns @ new_weights 118 | 119 | p_weights = self.cumulative_weights(N, delta=self.delta_pos) 120 | n_weights = self.cumulative_weights(N, delta=self.delta_neg) 121 | 122 | lin_neg_util = self.linearize_neg_utility(new_returns, previous_portfolio_returns) 123 | negative_contributions = cp.dotsort(cp.pos(lin_neg_util), n_weights) 124 | 125 | previous_utility = self.p_util_expression(previous_portfolio_returns).value 126 | new_utility = self.p_util_expression(new_returns) 127 | positive_contributions = self.get_linearize_weighted_pos_expression( 128 | new_utility, previous_utility, p_weights 129 | ) 130 | 131 | ob = positive_contributions - negative_contributions 132 | return ob, positive_contributions, negative_contributions 133 | 134 | @staticmethod 135 | def get_linearize_weighted_pos_expression( 136 | new_utility: cp.Expression, previous_utility: np.array, weights: np.array 137 | ) -> cp.Expression: 138 | assert (np.diff(weights) >= -1e-5).all() 139 | x_0_inds = np.argsort(previous_utility) 140 | x_0_sorted = previous_utility[x_0_inds] 141 | x_sorted = new_utility[x_0_inds] 142 | grad_top_k_pos = weights * (x_0_sorted >= 0) 143 | 144 | weighted_pos_prev = cp.dotsort(cp.pos(previous_utility), weights).value 145 | return weighted_pos_prev + grad_top_k_pos @ (x_sorted - x_0_sorted) 146 | 147 | def linearize_neg_utility( 148 | self, 149 | current_portfolio_returns: cp.Expression, 150 | previous_portfolio_returns: np.array, 151 | ) -> cp.Expression: 152 | """ 153 | Linearizes the negative utility 1-exp(gn * r) 154 | """ 155 | grad_neg_util = -self.gamma_neg * np.exp(self.gamma_neg * previous_portfolio_returns) 156 | prev_neg_utils = self.n_util(previous_portfolio_returns) 157 | return prev_neg_utils + cp.multiply( 158 | grad_neg_util, (current_portfolio_returns - previous_portfolio_returns) 159 | ) 160 | 161 | 162 | class CPTUtilityCC(ConverterMixin, CPTUtility): 163 | def get_approximate_concave_minorant_expression( 164 | self, 165 | returns: np.ndarray, 166 | new_weights: cp.Variable, 167 | previous_weights: np.ndarray, 168 | pi: np.ndarray, 169 | ) -> Tuple[cp.Expression, cp.Expression, cp.Expression, List[cp.Expression]]: 170 | previous_portfolio_returns = returns @ previous_weights 171 | new_returns = returns @ new_weights 172 | 173 | lin_neg_utilities = self.linearize_convex_concave_negative( 174 | new_returns, previous_portfolio_returns 175 | ) 176 | negative_contributions = pi @ lin_neg_utilities 177 | 178 | pos_utilities, aux_constraints = self.convex_concave_positive(new_returns) 179 | positive_contributions = pi @ pos_utilities 180 | 181 | ob = positive_contributions + negative_contributions 182 | return ob, positive_contributions, negative_contributions, aux_constraints 183 | 184 | def linearize_convex_concave_negative( 185 | self, new_returns: cp.Expression, previous_portfolio_returns: np.array 186 | ) -> cp.Expression: 187 | grad_conv = ( 188 | self.gamma_neg * np.exp(self.gamma_neg * previous_portfolio_returns) - self.gamma_neg 189 | ) 190 | grad_conv[previous_portfolio_returns >= 0] = 0 191 | prev_conv = ( 192 | -1 193 | + np.exp(self.gamma_neg * previous_portfolio_returns) 194 | - self.gamma_neg * previous_portfolio_returns 195 | ) 196 | prev_conv[previous_portfolio_returns >= 0] = 0 197 | return prev_conv + cp.multiply(grad_conv, (new_returns - previous_portfolio_returns)) 198 | 199 | def convex_concave_positive( 200 | self, new_returns: cp.Expression 201 | ) -> Tuple[cp.Variable, List[cp.Expression]]: 202 | N = new_returns.shape[0] 203 | r_neg = cp.Variable(N, nonpos=True) 204 | r_pos = cp.Variable(N, nonneg=True) 205 | 206 | t = cp.Variable(N) 207 | t1 = cp.Variable(N) 208 | t2 = cp.Variable(N) 209 | 210 | extended = self.gamma_neg * r_neg 211 | util = 1 - cp.exp(-self.gamma_pos * r_pos) 212 | 213 | aux_constraints = [ 214 | t <= t1 + t2, 215 | new_returns == r_neg + r_pos, 216 | t1 == extended, 217 | t2 <= util, 218 | ] 219 | return t, aux_constraints 220 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.stats import multivariate_normal as normal 3 | 4 | from cptopt.optimizer import MinorizationMaximizationOptimizer, ConvexConcaveOptimizer, \ 5 | MeanVarianceFrontierOptimizer, GradientOptimizer 6 | from cptopt.utility import CPTUtility 7 | 8 | # Generate returns 9 | corr = np.array([ 10 | [1, -.2, -.4], 11 | [-.2, 1, .5], 12 | [-.4, .5, 1] 13 | ]) 14 | sd = np.array([.01, .1, .2]) 15 | Sigma = np.diag(sd) @ corr @ np.diag(sd) 16 | 17 | np.random.seed(0) 18 | r = normal.rvs([.03, .1, .193], Sigma, size=100) 19 | 20 | # Define utility function 21 | utility = CPTUtility( 22 | gamma_pos=8.4, gamma_neg=11.4, 23 | delta_pos=.77, delta_neg=.79 24 | ) 25 | 26 | initial_weights = np.array([1/3, 1/3, 1/3]) 27 | 28 | # Optimize 29 | mv = MeanVarianceFrontierOptimizer(utility) 30 | mv.optimize(r, verbose=True) 31 | 32 | mm = MinorizationMaximizationOptimizer(utility) 33 | mm.optimize(r, initial_weights=initial_weights, verbose=True) 34 | 35 | cc = ConvexConcaveOptimizer(utility) 36 | cc.optimize(r, initial_weights=initial_weights, verbose=True) 37 | 38 | ga = GradientOptimizer(utility) 39 | ga.optimize(r, initial_weights=initial_weights, verbose=True) 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = cptopt 3 | version = 0.1.0 4 | author = Eric Luxenberg, Philipp Schiele, Stephen Boyd 5 | author_email = ericlux@stanford.edu, philipp.schiele@stat.uni-muenchen.de, boyd@stanford.edu 6 | url = https://github.com/cvxgrp/cptopt 7 | description = Portfolio Optimization with Cumulative Prospect Theory Utility via Convex Optimization 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | license = Apache License, Version 2.0 11 | 12 | [options] 13 | packages = find: 14 | install_requires = 15 | cvxpy >= 1.3 16 | numpy 17 | scipy 18 | torch 19 | zip_safe = True 20 | include_package_data = True 21 | 22 | [options.extras_require] 23 | dev = pytest 24 | 25 | [options.package_data] 26 | * = README.md 27 | 28 | [flake8] 29 | max-line-length = 100 --------------------------------------------------------------------------------