├── .github └── workflows │ └── python-app.yml ├── LICENSE ├── README.md ├── notebooks ├── ConstrainedRiskBudgeting.ipynb ├── RiskBudgeting.ipynb └── data.csv ├── pyrb ├── __init__.py ├── allocation.py ├── settings.py ├── solvers.py ├── tools.py └── validation.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py └── test_risk_budgeting.py /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 3.6 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.6 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install flake8 pytest 27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | - name: Test with pytest 35 | run: | 36 | pytest 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 jcrichard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Constrained and Unconstrained Risk Budgeting Allocation in Python 2 | ================ 3 | 4 | [![Actions Status](https://github.com/jcrichard/pyrb/workflows/Python%20application/badge.svg)](https://github.com/jcrichard/pyrb/actions) 5 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | 8 | 9 | This repository contains the code for solving constrained risk budgeting 10 | with generalized standard deviation-based risk measure: 11 | 12 | 13 | 14 | 15 | This formulation encompasses Gaussian value-at-risk and Gaussian expected shortfall and the volatility. The algorithm supports bounds constraints and inequality constraints. It is is efficient for large dimension and suitable for backtesting. 16 | 17 | A description can be found in [*Constrained Risk Budgeting Portfolios: Theory, Algorithms, Applications & Puzzles*](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3331184) 18 | by Jean-Charles Richard and Thierry Roncalli. 19 | 20 | You can solve 21 | ------------------ 22 | 23 | - Equally risk contribution 24 | - Risk budgeting 25 | - Risk parity with expected return 26 | - Constrained Risk parity 27 | 28 | Installation 29 | ------------------ 30 | Can be done using ``pip``: 31 | 32 | pip install git+https://github.com/jcrichard/pyrb 33 | 34 | 35 | Usage 36 | ------------------ 37 | 38 | from pyrb import EqualRiskContribution 39 | 40 | ERC = EqualRiskContribution(cov) 41 | ERC.solve() 42 | ERC.get_risk_contributions() 43 | ERC.get_volatility() 44 | 45 | 46 | References 47 | ------------------ 48 | 49 | >Griveau-Billion, T., Richard, J-C., and Roncalli, T. (2013), A Fast Algorithm for Computing High-dimensional Risk Parity Portfolios, SSRN. 50 | 51 | >Maillard, S., Roncalli, T. and 52 | Teiletche, J. (2010), The Properties of Equally Weighted Risk Contribution Portfolios, 53 | Journal of Portfolio Management, 36(4), pp. 60-70. 54 | 55 | >Richard, J-C., and Roncalli, T. (2015), Smart 56 | Beta: Managing Diversification of Minimum Variance Portfolios, in Jurczenko, E. (Ed.), 57 | Risk-based and Factor Investing, ISTE Press -- Elsevier. 58 | 59 | >Richard, J-C., and Roncalli, T. (2019), Constrained Risk Budgeting Portfolios: Theory, Algorithms, Applications & Puzzles, SSRN. 60 | 61 | >Roncalli, T. (2015), Introducing Expected Returns into Risk Parity Portfolios: A New Framework for Asset Allocation, 62 | Bankers, Markets & Investors, 138, pp. 18-28. 63 | 64 | -------------------------------------------------------------------------------- /notebooks/ConstrainedRiskBudgeting.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "### Repoducing Table 9 from the paper Constrained Risk Budgeting Portfolios." 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": 1, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import pandas as pd\n", 17 | "import numpy as np\n", 18 | "from pyrb import ConstrainedRiskBudgeting\n" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": 2, 24 | "metadata": {}, 25 | "outputs": [], 26 | "source": [ 27 | "vol = [0.05,0.05,0.07,0.1,0.15,0.15,0.15,0.18]\n", 28 | "cor = np.array([[100, 80, 60, -20, -10, -20, -20, -20],\n", 29 | " [ 80, 100, 40, -20, -20, -10, -20, -20],\n", 30 | " [ 60, 40, 100, 50, 30, 20, 20, 30],\n", 31 | " [-20, -20, 50, 100, 60, 60, 50, 60],\n", 32 | " [-10, -20, 30, 60, 100, 90, 70, 70],\n", 33 | " [-20, -10, 20, 60, 90, 100, 60, 70],\n", 34 | " [-20, -20, 20, 50, 70, 60, 100, 70],\n", 35 | " [-20, -20, 30, 60, 70, 70, 70, 100]])/100\n", 36 | "cov = np.outer(vol,vol)*cor" 37 | ] 38 | }, 39 | { 40 | "cell_type": "code", 41 | "execution_count": 11, 42 | "metadata": {}, 43 | "outputs": [ 44 | { 45 | "name": "stdout", 46 | "output_type": "stream", 47 | "text": [ 48 | "solution x: [26.8306 28.6769 11.4095 9.7985 5.6135 5.9029 6.656 5.1121]\n", 49 | "lambda star: 4.7776\n", 50 | "risk contributions: [12.5 12.5 12.5 12.5 12.5 12.5 12.5 12.5]\n", 51 | "sigma(x): 4.7776\n", 52 | "sum(x): 100.0\n", 53 | "mu(x): 0.0\n", 54 | "\n" 55 | ] 56 | } 57 | ], 58 | "source": [ 59 | "C = None\n", 60 | "d = None\n", 61 | "\n", 62 | "CRB = ConstrainedRiskBudgeting(cov,C=C,d=d)\n", 63 | "CRB.solve()\n", 64 | "print(CRB)" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": 13, 70 | "metadata": {}, 71 | "outputs": [ 72 | { 73 | "name": "stdout", 74 | "output_type": "stream", 75 | "text": [ 76 | "solver: admm_ccd\n", 77 | "----------------------------\n", 78 | "solution x: [25.7859 27.4087 9.5153 7.2904 7.0579 7.7127 9.2265 6.0027]\n", 79 | "lambda star: 3.5906\n", 80 | "risk contributions: [ 8.6357 8.6355 8.6356 8.6356 15.9089 16.5836 18.1435 14.8215]\n", 81 | "sigma(x): 5.1974\n", 82 | "sum(x): 100.0\n", 83 | "mu(x): 0.0\n", 84 | "C@x: [-0.29999813]\n", 85 | "\n" 86 | ] 87 | } 88 | ], 89 | "source": [ 90 | "C = np.array([[0,0,0,0,-1.0,-1.0,-1.0,-1.0]]) \n", 91 | "d = [-0.3]\n", 92 | "\n", 93 | "CRB = ConstrainedRiskBudgeting(cov,C=C,d=d)\n", 94 | "CRB.solve()\n", 95 | "print(CRB)" 96 | ] 97 | }, 98 | { 99 | "cell_type": "code", 100 | "execution_count": 14, 101 | "metadata": {}, 102 | "outputs": [ 103 | { 104 | "name": "stdout", 105 | "output_type": "stream", 106 | "text": [ 107 | "solver: admm_ccd\n", 108 | "----------------------------\n", 109 | "solution x: [24.5238 28.6911 9.5175 7.2674 6.9707 7.8033 9.2305 5.9956]\n", 110 | "lambda star: 3.5783\n", 111 | "risk contributions: [ 8.1646 9.1316 8.6103 8.6103 15.6929 16.8225 18.1567 14.8111]\n", 112 | "sigma(x): 5.1947\n", 113 | "sum(x): 100.0\n", 114 | "mu(x): 0.0\n", 115 | "C@x: [-0.30000182 -0.05000006]\n", 116 | "\n" 117 | ] 118 | } 119 | ], 120 | "source": [ 121 | "C = np.array([[0,0,0,0,-1.0,-1.0,-1.0,-1.0],[1,-1,0,0,1,-1,0,0]]) \n", 122 | "d = [-0.3,-0.05]\n", 123 | "\n", 124 | "CRB = ConstrainedRiskBudgeting(cov,C=C,d=d)\n", 125 | "CRB.solve()\n", 126 | "print(CRB)" 127 | ] 128 | }, 129 | { 130 | "cell_type": "code", 131 | "execution_count": 15, 132 | "metadata": {}, 133 | "outputs": [ 134 | { 135 | "name": "stdout", 136 | "output_type": "stream", 137 | "text": [ 138 | "solver: admm_ccd\n", 139 | "----------------------------\n", 140 | "solution x: [ 0.1575 69.2533 0.3023 0.2868 29.4809 0.2412 0.1871 0.091 ]\n", 141 | "lambda star: 0.1833\n", 142 | "risk contributions: [ 2.6239 2.6173 2.6239 2.6239 80.2721 3.259 3.1166 2.8634]\n", 143 | "sigma(x): 5.1158\n", 144 | "sum(x): 100.0\n", 145 | "mu(x): 9.3581\n", 146 | "C@x: [-0.30000095 -0.39855982]\n", 147 | "\n" 148 | ] 149 | } 150 | ], 151 | "source": [ 152 | "C = np.array([[0,0,0,0,-1.0,-1.0,-1.0,-1.0],[1,-1,0,0,1,-1,0,0]]) \n", 153 | "d = [-0.3,-0.05]\n", 154 | "pi = [-0.1,0.05,0,0,0.2,0.1,0,-0.1]# not in the paper\n", 155 | "c = 2\n", 156 | "\n", 157 | "CRB = ConstrainedRiskBudgeting(cov,C=C,d=d,pi=pi,c=c)\n", 158 | "CRB.solve()\n", 159 | "print(CRB)" 160 | ] 161 | } 162 | ], 163 | "metadata": { 164 | "kernelspec": { 165 | "display_name": "Python 3", 166 | "language": "python", 167 | "name": "python3" 168 | }, 169 | "language_info": { 170 | "codemirror_mode": { 171 | "name": "ipython", 172 | "version": 3 173 | }, 174 | "file_extension": ".py", 175 | "mimetype": "text/x-python", 176 | "name": "python", 177 | "nbconvert_exporter": "python", 178 | "pygments_lexer": "ipython3", 179 | "version": "3.6.3" 180 | } 181 | }, 182 | "nbformat": 4, 183 | "nbformat_minor": 2 184 | } 185 | -------------------------------------------------------------------------------- /notebooks/RiskBudgeting.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## Example" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "from pyrb import EqualRiskContribution, RiskBudgeting, RiskBudgetAllocation\n", 17 | "import pandas as pd\n", 18 | "import numpy as np" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": { 25 | "scrolled": true 26 | }, 27 | "outputs": [], 28 | "source": [ 29 | "#get a covariance matrix of an asset universe\n", 30 | "covariance_matrix = pd.read_csv(\"data.csv\",sep=\";\",index_col=0).pct_change().cov() * 260\n", 31 | "covariance_matrix" 32 | ] 33 | }, 34 | { 35 | "cell_type": "markdown", 36 | "metadata": {}, 37 | "source": [ 38 | "#### Solving the ERC problem" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": null, 44 | "metadata": {}, 45 | "outputs": [], 46 | "source": [ 47 | "ERC = EqualRiskContribution(covariance_matrix)\n", 48 | "ERC.solve()" 49 | ] 50 | }, 51 | { 52 | "cell_type": "markdown", 53 | "metadata": {}, 54 | "source": [ 55 | " The optimal solution that gives equal risk contributions is:" 56 | ] 57 | }, 58 | { 59 | "cell_type": "code", 60 | "execution_count": null, 61 | "metadata": {}, 62 | "outputs": [], 63 | "source": [ 64 | "optimal_weights = ERC.x\n", 65 | "risk_contributions = ERC.get_risk_contributions(scale = False)\n", 66 | "risk_contributions_scaled = ERC.get_risk_contributions()\n", 67 | "allocation = pd.DataFrame(np.concatenate([[optimal_weights,risk_contributions,risk_contributions_scaled]] ).T, index = covariance_matrix.index,columns=[\"optinal weigths\",\"risk contribution\",\"risk contribution(scaled)\"])\n", 68 | "allocation" 69 | ] 70 | }, 71 | { 72 | "cell_type": "markdown", 73 | "metadata": {}, 74 | "source": [ 75 | "Each asset has a risk contribution of 10% to the total risk. We also verify that the sum of the risk budget is equal to the variance:" 76 | ] 77 | }, 78 | { 79 | "cell_type": "code", 80 | "execution_count": null, 81 | "metadata": {}, 82 | "outputs": [], 83 | "source": [ 84 | "np.round(np.dot(np.dot(ERC.x,covariance_matrix),ERC.x)**0.5,10) == np.round(allocation['risk contribution'].sum(),10)" 85 | ] 86 | }, 87 | { 88 | "cell_type": "markdown", 89 | "metadata": {}, 90 | "source": [ 91 | "#### Solving the risk budgeting problem\n" 92 | ] 93 | }, 94 | { 95 | "cell_type": "markdown", 96 | "metadata": {}, 97 | "source": [ 98 | "Now we want the risk contributions equal to specific budgets" 99 | ] 100 | }, 101 | { 102 | "cell_type": "code", 103 | "execution_count": null, 104 | "metadata": {}, 105 | "outputs": [], 106 | "source": [ 107 | "budgets = [0.1,0.1,0.1,0.2,0.2,0.05,0.05,0.05,0.05,0.1]\n", 108 | "RB = RiskBudgeting(covariance_matrix,budgets)\n", 109 | "RB.solve()" 110 | ] 111 | }, 112 | { 113 | "cell_type": "code", 114 | "execution_count": null, 115 | "metadata": {}, 116 | "outputs": [], 117 | "source": [ 118 | "optimal_weights = RB.x\n", 119 | "risk_contributions = RB.get_risk_contributions(scale = False)\n", 120 | "risk_contributions_scaled = RB.get_risk_contributions()\n", 121 | "allocation = pd.DataFrame(np.concatenate([[optimal_weights,risk_contributions,risk_contributions_scaled]] ).T, index = covariance_matrix.index,columns=[\"optinal weigths\",\"risk contribution\",\"risk contribution(scaled)\"])\n", 122 | "allocation" 123 | ] 124 | }, 125 | { 126 | "cell_type": "markdown", 127 | "metadata": {}, 128 | "source": [ 129 | "Again the risk contributions match the budgets and the variance equals the sum of the risk contribution." 130 | ] 131 | }, 132 | { 133 | "cell_type": "code", 134 | "execution_count": null, 135 | "metadata": {}, 136 | "outputs": [], 137 | "source": [ 138 | " np.round(np.dot(np.dot(RB.x,covariance_matrix),RB.x)**0.5,10) == np.round(allocation['risk contribution'].sum(),10)" 139 | ] 140 | } 141 | ], 142 | "metadata": { 143 | "kernelspec": { 144 | "display_name": "Python 3", 145 | "language": "python", 146 | "name": "python3" 147 | }, 148 | "language_info": { 149 | "codemirror_mode": { 150 | "name": "ipython", 151 | "version": 3 152 | }, 153 | "file_extension": ".py", 154 | "mimetype": "text/x-python", 155 | "name": "python", 156 | "nbconvert_exporter": "python", 157 | "pygments_lexer": "ipython3", 158 | "version": "3.6.3" 159 | } 160 | }, 161 | "nbformat": 4, 162 | "nbformat_minor": 2 163 | } 164 | -------------------------------------------------------------------------------- /pyrb/__init__.py: -------------------------------------------------------------------------------- 1 | from .allocation import ( 2 | EqualRiskContribution, 3 | RiskBudgeting, 4 | RiskBudgetAllocation, 5 | RiskBudgetingWithER, 6 | ConstrainedRiskBudgeting, 7 | ) 8 | -------------------------------------------------------------------------------- /pyrb/allocation.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import abstractmethod 3 | 4 | import numpy as np 5 | import scipy.optimize as optimize 6 | 7 | from . import tools 8 | from . import validation 9 | from .settings import BISECTION_UPPER_BOUND, MAXITER_BISECTION 10 | from .solvers import solve_rb_ccd, solve_rb_admm_qp, solve_rb_admm_ccd 11 | 12 | 13 | class RiskBudgetAllocation: 14 | @property 15 | def cov(self): 16 | return self.__cov 17 | 18 | @property 19 | def x(self): 20 | return self._x 21 | 22 | @property 23 | def pi(self): 24 | return self.__pi 25 | 26 | @property 27 | def n(self): 28 | return self.__n 29 | 30 | def __init__(self, cov, pi=None, x=None): 31 | """ 32 | Base class for Risk Budgeting Allocation. 33 | 34 | Parameters 35 | ---------- 36 | cov : array, shape (n, n) 37 | Covariance matrix of the returns. 38 | 39 | pi : array, shape(n,) 40 | Expected excess return for each asset (the default is None which implies 0 for each asset). 41 | 42 | x : array, shape(n,) 43 | Array of weights. 44 | 45 | """ 46 | self.__n = cov.shape[0] 47 | if x is None: 48 | x = np.array([np.nan] * self.n) 49 | self._x = x 50 | validation.check_covariance(cov) 51 | 52 | if pi is None: 53 | pi = np.array([0.0] * self.n) 54 | validation.check_expected_return(pi, self.n) 55 | self.__pi = tools.to_column_matrix(pi) 56 | 57 | self.__cov = np.array(cov) 58 | self.lambda_star = np.nan 59 | 60 | @abstractmethod 61 | def solve(self): 62 | """Solve the problem.""" 63 | pass 64 | 65 | @abstractmethod 66 | def get_risk_contributions(self): 67 | """Get the risk contribution of the Risk Budgeting Allocation.""" 68 | pass 69 | 70 | def get_variance(self): 71 | """Get the portfolio variance: x.T * cov * x.""" 72 | x = self.x 73 | cov = self.cov 74 | x = tools.to_column_matrix(x) 75 | cov = np.matrix(cov) 76 | RC = np.multiply(x, cov * x) 77 | return np.sum(tools.to_array(RC)) 78 | 79 | def get_volatility(self): 80 | """Get the portfolio volatility: x.T * cov * x.""" 81 | return self.get_variance() ** 0.5 82 | 83 | def get_expected_return(self): 84 | """Get the portfolio expected excess returns: x.T * pi.""" 85 | if self.pi is None: 86 | return np.nan 87 | else: 88 | x = self.x 89 | x = tools.to_column_matrix(x) 90 | return float(x.T * self.pi) 91 | 92 | def __str__(self): 93 | return ( 94 | "solution x: {}\n" 95 | "lambda star: {}\n" 96 | "risk contributions: {}\n" 97 | "sigma(x): {}\n" 98 | "sum(x): {}\n" 99 | ).format( 100 | np.round(self.x * 100, 4), 101 | np.round(self.lambda_star * 100, 4), 102 | np.round(self.get_risk_contributions() * 100, 4), 103 | np.round(self.get_volatility() * 100, 4), 104 | np.round(self.x.sum() * 100, 4), 105 | ) 106 | 107 | 108 | class EqualRiskContribution(RiskBudgetAllocation): 109 | def __init__(self, cov): 110 | """ 111 | Solve the equal risk contribution problem using cyclical coordinate descent. Although this does not change 112 | the optimal solution, the risk measure considered is the portfolio volatility. 113 | 114 | Parameters 115 | ---------- 116 | cov : array, shape (n, n) 117 | Covariance matrix of the returns. 118 | 119 | """ 120 | 121 | RiskBudgetAllocation.__init__(self, cov) 122 | 123 | def solve(self): 124 | x = solve_rb_ccd(cov=self.cov) 125 | self._x = tools.to_array(x / x.sum()) 126 | self.lambda_star = self.get_volatility() 127 | 128 | def get_risk_contributions(self, scale=True): 129 | x = self.x 130 | cov = self.cov 131 | x = tools.to_column_matrix(x) 132 | cov = np.matrix(cov) 133 | RC = np.multiply(x, cov * x) / self.get_volatility() 134 | if scale: 135 | RC = RC / RC.sum() 136 | return tools.to_array(RC) 137 | 138 | 139 | class RiskBudgeting(RiskBudgetAllocation): 140 | def __init__(self, cov, budgets): 141 | """ 142 | Solve the risk budgeting problem using cyclical coordinate descent. Although this does not change 143 | the optimal solution, the risk measure considered is the portfolio volatility. 144 | 145 | Parameters 146 | ---------- 147 | cov : array, shape (n, n) 148 | Covariance matrix of the returns. 149 | 150 | budgets : array, shape(n,) 151 | Risk budgets for each asset (the default is None which implies equal risk budget). 152 | 153 | """ 154 | RiskBudgetAllocation.__init__(self, cov=cov) 155 | validation.check_risk_budget(budgets, self.n) 156 | self.budgets = budgets 157 | 158 | def solve(self): 159 | x = solve_rb_ccd(cov=self.cov, budgets=self.budgets) 160 | self._x = tools.to_array(x / x.sum()) 161 | self.lambda_star = self.get_volatility() 162 | 163 | def get_risk_contributions(self, scale=True): 164 | x = self.x 165 | cov = self.cov 166 | x = tools.to_column_matrix(x) 167 | cov = np.matrix(cov) 168 | RC = np.multiply(x, cov * x) / self.get_volatility() 169 | if scale: 170 | RC = RC / RC.sum() 171 | return tools.to_array(RC) 172 | 173 | 174 | class RiskBudgetingWithER(RiskBudgetAllocation): 175 | def __init__(self, cov, budgets=None, pi=None, c=1): 176 | """ 177 | Solve the risk budgeting problem for the standard deviation risk measure using cyclical coordinate descent. 178 | The risk measure is given by R(x) = c * sqrt(x^T cov x) - pi^T x. 179 | 180 | Parameters 181 | ---------- 182 | cov : array, shape (n, n) 183 | Covariance matrix of the returns. 184 | 185 | budgets : array, shape(n,) 186 | Risk budgets for each asset (the default is None which implies equal risk budget). 187 | 188 | pi : array, shape(n,) 189 | Expected excess return for each asset (the default is None which implies 0 for each asset). 190 | 191 | c : float 192 | Risk aversion parameter equals to one by default. 193 | """ 194 | RiskBudgetAllocation.__init__(self, cov=cov, pi=pi) 195 | validation.check_risk_budget(budgets, self.n) 196 | self.budgets = budgets 197 | self.c = c 198 | 199 | def solve(self): 200 | x = solve_rb_ccd(cov=self.cov, budgets=self.budgets, pi=self.pi, c=self.c) 201 | self._x = tools.to_array(x / x.sum()) 202 | self.lambda_star = -self.get_expected_return() + self.get_volatility() * self.c 203 | 204 | def get_risk_contributions(self, scale=True): 205 | x = self.x 206 | cov = self.cov 207 | x = tools.to_column_matrix(x) 208 | cov = np.matrix(cov) 209 | RC = np.multiply(x, cov * x) / self.get_volatility() * self.c - self.x * self.pi 210 | if scale: 211 | RC = RC / RC.sum() 212 | return tools.to_array(RC) 213 | 214 | def __str__(self): 215 | return super().__str__() + "mu(x): {}\n".format( 216 | np.round(self.get_expected_return() * 100, 4) 217 | ) 218 | 219 | 220 | class ConstrainedRiskBudgeting(RiskBudgetingWithER): 221 | def __init__( 222 | self, 223 | cov, 224 | budgets=None, 225 | pi=None, 226 | c=1, 227 | C=None, 228 | d=None, 229 | bounds=None, 230 | solver="admm_ccd", 231 | ): 232 | """ 233 | Solve the constrained risk budgeting problem. It supports linear inequality (Cx <= d) and bounds constraints. 234 | Notations follow the paper Constrained Risk Budgeting Portfolios by Richard J-C. and Roncalli T. (2019). 235 | 236 | Parameters 237 | ---------- 238 | cov : array, shape (n, n) 239 | Covariance matrix of the returns. 240 | 241 | budgets : array, shape (n,) 242 | Risk budgets for each asset (the default is None which implies equal risk budget). 243 | 244 | pi : array, shape (n,) 245 | Expected excess return for each asset (the default is None which implies 0 for each asset). 246 | 247 | c : float 248 | Risk aversion parameter equals to one by default. 249 | 250 | C : array, shape (p, n) 251 | Array of p inequality constraints. If None the problem is unconstrained and solved using CCD 252 | (algorithm 3) and it solves equation (17). 253 | 254 | d : array, shape (p,) 255 | Array of p constraints that matches the inequalities. 256 | 257 | bounds : array, shape (n, 2) 258 | Array of minimum and maximum bounds. If None the default bounds are [0,1]. 259 | 260 | solver : basestring 261 | "admm_ccd" (default): generalized standard deviation-based risk measure + linear constraints. The algorithm is ADMM_CCD (algorithm 4) and it solves equation (14). 262 | "admm_qp" : mean variance risk measure + linear constraints. The algorithm is ADMM_QP and it solves equation (15). 263 | 264 | """ 265 | 266 | RiskBudgetingWithER.__init__(self, cov=cov, budgets=budgets, pi=pi, c=c) 267 | 268 | self.d = d 269 | self.C = C 270 | self.bounds = bounds 271 | validation.check_bounds(bounds, self.n) 272 | validation.check_constraints(C, d, self.n) 273 | self.solver = solver 274 | if (self.solver == "admm_qp") and (self.pi is not None): 275 | logging.warning( 276 | "The solver is set to 'admm_qp'. The risk measure is the mean variance in this case. The optimal " 277 | "solution will not be the same than 'admm_ccd' when pi is not zero. " 278 | ) 279 | 280 | def __str__(self): 281 | if self.C is not None: 282 | return ( 283 | "solver: {}\n".format(self.solver) 284 | + "----------------------------\n" 285 | + super().__str__() 286 | + "C@x: {}\n".format(self.C @ self.x) 287 | ) 288 | else: 289 | return super().__str__() 290 | 291 | def _sum_to_one_constraint(self, lamdba): 292 | x = self._lambda_solve(lamdba) 293 | sum_x = sum(x) 294 | return sum_x - 1 295 | 296 | def _lambda_solve(self, lamdba): 297 | if ( 298 | self.C is None 299 | ): # it is optimal to take the CCD in case of separable constraints 300 | x = solve_rb_ccd( 301 | self.cov, self.budgets, self.pi, self.c, self.bounds, lamdba 302 | ) 303 | self.solver = "ccd" 304 | elif self.solver == "admm_qp": 305 | x = solve_rb_admm_qp( 306 | cov=self.cov, 307 | budgets=self.budgets, 308 | pi=self.pi, 309 | c=self.c, 310 | C=self.C, 311 | d=self.d, 312 | bounds=self.bounds, 313 | lambda_log=lamdba, 314 | ) 315 | elif self.solver == "admm_ccd": 316 | x = solve_rb_admm_ccd( 317 | cov=self.cov, 318 | budgets=self.budgets, 319 | pi=self.pi, 320 | c=self.c, 321 | C=self.C, 322 | d=self.d, 323 | bounds=self.bounds, 324 | lambda_log=lamdba, 325 | ) 326 | return x 327 | 328 | def solve(self): 329 | try: 330 | lambda_star = optimize.bisect( 331 | self._sum_to_one_constraint, 332 | 0, 333 | BISECTION_UPPER_BOUND, 334 | maxiter=MAXITER_BISECTION, 335 | ) 336 | self.lambda_star = lambda_star 337 | self._x = self._lambda_solve(lambda_star) 338 | except Exception as e: 339 | if e.args[0] == "f(a) and f(b) must have different signs": 340 | logging.exception( 341 | "Bisection failed: " 342 | + str(e) 343 | + ". If you are using expected returns the parameter 'c' need to be correctly scaled (see remark 1 in the paper). Otherwise please check the constraints or increase the bisection upper bound." 344 | ) 345 | else: 346 | logging.exception("Problem not solved: " + str(e)) 347 | 348 | def get_risk_contributions(self, scale=True): 349 | """ 350 | Return the risk contribution. If the solver is "admm_qp" the mean variance risk 351 | measure is considered. 352 | 353 | Parameters 354 | ---------- 355 | scale : bool 356 | If True, the sum on risk contribution is scaled to one. 357 | 358 | Returns 359 | ------- 360 | 361 | RC : array, shape (n,) 362 | Returns the risk contribution of each asset. 363 | 364 | """ 365 | x = self.x 366 | cov = self.cov 367 | x = tools.to_column_matrix(x) 368 | cov = np.matrix(cov) 369 | 370 | if self.solver == "admm_qp": 371 | RC = np.multiply(x, cov * x) - self.c * self.x * self.pi 372 | else: 373 | RC = np.multiply( 374 | x, cov * x 375 | ).T / self.get_volatility() * self.c - tools.to_array( 376 | self.x.T 377 | ) * tools.to_array( 378 | self.pi 379 | ) 380 | if scale: 381 | RC = RC / RC.sum() 382 | 383 | return tools.to_array(RC) 384 | -------------------------------------------------------------------------------- /pyrb/settings.py: -------------------------------------------------------------------------------- 1 | # algorithm tolerance 2 | CCD_COVERGENCE_TOL = 1e-10 3 | BISECTION_TOL = 1e-5 4 | ADMM_TOL = 1e-10 5 | MAX_ITER = 5000 6 | BISECTION_UPPER_BOUND = 10 7 | MAXITER_BISECTION = 5000 8 | 9 | # bounds 10 | MIN_WEIGHT = 0 11 | MAX_WEIGHT = 1e3 12 | RISK_BUDGET_TOL = 0.00001 13 | -------------------------------------------------------------------------------- /pyrb/solvers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numba 4 | import numpy as np 5 | 6 | from . import tools 7 | from .settings import CCD_COVERGENCE_TOL, MAX_ITER, MAX_WEIGHT, ADMM_TOL 8 | 9 | 10 | @numba.njit 11 | def accelarate(_varphi, r, s, u, alpha=10, tau=2): 12 | """ 13 | Update varphy and dual error for accelerating convergence after ADMM steps. 14 | 15 | Parameters 16 | ---------- 17 | _varphi 18 | r: primal_error. 19 | s: dual error. 20 | u: primal_error. 21 | alpha: error treshld. 22 | tau: scaling parameter. 23 | 24 | Returns 25 | ------- 26 | updated varphy and primal_error. 27 | """ 28 | 29 | primal_error = np.sum(r ** 2) 30 | dual_error = np.sum(s * s) 31 | if primal_error > alpha * dual_error: 32 | _varphi = _varphi * tau 33 | u = u / tau 34 | elif dual_error > alpha * primal_error: 35 | _varphi = _varphi / tau 36 | u = u * tau 37 | return _varphi, u 38 | 39 | 40 | @numba.jit('Tuple((float64[:], float64[:], float64))(float64[:], float64, float64[:], float64, float64, float64[:], float64[:], float64[:], float64[:,:], float64, float64[:,:])', 41 | nopython=True) 42 | def _cycle(x, c, var, _varphi, sigma_x, Sx, budgets, pi, bounds, lambda_log, cov): 43 | """ 44 | Internal numba function for computing one cycle of the CCD algorithm. 45 | 46 | """ 47 | n = len(x) 48 | for i in range(n): 49 | alpha = c * var[i] + _varphi * sigma_x 50 | beta = c * (Sx[i] - x[i] * var[i]) - pi[i] * sigma_x 51 | gamma_ = -lambda_log * budgets[i] * sigma_x 52 | x_tilde = (-beta + np.sqrt(beta ** 2 - 4 * alpha * gamma_)) / (2 * alpha) 53 | 54 | x_tilde = np.maximum(np.minimum(x_tilde, bounds[i, 1]), bounds[i, 0]) 55 | 56 | x[i] = x_tilde 57 | Sx = np.dot(cov, x) 58 | sigma_x = np.sqrt(np.dot(Sx, x)) 59 | return x, Sx, sigma_x 60 | 61 | 62 | def solve_rb_ccd( 63 | cov, budgets=None, pi=None, c=1.0, bounds=None, lambda_log=1.0, _varphi=0.0 64 | ): 65 | """ 66 | Solve the risk budgeting problem for standard deviation risk-based measure with bounds constraints using cyclical 67 | coordinate descent (CCD). It is corresponding to solve equation (17) in the paper. 68 | 69 | By default the function solve the ERC portfolio or the RB portfolio if budgets are given. 70 | 71 | Parameters 72 | ---------- 73 | cov : array, shape (n, n) 74 | Covariance matrix of the returns. 75 | 76 | budgets : array, shape (n,) 77 | Risk budgets for each asset (the default is None which implies equal risk budget). 78 | 79 | pi : array, shape (n,) 80 | Expected excess return for each asset (the default is None which implies 0 for each asset). 81 | 82 | c : float 83 | Risk aversion parameter equals to one by default. 84 | 85 | bounds : array, shape (n, 2) 86 | Array of minimum and maximum bounds. If None the default bounds are [0,1]. 87 | 88 | lambda_log : float 89 | Log penalty parameter. 90 | 91 | _varphi : float 92 | This parameters is only useful for solving ADMM-CCD algorithm should be zeros otherwise. 93 | 94 | Returns 95 | ------- 96 | x : aray shape(n,) 97 | The array of optimal solution. 98 | 99 | """ 100 | 101 | n = cov.shape[0] 102 | 103 | if bounds is None: 104 | bounds = np.zeros((n, 2)) 105 | bounds[:, 1] = MAX_WEIGHT 106 | else: 107 | bounds = np.array(bounds * 1.0) 108 | 109 | if budgets is None: 110 | budgets = np.array([1.0] * n) / n 111 | else: 112 | budgets = np.array(budgets) 113 | budgets = budgets / np.sum(budgets) 114 | 115 | if (c is None) | (pi is None): 116 | c = 1.0 117 | pi = np.array([0.0] * n) 118 | else: 119 | c = float(c) 120 | pi = np.array(pi).astype(float) 121 | 122 | # initial value equals to 1/vol portfolio 123 | x = 1 / np.diag(cov) ** 0.5 / (np.sum(1 / np.diag(cov) ** 0.5)) 124 | x0 = x / 100 125 | 126 | budgets = tools.to_array(budgets) 127 | pi = tools.to_array(pi) 128 | var = np.array(np.diag(cov)) 129 | Sx = tools.to_array(np.dot(cov, x)) 130 | sigma_x = np.sqrt(np.dot(Sx, x)) 131 | 132 | cvg = False 133 | iters = 0 134 | 135 | while not cvg: 136 | x, Sx, sigma_x = _cycle( 137 | x, c, var, _varphi, sigma_x, Sx, budgets, pi, bounds, lambda_log, cov 138 | ) 139 | cvg = np.sum(np.array(x - x0) ** 2) <= CCD_COVERGENCE_TOL 140 | x0 = x.copy() 141 | iters = iters + 1 142 | if iters >= MAX_ITER: 143 | logging.info( 144 | "Maximum iteration reached during the CCD descent: {}".format(MAX_ITER) 145 | ) 146 | break 147 | 148 | return tools.to_array(x) 149 | 150 | 151 | def solve_rb_admm_qp( 152 | cov, 153 | budgets=None, 154 | pi=None, 155 | c=None, 156 | C=None, 157 | d=None, 158 | bounds=None, 159 | lambda_log=1, 160 | _varphi=1, 161 | ): 162 | """ 163 | Solve the constrained risk budgeting constraint for the Mean Variance risk measure: 164 | The risk measure is given by R(x) = x^T cov x - c * pi^T x 165 | 166 | Parameters 167 | ---------- 168 | cov : array, shape (n, n) 169 | Covariance matrix of the returns. 170 | 171 | budgets : array, shape (n,) 172 | Risk budgets for each asset (the default is None which implies equal risk budget). 173 | 174 | pi : array, shape (n,) 175 | Expected excess return for each asset (the default is None which implies 0 for each asset). 176 | 177 | c : float 178 | Risk aversion parameter equals to one by default. 179 | 180 | C : array, shape (p, n) 181 | Array of p inequality constraints. If None the problem is unconstrained and solved using CCD 182 | (algorithm 3) and it solves equation (17). 183 | 184 | d : array, shape (p,) 185 | Array of p constraints that matches the inequalities. 186 | 187 | bounds : array, shape (n, 2) 188 | Array of minimum and maximum bounds. If None the default bounds are [0,1]. 189 | 190 | lambda_log : float 191 | Log penalty parameter. 192 | 193 | _varphi : float 194 | This parameters is only useful for solving ADMM-CCD algorithm should be zeros otherwise. 195 | 196 | Returns 197 | ------- 198 | x : aray shape(n,) 199 | The array of optimal solution. 200 | 201 | """ 202 | 203 | def proximal_log(a, b, c, budgets): 204 | delta = b * b - 4 * a * c * budgets 205 | x = (b + np.sqrt(delta)) / (2 * a) 206 | return x 207 | 208 | cov = np.array(cov) 209 | n = np.shape(cov)[0] 210 | 211 | if bounds is None: 212 | bounds = np.zeros((n, 2)) 213 | bounds[:, 1] = MAX_WEIGHT 214 | else: 215 | bounds = np.array(bounds * 1.0) 216 | 217 | if budgets is None: 218 | budgets = np.array([1.0 / n] * n) 219 | 220 | x0 = 1 / np.diag(cov) / (np.sum(1 / np.diag(cov))) 221 | 222 | x = x0 / 100 223 | z = x.copy() 224 | zprev = z 225 | u = np.zeros(len(x)) 226 | cvg = False 227 | iters = 0 228 | pi_vec = tools.to_array(pi) 229 | identity_matrix = np.identity(n) 230 | 231 | while not cvg: 232 | 233 | # x-update 234 | x = tools.quadprog_solve_qp( 235 | cov + _varphi * identity_matrix, 236 | c * pi_vec + _varphi * (z - u), 237 | G=C, 238 | h=d, 239 | bounds=bounds, 240 | ) 241 | 242 | # z-update 243 | z = proximal_log(_varphi, (x + u) * _varphi, -lambda_log, budgets) 244 | 245 | # u-update 246 | r = x - z 247 | s = _varphi * (z - zprev) 248 | u += x - z 249 | 250 | # convergence check 251 | cvg1 = sum((x - x0) ** 2) 252 | cvg2 = sum((x - z) ** 2) 253 | cvg3 = sum((z - zprev) ** 2) 254 | cvg = np.max([cvg1, cvg2, cvg3]) <= ADMM_TOL 255 | x0 = x.copy() 256 | zprev = z 257 | 258 | iters = iters + 1 259 | if iters >= MAX_ITER: 260 | logging.info("Maximum iteration reached: {}".format(MAX_ITER)) 261 | break 262 | 263 | # parameters update 264 | _varphi, u = accelarate(_varphi, r, s, u) 265 | 266 | return tools.to_array(x) 267 | 268 | 269 | def solve_rb_admm_ccd( 270 | cov, 271 | budgets=None, 272 | pi=None, 273 | c=None, 274 | C=None, 275 | d=None, 276 | bounds=None, 277 | lambda_log=1, 278 | _varphi=1, 279 | ): 280 | """ 281 | Solve the constrained risk budgeting constraint for the standard deviation risk measure: 282 | The risk measure is given by R(x) = c * sqrt(x^T cov x) - pi^T x 283 | 284 | Parameters 285 | ---------- 286 | Parameters 287 | ---------- 288 | cov : array, shape (n, n) 289 | Covariance matrix of the returns. 290 | 291 | budgets : array, shape (n,) 292 | Risk budgets for each asset (the default is None which implies equal risk budget). 293 | 294 | pi : array, shape (n,) 295 | Expected excess return for each asset (the default is None which implies 0 for each asset). 296 | 297 | c : float 298 | Risk aversion parameter equals to one by default. 299 | 300 | C : array, shape (p, n) 301 | Array of p inequality constraints. If None the problem is unconstrained and solved using CCD 302 | (algorithm 3) and it solves equation (17). 303 | 304 | d : array, shape (p,) 305 | Array of p constraints that matches the inequalities. 306 | 307 | bounds : array, shape (n, 2) 308 | Array of minimum and maximum bounds. If None the default bounds are [0,1]. 309 | 310 | lambda_log : float 311 | Log penalty parameter. 312 | 313 | _varphi : float 314 | This parameters is only useful for solving ADMM-CCD algorithm should be zeros otherwise. 315 | 316 | Returns 317 | ------- 318 | x : aray shape(n,) 319 | The array of optimal solution. 320 | 321 | 322 | """ 323 | 324 | cov = np.array(cov) 325 | 326 | x0 = 1 / np.diag(cov) / (np.sum(1 / np.diag(cov))) 327 | 328 | x = x0 / 100 329 | z = x.copy() 330 | zprev = z 331 | u = np.zeros(len(x)) 332 | cvg = False 333 | iters = 0 334 | pi_vec = tools.to_array(pi) 335 | while not cvg: 336 | 337 | # x-update 338 | x = solve_rb_ccd( 339 | cov, 340 | budgets=budgets, 341 | pi=pi_vec + (_varphi * (z - u)), 342 | bounds=bounds, 343 | lambda_log=lambda_log, 344 | c=c, 345 | _varphi=_varphi, 346 | ) 347 | 348 | # z-update 349 | z = tools.proximal_polyhedra(x + u, C, d, A=None, b=None, bound=bounds) 350 | 351 | # u-update 352 | r = x - z 353 | s = _varphi * (z - zprev) 354 | u += x - z 355 | 356 | # convergence check 357 | cvg1 = sum((x - x0) ** 2) 358 | cvg2 = sum((x - z) ** 2) 359 | cvg3 = sum((z - zprev) ** 2) 360 | cvg = np.max([cvg1, cvg2, cvg3]) <= ADMM_TOL 361 | x0 = x.copy() 362 | zprev = z 363 | 364 | iters = iters + 1 365 | if iters >= MAX_ITER: 366 | logging.info("Maximum iteration reached: {}".format(MAX_ITER)) 367 | break 368 | 369 | # parameters update 370 | _varphi, u = accelarate(_varphi, r, s, u) 371 | 372 | return tools.to_array(x) 373 | -------------------------------------------------------------------------------- /pyrb/tools.py: -------------------------------------------------------------------------------- 1 | import quadprog 2 | 3 | import numpy as np 4 | 5 | 6 | def to_column_matrix(x): 7 | """Return x as a matrix columns.""" 8 | x = np.matrix(x) 9 | if x.shape[1] != 1: 10 | x = x.T 11 | if x.shape[1] == 1: 12 | return x 13 | else: 14 | raise ValueError("x is not a vector") 15 | 16 | 17 | def to_array(x): 18 | """Turn a columns or row matrix to an array.""" 19 | if x is None: 20 | return None 21 | elif (len(x.shape)) == 1: 22 | return x 23 | 24 | if x.shape[1] != 1: 25 | x = x.T 26 | return np.squeeze(np.asarray(x)) 27 | 28 | 29 | def quadprog_solve_qp(P, q, G=None, h=None, A=None, b=None, bounds=None): 30 | """Quadprog helper.""" 31 | n = P.shape[0] 32 | if bounds is not None: 33 | I = np.eye(n) 34 | LB = -I 35 | UB = I 36 | if G is None: 37 | G = np.vstack([LB, UB]) 38 | h = np.array(np.hstack([-to_array(bounds[:, 0]), to_array(bounds[:, 1])])) 39 | else: 40 | G = np.vstack([G, LB, UB]) 41 | h = np.array( 42 | np.hstack([h, -to_array(bounds[:, 0]), to_array(bounds[:, 1])]) 43 | ) 44 | 45 | qp_a = q # because 1/2 x^T G x - a^T x 46 | qp_G = P 47 | if A is not None: 48 | qp_C = -np.vstack([A, G]).T 49 | qp_b = -np.hstack([b, h]) 50 | meq = A.shape[0] 51 | else: # no equality constraints 52 | qp_C = -G.T 53 | qp_b = -h 54 | meq = 0 55 | return quadprog.solve_qp(qp_G, qp_a, qp_C, qp_b, meq)[0] 56 | 57 | 58 | def proximal_polyhedra(y, C, d, bound, A=None, b=None): 59 | """Wrapper for projecting a vector on the constrained set.""" 60 | n = len(y) 61 | return quadprog_solve_qp( 62 | np.eye(n), np.array(y), np.array(C), np.array(d), A=A, b=b, bounds=bound 63 | ) 64 | -------------------------------------------------------------------------------- /pyrb/validation.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from .settings import RISK_BUDGET_TOL 4 | 5 | 6 | def check_covariance(cov): 7 | if cov.shape[0] != cov.shape[1]: 8 | raise ValueError("The covariance matrix is not squared") 9 | if np.isnan(cov).sum().sum() > 0: 10 | raise ValueError("The covariance matrix contains missing values") 11 | 12 | 13 | def check_expected_return(mu, n): 14 | if mu is None: 15 | return 16 | if n != len(mu): 17 | raise ValueError( 18 | "Expected returns vector size is not equal to the number of asset." 19 | ) 20 | if np.isnan(mu).sum() > 0: 21 | raise ValueError("The expected returns vector contains missing values") 22 | 23 | 24 | def check_constraints(C, d, n): 25 | if C is None: 26 | return 27 | if n != C.shape[1]: 28 | raise ValueError("Number of columns of C is not equal to the number of asset.") 29 | if len(d) != C.shape[0]: 30 | raise ValueError("Number of rows of C is not equal to the length of d.") 31 | 32 | 33 | def check_bounds(bounds, n): 34 | if bounds is None: 35 | return 36 | if n != bounds.shape[0]: 37 | raise ValueError( 38 | "The number of rows of the bounds array is not equal to the number of asset." 39 | ) 40 | if 2 != bounds.shape[1]: 41 | raise ValueError( 42 | "The number of columns the bounds array should be equal to two (min and max bounds)." 43 | ) 44 | 45 | 46 | def check_risk_budget(riskbudgets, n): 47 | if riskbudgets is None: 48 | return 49 | if np.isnan(riskbudgets).sum() > 0: 50 | raise ValueError("Risk budget contains missing values") 51 | if (np.array(riskbudgets) < 0).sum() > 0: 52 | raise ValueError("Risk budget contains negative values") 53 | if n != len(riskbudgets): 54 | raise ValueError("Risk budget size is not equal to the number of asset.") 55 | if all(v < RISK_BUDGET_TOL for v in riskbudgets): 56 | raise ValueError( 57 | "One of the budget is smaller than {}. If you want a risk budget of 0 please remove the asset.".format( 58 | RISK_BUDGET_TOL 59 | ) 60 | ) 61 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numba==0.51.0 2 | quadprog==0.1.7 3 | scipy==1.4.1 4 | llvmlite==0.34.0 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | DISTNAME = "pyrb" 4 | VERSION = "1.0.1" 5 | DESCRIPTION = ( 6 | """pyrb is a Python library to solve constrained risk budgeting problem.""" 7 | ) 8 | LONG_DESCRIPTION = ( 9 | """pyrb is a Python library to solve constrained risk budgeting problem.""" 10 | ) 11 | AUTHOR = "Jean-Charles Richard" 12 | AUTHOR_EMAIL = "jcharles.richard@gmail.com" 13 | URL = "https://github.com/jcrichard/pyrb" 14 | LICENSE = "Apache License, Version 2.0" 15 | 16 | REQUIREMENTS = ["pandas>=0.19", "numba>=0.4", "quadprog>=0.1.0"] 17 | 18 | if __name__ == "__main__": 19 | setup( 20 | name=DISTNAME, 21 | version=VERSION, 22 | description=DESCRIPTION, 23 | long_description=LONG_DESCRIPTION, 24 | author=AUTHOR, 25 | author_email=AUTHOR_EMAIL, 26 | url=URL, 27 | license=LICENSE, 28 | packages=find_packages(), 29 | package_data={"docs": ["*"]}, 30 | include_package_data=True, 31 | zip_safe=False, 32 | install_requires=REQUIREMENTS, 33 | classifiers=["Programming Language :: Python :: 3.4"], 34 | ) 35 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jcrichard/pyrb/250054efe02ce48cd1ae1ef72f8d808951af5a53/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_risk_budgeting.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pyrb.allocation import ( 3 | EqualRiskContribution, 4 | RiskBudgeting, 5 | ConstrainedRiskBudgeting, 6 | ) 7 | 8 | 9 | CORRELATIONMATRIX = np.array( 10 | [ 11 | [1, 0.1, 0.4, 0.5, 0.5], 12 | [0.1, 1, 0.7, 0.4, 0.4], 13 | [0.4, 0.7, 1, 0.8, 0.05], 14 | [0.5, 0.4, 0.8, 1, 0.1], 15 | [0.5, 0.4, 0.05, 0.1, 1], 16 | ] 17 | ) 18 | vol = [0.15, 0.20, 0.25, 0.3, 0.1] 19 | NUMBEROFASSET = len(vol) 20 | COVARIANCEMATRIX = CORRELATIONMATRIX * np.outer(vol, vol) 21 | RISKBUDGET = [0.2, 0.2, 0.3, 0.1, 0.2] 22 | BOUNDS = np.array([[0.2, 0.3], [0.2, 0.3], [0.05, 0.15], [0.05, 0.15], [0.25, 0.35]]) 23 | 24 | 25 | def test_erc(): 26 | ERC = EqualRiskContribution(COVARIANCEMATRIX) 27 | ERC.solve() 28 | np.testing.assert_almost_equal(np.sum(ERC.x), 1) 29 | np.testing.assert_almost_equal( 30 | np.dot(np.dot(ERC.x, COVARIANCEMATRIX), ERC.x) ** 0.5, 31 | ERC.get_risk_contributions(scale=False).sum(), 32 | decimal=10, 33 | ) 34 | np.testing.assert_equal( 35 | abs(ERC.get_risk_contributions().mean() - 1.0 / NUMBEROFASSET) < 1e-5, True 36 | ) 37 | 38 | 39 | def test_rb(): 40 | RB = RiskBudgeting(COVARIANCEMATRIX, RISKBUDGET) 41 | RB.solve() 42 | np.testing.assert_almost_equal(np.sum(RB.x), 1, decimal=5) 43 | np.testing.assert_almost_equal( 44 | np.dot(np.dot(RB.x, COVARIANCEMATRIX), RB.x) ** 0.5, 45 | RB.get_risk_contributions(scale=False).sum(), 46 | decimal=10, 47 | ) 48 | np.testing.assert_equal( 49 | abs(RB.get_risk_contributions() - RISKBUDGET).sum() < 1e-5, True 50 | ) 51 | 52 | 53 | def test_cerb(): 54 | CRB = ConstrainedRiskBudgeting( 55 | COVARIANCEMATRIX, budgets=None, pi=None, bounds=BOUNDS 56 | ) 57 | CRB.solve() 58 | np.testing.assert_almost_equal(np.sum(CRB.x), 1) 59 | np.testing.assert_almost_equal(CRB.get_risk_contributions()[1], 0.2455, decimal=5) 60 | np.testing.assert_almost_equal(np.sum(CRB.x[1]), 0.2) 61 | --------------------------------------------------------------------------------