├── .dockerignore ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── feature_request.md │ ├── help-needed.md │ ├── installation-error.md │ └── workflows │ │ └── main.yml └── workflows │ ├── codecov.yml │ └── main.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── cookbook ├── 1-RiskReturnModels.ipynb ├── 2-Mean-Variance-Optimisation.ipynb ├── 3-Advanced-Mean-Variance-Optimisation.ipynb ├── 4-Black-Litterman-Allocation.ipynb ├── 5-Hierarchical-Risk-Parity.ipynb └── data │ ├── spy_prices.csv │ └── stock_prices.csv ├── docker └── Dockerfile ├── docs ├── About.rst ├── BlackLitterman.rst ├── Citing.rst ├── Contributing.rst ├── ExpectedReturns.rst ├── FAQ.rst ├── GeneralEfficientFrontier.rst ├── Makefile ├── MeanVariance.rst ├── OtherOptimizers.rst ├── Plotting.rst ├── Postprocessing.rst ├── RiskModels.rst ├── Roadmap.rst ├── UserGuide.rst ├── conf.py └── index.rst ├── example └── examples.py ├── media ├── cla_plot.png ├── conceptual_flowchart_v1-grey.png ├── conceptual_flowchart_v1.png ├── conceptual_flowchart_v2-grey.png ├── conceptual_flowchart_v2.png ├── corrplot.png ├── corrplot_white.png ├── dendrogram.png ├── ef_plot.png ├── ef_scatter.png ├── efficient_frontier.png ├── efficient_frontier_white.png ├── logo_v0.png ├── logo_v1-grey.png ├── logo_v1.png └── weight_plot.png ├── poetry.lock ├── pypfopt ├── __init__.py ├── base_optimizer.py ├── black_litterman.py ├── cla.py ├── discrete_allocation.py ├── efficient_frontier │ ├── __init__.py │ ├── efficient_cdar.py │ ├── efficient_cvar.py │ ├── efficient_frontier.py │ └── efficient_semivariance.py ├── exceptions.py ├── expected_returns.py ├── hierarchical_portfolio.py ├── objective_functions.py ├── plotting.py └── risk_models.py ├── pyproject.toml ├── readthedocs.yml ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── resources ├── cov_matrix.csv ├── spy_prices.csv ├── stock_prices.csv └── weights_hrp.csv ├── test_base_optimizer.py ├── test_black_litterman.py ├── test_cla.py ├── test_custom_objectives.py ├── test_discrete_allocation.py ├── test_efficient_cdar.py ├── test_efficient_cvar.py ├── test_efficient_frontier.py ├── test_efficient_semivariance.py ├── test_expected_returns.py ├── test_hrp.py ├── test_imports.py ├── test_objective_functions.py ├── test_plotting.py ├── test_risk_models.py └── utilities_for_tests.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Environments 2 | .env 3 | .venv 4 | env/ 5 | venv/ 6 | ENV/ 7 | env.bak/ 8 | venv.bak/ 9 | 10 | */__pycache__ 11 | */*.pyc -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | extend-ignore = E203 4 | per-file-ignores = 5 | tests/test_imports.py:F401 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Something isn't working 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **Code sample** 17 | Add a minimal reproducible example (see [here](https://stackoverflow.com/help/minimal-reproducible-example)). 18 | 19 | **Operating system, python version, PyPortfolioOpt version** 20 | e.g MacOS 10.146, python 3.7.3, PyPortfolioOpt 1.2.6 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'Feature request: [your feature]' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem?** 11 | A clear and concise description of what the problem is. 12 | 13 | **Describe the feature you'd like** 14 | A clear description of the feature you want, or a link to the textbook/article describing the feature. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help-needed.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Help needed 3 | about: If you have a question about how to use PyPortfolioOpt 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What are you trying to do?** 11 | Clear description of the problem you are trying to solve with PyPortfolioOpt 12 | 13 | **What have you tried?** 14 | 15 | **What data are you using?** 16 | What asset class, how many assets, how many data points. Preferably provide a sample of the dataset as a csv attachment. 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/installation-error.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Installation Error 3 | about: If you are having trouble installing 4 | title: e.g Could not install on Windows Anaconda [replace with your environment] 5 | labels: packaging 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Operating system, environment, python version** 11 | e.g Windows, anaconda, python3.7 12 | 13 | **What you tried** 14 | pip install pyportfolioopt 15 | 16 | **Error message** 17 | 18 | ``` 19 | Copy paste the terminal message inside the backticks. 20 | ``` 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: pytest 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | pytest: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: [3.6, 3.7, 3.8] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Get full python version 21 | id: full-python-version 22 | run: | 23 | echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info[:3]))") 24 | - name: Install Poetry 25 | uses: snok/install-poetry@v1.1.1 26 | with: 27 | virtualenvs-create: true 28 | virtualenvs-in-project: true 29 | - name: Set up cache 30 | id: cached-poetry-dependencies 31 | uses: actions/cache@v2 32 | with: 33 | path: .venv 34 | key: venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} 35 | - name: Install dependencies 36 | run: poetry install -E optionals 37 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 38 | - name: Run tests 39 | run: poetry run pytest 40 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: codecov 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | codecov: 10 | name: py${{ matrix.python-version }} on ${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | env: 13 | MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | python-version: ["3.12"] 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | cache: "pip" 25 | - name: Install dependencies 26 | run: | 27 | pip install -r requirements.txt 28 | pip install ecos 29 | - name: Generate coverage report 30 | run: | 31 | pip install pytest pytest-cov 32 | pytest --cov=./ --cov-report=xml 33 | - name: Upload coverage to Codecov 34 | uses: codecov/codecov-action@v3 35 | with: 36 | files: ./coverage.xml 37 | fail_ci_if_error: true 38 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: pytest 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | pytest: 14 | name: py${{ matrix.python-version }} on ${{ matrix.os }} 15 | runs-on: ${{ matrix.os }} 16 | env: 17 | MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest, macos-latest, windows-latest] 21 | python-version: ["3.9", "3.10", "3.11", "3.12"] 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | cache: "pip" 30 | - name: Install dependencies 31 | run: | 32 | pip install -r requirements.txt 33 | pip install pytest isort black flake8 34 | pip install ecos 35 | - name: Test with pytest 36 | run: | 37 | pytest ./tests 38 | - name: Check with isort 39 | run: | 40 | isort --check --diff . 41 | - name: Check with black 42 | run: | 43 | black --check --diff . 44 | - name: Check with flake8 45 | run: | 46 | flake8 --show-source --statistics . 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | *.pyc 3 | .idea 4 | build 5 | sandbox 6 | .DS_Store 7 | 8 | tests/dockerfiles/ 9 | .tox/ 10 | docs/_build/ 11 | media/logo_design/ 12 | DEV/ 13 | .pytest_cache/ 14 | .vscode/ 15 | 16 | # Environments 17 | .env 18 | .venv 19 | env/ 20 | venv/ 21 | ENV/ 22 | env.bak/ 23 | venv.bak/ 24 | 25 | pip-selfcheck.json 26 | 27 | .coverage 28 | .coverage* 29 | 30 | html-coverage 31 | html-report 32 | 33 | .~lock.* 34 | *.dot 35 | *.svg 36 | 37 | 38 | dist 39 | *egg-info 40 | 41 | */.ipynb_checkpoints 42 | 43 | */__pycache__ 44 | */*.pyc 45 | .ipynb_checkpoints 46 | 47 | .eggs 48 | 49 | artifacts 50 | 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to PyPortfolioOpt 2 | 3 | Please refer to the roadmap for a list of areas that I think PyPortfolioOpt could really benefit 4 | from. In addition, the following is always welcome:: 5 | 6 | - Improve performance of existing code (but not at the cost of readability) – are there any nice numpy tricks I've missed? 7 | - Add new optimization objective functions. For example, if you think that the best performance metric has not been included, write it into a function (or suggest it in [Issues](https://github.com/robertmartin8/PyPortfolioOpt/issues) and I will have a go). 8 | - Help me write more tests! If you are someone learning about quant finance and/or unit testing in python, what better way to practice than to write some tests on an open-source project! Feel free to check for edge cases, or test performance on a dataset with more stocks. 9 | 10 | ## Guidelines 11 | 12 | ### Seek early feedback 13 | 14 | Before you start coding your contribution, it may be wise to raise an issue on GitHub to discuss whether the contribution is appropriate for the project. 15 | 16 | I care a lot about having a clean API, so I am unlikely to accept a random PR that significantly complicates the API (or the dependencies). 17 | 18 | ### Code style 19 | 20 | For this project I have used [Black](https://github.com/ambv/black) as the formatting standard, with all of the default arguments. I recommend that PRs use black. 21 | 22 | ### Testing 23 | 24 | Any contributions **must** be accompanied by unit tests (written with `pytest`). These are incredibly simple to write, just find the relevant test file (or create a new one), and write a bunch of `assert` statements. The test should be applied to the dummy dataset I have provided in `tests/stock_prices.csv`, and should cover core functionality, warnings/errors (check that they are raised as expected), and limiting behaviour or edge cases. 25 | 26 | ### Documentation 27 | 28 | Inline comments (and docstrings!) are great when needed, but don't go overboard. A lot of the explanation can and should be offloaded to ReadTheDocs. Docstrings should follow [PEP257](https://stackoverflow.com/questions/2557110/what-to-put-in-a-python-module-docstring) semantically and sphinx syntactically. 29 | 30 | I would appreciate if changes are accompanied by relevant documentation – it doesn't have to be pretty, because I will probably try to tidy it up before it goes onto ReadTheDocs, but it'd make things a lot simpler to have the person who wrote the code explain it in their own words. 31 | 32 | ## Questions 33 | 34 | If you have any questions related to the project, it is probably easiest to [raise an issue](https://github.com/robertmartin8/PyPortfolioOpt/issues), and I will tag it as a question. 35 | 36 | If you have questions unrelated to the project, drop me an email – contact details can be found on my [website](https://reasonabledeviations.com/about/) 37 | 38 | ## Bugs/issues 39 | 40 | If you find any bugs or the portfolio optimization is not working as expected, feel free to [raise an issue](https://github.com/robertmartin8/PyPortfolioOpt/issues). I would ask that you provide the following information in the issue: 41 | 42 | - Descriptive title so that other users can see the existing issues 43 | - Operating system, python version, and python distribution (optional). 44 | - Minimal example for reproducing the issue. 45 | - What you expected to happen 46 | - What actually happened 47 | - A full traceback of the error message (omit personal details as you see fit). 48 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Robert Andrew Martin 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 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim-buster 2 | 3 | WORKDIR pypfopt 4 | COPY requirements.txt . 5 | 6 | RUN pip install --upgrade pip \ 7 | pip install yfinance && \ 8 | pip install poetry \ 9 | pip install ipython \ 10 | pip install jupyter \ 11 | pip install pytest \ 12 | pip install -r requirements.txt 13 | 14 | COPY . . 15 | 16 | RUN cd cookbook 17 | 18 | # Usage examples: 19 | # 20 | # Build 21 | # from root of repo: 22 | # docker build -f docker/Dockerfile . -t pypfopt 23 | # 24 | # Run 25 | # iPython interpreter: 26 | # docker run -it pypfopt poetry run ipython 27 | # Jupyter notebook server: 28 | # docker run -it -p 8888:8888 pypfopt poetry run jupyter notebook --allow-root --no-browser --ip 0.0.0.0 29 | # click on http://127.0.0.1:8888/?token=xxx 30 | # Pytest 31 | # docker run -t pypfopt poetry run pytest 32 | # Bash 33 | # docker run -it pypfopt bash 34 | -------------------------------------------------------------------------------- /docs/About.rst: -------------------------------------------------------------------------------- 1 | ##### 2 | About 3 | ##### 4 | 5 | I'm Robert, a Natural Sciences undergraduate at the University of Cambridge. I am interested 6 | in a broad range of quantitative topics, including physics, statistics, finance and 7 | computer science (and the intersection between them). For more about me, please head 8 | over to my `website `_. 9 | 10 | I learn fastest when making real projects. In early 2018 I began seriously trying 11 | to self-educate on certain topics in quantitative finance, and mean-variance 12 | optimization is one of the cornerstones of this field. I read quite a few journal 13 | articles and explanations but ultimately felt that a real proof of understanding would 14 | lie in the implementation. At the same time, I realised that existing open-source 15 | (python) portfolio optimization libraries (there are one or two), were unsatisfactory 16 | for several reasons, and that people 'out there' might benefit from a 17 | well-documented and intuitive API. This is what motivated the development of 18 | PyPortfolioOpt. 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/Citing.rst: -------------------------------------------------------------------------------- 1 | ##################### 2 | Citing PyPortfolioOpt 3 | ##################### 4 | 5 | If you use PyPortfolioOpt for published work, please cite the `JOSS paper `_. 6 | 7 | Citation string:: 8 | 9 | Martin, R. A., (2021). PyPortfolioOpt: portfolio optimization in Python. Journal of Open Source Software, 6(61), 3066, https://doi.org/10.21105/joss.03066 10 | 11 | BibTex:: 12 | 13 | @article{Martin2021, 14 | doi = {10.21105/joss.03066}, 15 | url = {https://doi.org/10.21105/joss.03066}, 16 | year = {2021}, 17 | publisher = {The Open Journal}, 18 | volume = {6}, 19 | number = {61}, 20 | pages = {3066}, 21 | author = {Robert Andrew Martin}, 22 | title = {PyPortfolioOpt: portfolio optimization in Python}, 23 | journal = {Journal of Open Source Software} 24 | } 25 | -------------------------------------------------------------------------------- /docs/Contributing.rst: -------------------------------------------------------------------------------- 1 | ############ 2 | Contributing 3 | ############ 4 | 5 | Some of the things that I'd love for people to help with: 6 | 7 | - Improve performance of existing code (but not at the cost of readability) 8 | - Add new optimization objectives. For example, if you would like to use something other 9 | than the Sharpe ratio, write an optimizer! (or suggest it in 10 | `Issues `_ and I will have a go). 11 | - Help me write more tests! If you are someone learning about quant finance and/or unit 12 | testing in python, what better way to practice than to write some tests on an 13 | open-source project! Feel free to check for edge cases, or for uncommon parameter 14 | combinations which may cause silent errors. 15 | 16 | 17 | Guidelines 18 | ========== 19 | 20 | Seek early feedback 21 | ------------------- 22 | 23 | Before you start coding your contribution, it may be wise to 24 | `raise an issue `_ on 25 | GitHub to discuss whether the contribution is appropriate for the project. 26 | 27 | Code style 28 | ---------- 29 | 30 | For this project I have used `Black `_ as the 31 | formatting standard, with all of the default settings. It would be much 32 | appreciated if any PRs follow this standard because if not I will have to 33 | format before merging. 34 | 35 | Testing 36 | ------- 37 | 38 | Any contributions **must** be accompanied by unit tests (written with ``pytest``). 39 | These are incredibly simple to write, just find the relevant test file (or create 40 | a new one), and write a bunch of ``assert`` statements. The test should be applied 41 | to the dummy dataset I have provided in ``tests/stock_prices.csv``, and should 42 | cover core functionality, warnings/errors (check that they are raised as expected), 43 | and limiting behaviour or edge cases. 44 | 45 | Documentation 46 | ------------- 47 | 48 | Inline comments are great when needed, but don't go overboard. Docstring content 49 | should follow `PEP257 `_ 50 | semantically and sphinx syntactically, such that sphinx can automatically document 51 | the methods and their arguments. I am personally not a fan of writing long 52 | paragraphs in the docstrings: in my view, docstrings should state briefly how an 53 | object can be used, while the rest of the explanation and theoretical background 54 | should be offloaded to ReadTheDocs. 55 | 56 | I would appreciate if changes are accompanied by relevant documentation - it 57 | doesn't have to be pretty, because I will probably try to tidy it up before it 58 | goes onto ReadTheDocs, but it'd make things a lot simpler to have the person who 59 | wrote the code explain it in their own words. 60 | 61 | Questions 62 | ========= 63 | 64 | If you have any questions related to the project, it is probably best to 65 | `raise an issue `_ and 66 | I will tag it as a question. 67 | 68 | If you have questions *unrelated* to the project, drop me an email - contact 69 | details can be found on my `website `_. 70 | 71 | Bugs/issues 72 | =========== 73 | 74 | If you find any bugs or the portfolio optimization is not working as expected, 75 | feel free to `raise an issue `_. 76 | I would ask that you provide the following information in the issue: 77 | 78 | - Descriptive title so that other users can see the existing issues 79 | - Operating system, python version, and python distribution (optional). 80 | - Minimal example for reproducing the issue. 81 | - What you expected to happen 82 | - What actually happened 83 | - A full traceback of the error message (omit personal details as you see fit). 84 | -------------------------------------------------------------------------------- /docs/ExpectedReturns.rst: -------------------------------------------------------------------------------- 1 | .. _expected-returns: 2 | 3 | ################ 4 | Expected Returns 5 | ################ 6 | 7 | Mean-variance optimization requires knowledge of the expected returns. In practice, 8 | these are rather difficult to know with any certainty. Thus the best we can do is to 9 | come up with estimates, for example by extrapolating historical data, This is the 10 | main flaw in mean-variance optimization – the optimization procedure is sound, and provides 11 | strong mathematical guarantees, *given the correct inputs*. This is one of the reasons 12 | why I have emphasised modularity: users should be able to come up with their own 13 | superior models and feed them into the optimizer. 14 | 15 | .. caution:: 16 | 17 | Supplying expected returns can do more harm than good. If 18 | predicting stock returns were as easy as calculating the mean historical return, 19 | we'd all be rich! For most use-cases, I would suggest that you focus your efforts 20 | on choosing an appropriate risk model (see :ref:`risk-models`). 21 | 22 | As of v0.5.0, you can use :ref:`black-litterman` to significantly improve the quality of 23 | your estimate of the expected returns. 24 | 25 | .. automodule:: pypfopt.expected_returns 26 | 27 | .. note:: 28 | 29 | For any of these methods, if you would prefer to pass returns (the default is prices), 30 | set the boolean flag ``returns_data=True`` 31 | 32 | .. autofunction:: mean_historical_return 33 | 34 | This is probably the default textbook approach. It is intuitive and easily interpretable, 35 | however the estimates are subject to large uncertainty. This is a problem especially in the 36 | context of a mean-variance optimizer, which will maximise the erroneous inputs. 37 | 38 | 39 | .. autofunction:: ema_historical_return 40 | 41 | The exponential moving average is a simple improvement over the mean historical 42 | return; it gives more credence to recent returns and thus aims to increase the relevance 43 | of the estimates. This is parameterised by the ``span`` parameter, which gives users 44 | the ability to decide exactly how much more weight is given to recent data. 45 | Generally, I would err on the side of a higher span – in the limit, this tends towards 46 | the mean historical return. However, if you plan on rebalancing much more frequently, 47 | there is a case to be made for lowering the span in order to capture recent trends. 48 | 49 | .. autofunction:: capm_return 50 | 51 | .. autofunction:: returns_from_prices 52 | 53 | .. autofunction:: prices_from_returns 54 | 55 | 56 | .. References 57 | .. ========== 58 | -------------------------------------------------------------------------------- /docs/FAQ.rst: -------------------------------------------------------------------------------- 1 | .. _faq: 2 | 3 | #### 4 | FAQs 5 | #### 6 | 7 | Constraining a score 8 | -------------------- 9 | 10 | Suppose that for each asset you have some "score" – it could be an ESG metric, or some custom risk/return metric. It is simple to specify linear constraints, like "portfolio ESG score must be greater than x": you simply create 11 | a vector of scores, add a constraint on the dot product of those scores with the portfolio weights, then optimize your objective:: 12 | 13 | esg_scores = [0.3, 0.1, 0.4, 0.1, 0.5, 0.9, 0.2] 14 | portfolio_min_score = 0.5 15 | 16 | ef = EfficientFrontier(mu, S) 17 | ef.add_constraint(lambda w: esg_scores @ w >= portfolio_min_score) 18 | ef.min_volatility() 19 | 20 | 21 | Constraining the number of assets 22 | --------------------------------- 23 | 24 | Unfortunately, cardinality constraints are not convex, making them difficult to implement. 25 | 26 | However, we can treat it as a mixed-integer program and solve (provided you have access to a solver). 27 | for small problems with less than 1000 variables and constraints, you can use the community version of CPLEX: 28 | ``pip install cplex``. In the below example, we limit the portfolio to at most 10 assets:: 29 | 30 | import cvxpy as cp 31 | 32 | ef = EfficientFrontier(mu, S, solver=cp.CPLEX) 33 | booleans = cp.Variable(len(ef.tickers), boolean=True) 34 | ef.add_constraint(lambda x: x <= booleans) 35 | ef.add_constraint(lambda x: cp.sum(booleans) <= 10) 36 | ef.min_volatility() 37 | 38 | This does not play well with ``max_sharpe``, and needs to be modified for different bounds. 39 | See `this issue `_ for further discussion. 40 | 41 | Tracking error 42 | -------------- 43 | 44 | Tracking error can either be used as an objective (as described in :ref:`efficient-frontier`) or 45 | as a constraint. This is an example of adding a tracking error constraint:: 46 | 47 | from objective functions import ex_ante_tracking_error 48 | 49 | benchmark_weights = ... # benchmark 50 | 51 | ef = EfficientFrontier(mu, S) 52 | ef.add_constraint(ex_ante_tracking_error, cov_matrix=ef.cov_matrix, 53 | benchmark_weights=benchmark_weights) 54 | ef.min_volatility() 55 | 56 | 57 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | .PHONY: html 55 | html: 56 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | .PHONY: dirhtml 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | .PHONY: singlehtml 67 | singlehtml: 68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 69 | @echo 70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 71 | 72 | .PHONY: pickle 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | .PHONY: json 79 | json: 80 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 81 | @echo 82 | @echo "Build finished; now you can process the JSON files." 83 | 84 | .PHONY: htmlhelp 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | .PHONY: qthelp 92 | qthelp: 93 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 94 | @echo 95 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 96 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 97 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/package_template.qhcp" 98 | @echo "To view the help file:" 99 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/package_template.qhc" 100 | 101 | .PHONY: applehelp 102 | applehelp: 103 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 104 | @echo 105 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 106 | @echo "N.B. You won't be able to view it unless you put it in" \ 107 | "~/Library/Documentation/Help or install it in your application" \ 108 | "bundle." 109 | 110 | .PHONY: devhelp 111 | devhelp: 112 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 113 | @echo 114 | @echo "Build finished." 115 | @echo "To view the help file:" 116 | @echo "# mkdir -p $$HOME/.local/share/devhelp/package_template" 117 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/package_template" 118 | @echo "# devhelp" 119 | 120 | .PHONY: epub 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | .PHONY: latex 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | .PHONY: latexpdf 135 | latexpdf: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo "Running LaTeX files through pdflatex..." 138 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 139 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 140 | 141 | .PHONY: latexpdfja 142 | latexpdfja: 143 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 144 | @echo "Running LaTeX files through platex and dvipdfmx..." 145 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 146 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 147 | 148 | .PHONY: text 149 | text: 150 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 151 | @echo 152 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 153 | 154 | .PHONY: man 155 | man: 156 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 157 | @echo 158 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 159 | 160 | .PHONY: texinfo 161 | texinfo: 162 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 163 | @echo 164 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 165 | @echo "Run \`make' in that directory to run these through makeinfo" \ 166 | "(use \`make info' here to do that automatically)." 167 | 168 | .PHONY: info 169 | info: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo "Running Texinfo files through makeinfo..." 172 | make -C $(BUILDDIR)/texinfo info 173 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 174 | 175 | .PHONY: gettext 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | .PHONY: changes 182 | changes: 183 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 184 | @echo 185 | @echo "The overview file is in $(BUILDDIR)/changes." 186 | 187 | .PHONY: linkcheck 188 | linkcheck: 189 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 190 | @echo 191 | @echo "Link check complete; look for any errors in the above output " \ 192 | "or in $(BUILDDIR)/linkcheck/output.txt." 193 | 194 | .PHONY: doctest 195 | doctest: 196 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 197 | @echo "Testing of doctests in the sources finished, look at the " \ 198 | "results in $(BUILDDIR)/doctest/output.txt." 199 | 200 | .PHONY: coverage 201 | coverage: 202 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 203 | @echo "Testing of coverage in the sources finished, look at the " \ 204 | "results in $(BUILDDIR)/coverage/python.txt." 205 | 206 | .PHONY: xml 207 | xml: 208 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 209 | @echo 210 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 211 | 212 | .PHONY: pseudoxml 213 | pseudoxml: 214 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 215 | @echo 216 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 217 | -------------------------------------------------------------------------------- /docs/MeanVariance.rst: -------------------------------------------------------------------------------- 1 | .. _mean-variance: 2 | 3 | ########################## 4 | Mean-Variance Optimization 5 | ########################## 6 | 7 | Mathematical optimization is a very difficult problem in general, particularly when we are dealing 8 | with complex objectives and constraints. However, **convex optimization** problems are a well-understood 9 | class of problems, which happen to be incredibly useful for finance. A convex problem has the following form: 10 | 11 | .. math:: 12 | 13 | \begin{equation*} 14 | \begin{aligned} 15 | & \underset{\mathbf{x}}{\text{minimise}} & & f(\mathbf{x}) \\ 16 | & \text{subject to} & & g_i(\mathbf{x}) \leq 0, i = 1, \ldots, m\\ 17 | &&& A\mathbf{x} = b,\\ 18 | \end{aligned} 19 | \end{equation*} 20 | 21 | where :math:`\mathbf{x} \in \mathbb{R}^n`, and :math:`f(\mathbf{x}), g_i(\mathbf{x})` are convex functions. [1]_ 22 | 23 | Fortunately, portfolio optimization problems (with standard objectives and constraints) are convex. This 24 | allows us to immediately apply the vast body of theory as well as the refined solving routines -- accordingly, 25 | the main difficulty is inputting our specific problem into a solver. 26 | 27 | PyPortfolioOpt aims to do the hard work for you, allowing for one-liners like ``ef.min_volatility()`` 28 | to generate a portfolio that minimises the volatility, while at the same time allowing for more 29 | complex problems to be built up from modular units. This is all possible thanks to 30 | `cvxpy `_, the *fantastic* python-embedded modelling 31 | language for convex optimization upon which PyPortfolioOpt's efficient frontier functionality lies. 32 | 33 | .. tip:: 34 | 35 | You can find complete examples in the relevant cookbook `recipe `_. 36 | 37 | 38 | Structure 39 | ========= 40 | 41 | As shown in the definition of a convex problem, there are essentially two things we need to specify: 42 | the optimization objective, and the optimization constraints. For example, the classic portfolio 43 | optimization problem is to **minimise risk** subject to a **return constraint** (i.e the portfolio 44 | must return more than a certain amount). From an implementation perspective, however, there is 45 | not much difference between an objective and a constraint. Consider a similar problem, which is to 46 | **maximize return** subject to a **risk constraint** -- now, the role of risk and return have swapped. 47 | 48 | To that end, PyPortfolioOpt defines an :py:mod:`objective_functions` module that contains objective functions 49 | (which can also act as constraints, as we have just seen). The actual optimization occurs in the :py:class:`efficient_frontier.EfficientFrontier` class. 50 | This class provides straightforward methods for optimising different objectives (all documented below). 51 | 52 | However, PyPortfolioOpt was designed so that you can easily add new constraints or objective terms to an existing problem. 53 | For example, adding a regularisation objective (explained below) to a minimum volatility objective is as simple as:: 54 | 55 | ef = EfficientFrontier(expected_returns, cov_matrix) # setup 56 | ef.add_objective(objective_functions.L2_reg) # add a secondary objective 57 | ef.min_volatility() # find the portfolio that minimises volatility and L2_reg 58 | 59 | .. tip:: 60 | 61 | If you would like to plot the efficient frontier, take a look at the :ref:`plotting` module. 62 | 63 | Basic Usage 64 | =========== 65 | 66 | .. automodule:: pypfopt.efficient_frontier 67 | 68 | .. autoclass:: EfficientFrontier 69 | 70 | .. automethod:: __init__ 71 | 72 | .. note:: 73 | 74 | As of v0.5.0, you can pass a collection (list or tuple) of (min, max) pairs 75 | representing different bounds for different assets. 76 | 77 | .. tip:: 78 | 79 | If you want to generate short-only portfolios, there is a quick hack. Multiply 80 | your expected returns by -1, then optimize a long-only portfolio. 81 | 82 | .. automethod:: min_volatility 83 | 84 | .. automethod:: max_sharpe 85 | 86 | .. caution:: 87 | 88 | Because ``max_sharpe()`` makes a variable substitution, additional objectives may 89 | not work as intended. 90 | 91 | 92 | .. automethod:: max_quadratic_utility 93 | 94 | .. note:: 95 | 96 | ``pypfopt.black_litterman`` provides a method for calculating the market-implied 97 | risk-aversion parameter, which gives a useful estimate in the absence of other 98 | information! 99 | 100 | .. automethod:: efficient_risk 101 | 102 | .. caution:: 103 | 104 | If you pass an unreasonable target into :py:meth:`efficient_risk` or 105 | :py:meth:`efficient_return`, the optimizer will fail silently and return 106 | weird weights. *Caveat emptor* applies! 107 | 108 | .. automethod:: efficient_return 109 | 110 | .. automethod:: portfolio_performance 111 | 112 | .. tip:: 113 | 114 | If you would like to use the ``portfolio_performance`` function independently of any 115 | optimizer (e.g for debugging purposes), you can use:: 116 | 117 | from pypfopt import base_optimizer 118 | 119 | base_optimizer.portfolio_performance( 120 | weights, expected_returns, cov_matrix, verbose=True, risk_free_rate=0.02 121 | ) 122 | 123 | .. note:: 124 | 125 | PyPortfolioOpt defers to cvxpy's default choice of solver. If you would like to explicitly 126 | choose the solver, simply pass the optional ``solver = "ECOS"`` kwarg to the constructor. 127 | You can choose from any of the `supported solvers `_, 128 | and pass in solver params via ``solver_options`` (a ``dict``). 129 | 130 | 131 | Adding objectives and constraints 132 | ================================= 133 | 134 | EfficientFrontier inherits from the BaseConvexOptimizer class. In particular, the functions to 135 | add constraints and objectives are documented below: 136 | 137 | 138 | .. class:: pypfopt.base_optimizer.BaseConvexOptimizer 139 | :noindex: 140 | 141 | .. automethod:: add_constraint 142 | 143 | .. automethod:: add_sector_constraints 144 | 145 | .. automethod:: add_objective 146 | 147 | 148 | Objective functions 149 | =================== 150 | 151 | .. automodule:: pypfopt.objective_functions 152 | :members: 153 | 154 | 155 | 156 | .. _L2-Regularisation: 157 | 158 | More on L2 Regularisation 159 | ========================= 160 | 161 | As has been discussed in the :ref:`user-guide`, mean-variance optimization often 162 | results in many weights being negligible, i.e the efficient portfolio does not end up 163 | including most of the assets. This is expected behaviour, but it may be undesirable 164 | if you need a certain number of assets in your portfolio. 165 | 166 | In order to coerce the mean-variance optimizer to produce more non-negligible 167 | weights, we add what can be thought of as a "small weights penalty" to all 168 | of the objective functions, parameterised by :math:`\gamma` (``gamma``). Considering 169 | the minimum variance objective for instance, we have: 170 | 171 | .. math:: 172 | \underset{w}{\text{minimise}} ~ \left\{w^T \Sigma w \right\} ~~~ \longrightarrow ~~~ 173 | \underset{w}{\text{minimise}} ~ \left\{w^T \Sigma w + \gamma w^T w \right\} 174 | 175 | Note that :math:`w^T w` is the same as the sum of squared weights (I didn't 176 | write this explicitly to reduce confusion caused by :math:`\Sigma` denoting both the 177 | covariance matrix and the summation operator). This term reduces the number of 178 | negligible weights, because it has a minimum value when all weights are 179 | equally distributed, and maximum value in the limiting case where the entire portfolio 180 | is allocated to one asset. I refer to it as **L2 regularisation** because it has 181 | exactly the same form as the L2 regularisation term in machine learning, though 182 | a slightly different purpose (in ML it is used to keep weights small while here it is 183 | used to make them larger). 184 | 185 | .. note:: 186 | 187 | In practice, :math:`\gamma` must be tuned to achieve the level 188 | of regularisation that you want. However, if the universe of assets is small 189 | (less than 20 assets), then ``gamma=1`` is a good starting point. For larger 190 | universes, or if you want more non-negligible weights in the final portfolio, 191 | increase ``gamma``. 192 | 193 | 194 | References 195 | ========== 196 | 197 | .. [1] Boyd, S.; Vandenberghe, L. (2004). `Convex Optimization `_. 198 | -------------------------------------------------------------------------------- /docs/OtherOptimizers.rst: -------------------------------------------------------------------------------- 1 | .. _other-optimizers: 2 | 3 | ################ 4 | Other Optimizers 5 | ################ 6 | 7 | Efficient frontier methods involve the direct optimization of an objective subject to constraints. 8 | However, there are some portfolio optimization schemes that are completely different in character. 9 | PyPortfolioOpt provides support for these alternatives, while still giving you access to the same 10 | pre and post-processing API. 11 | 12 | .. note:: 13 | As of v0.4, these other optimizers now inherit from ``BaseOptimizer`` or 14 | ``BaseConvexOptimizer``, so you no longer have to implement pre-processing and 15 | post-processing methods on your own. You can thus easily swap out, say, 16 | ``EfficientFrontier`` for ``HRPOpt``. 17 | 18 | Hierarchical Risk Parity (HRP) 19 | ============================== 20 | 21 | Hierarchical Risk Parity is a novel portfolio optimization method developed by 22 | Marcos Lopez de Prado [1]_. Though a detailed explanation can be found in the 23 | linked paper, here is a rough overview of how HRP works: 24 | 25 | 26 | 1. From a universe of assets, form a distance matrix based on the correlation 27 | of the assets. 28 | 2. Using this distance matrix, cluster the assets into a tree via hierarchical 29 | clustering 30 | 3. Within each branch of the tree, form the minimum variance portfolio (normally 31 | between just two assets). 32 | 4. Iterate over each level, optimally combining the mini-portfolios at each node. 33 | 34 | 35 | The advantages of this are that it does not require the inversion of the covariance 36 | matrix as with traditional mean-variance optimization, and seems to produce diverse 37 | portfolios that perform well out of sample. 38 | 39 | .. image:: ../media/dendrogram.png 40 | :width: 80% 41 | :align: center 42 | :alt: cluster diagram 43 | 44 | 45 | .. automodule:: pypfopt.hierarchical_portfolio 46 | 47 | .. autoclass:: HRPOpt 48 | :members: 49 | :exclude-members: plot_dendrogram 50 | 51 | 52 | .. automethod:: __init__ 53 | 54 | .. _cla: 55 | 56 | The Critical Line Algorithm 57 | =========================== 58 | 59 | This is a robust alternative to the quadratic solver used to find mean-variance optimal portfolios, 60 | that is especially advantageous when we apply linear inequalities. Unlike generic convex optimization routines, 61 | the CLA is specially designed for portfolio optimization. It is guaranteed to converge after a certain 62 | number of iterations, and can efficiently derive the entire efficient frontier. 63 | 64 | .. image:: ../media/cla_plot.png 65 | :width: 80% 66 | :align: center 67 | :alt: the Efficient Frontier 68 | 69 | .. tip:: 70 | 71 | In general, unless you have specific requirements e.g you would like to efficiently compute the entire 72 | efficient frontier for plotting, I would go with the standard ``EfficientFrontier`` optimizer. 73 | 74 | I am most grateful to Marcos López de Prado and David Bailey for providing the implementation [2]_. 75 | Permission for its distribution has been received by email. It has been modified such that it has 76 | the same API, though as of v0.5.0 we only support ``max_sharpe()`` and ``min_volatility()``. 77 | 78 | 79 | .. automodule:: pypfopt.cla 80 | 81 | .. autoclass:: CLA 82 | :members: 83 | :exclude-members: plot_efficient_frontier 84 | 85 | .. automethod:: __init__ 86 | 87 | 88 | Implementing your own optimizer 89 | =============================== 90 | 91 | Please note that this is quite different to implementing :ref:`custom-optimization`, because in 92 | that case we are still using the same convex optimization structure. However, HRP and CLA optimization 93 | have a fundamentally different optimization method. In general, these are much more difficult 94 | to code up compared to custom objective functions. 95 | 96 | To implement a custom optimizer that is compatible with the rest of PyPortfolioOpt, just 97 | extend ``BaseOptimizer`` (or ``BaseConvexOptimizer`` if you want to use ``cvxpy``), 98 | both of which can be found in ``base_optimizer.py``. This gives you access to utility 99 | methods like ``clean_weights()``, as well as making sure that any output is compatible 100 | with ``portfolio_performance()`` and post-processing methods. 101 | 102 | .. automodule:: pypfopt.base_optimizer 103 | 104 | .. autoclass:: BaseOptimizer 105 | :members: 106 | 107 | .. automethod:: __init__ 108 | 109 | .. autoclass:: BaseConvexOptimizer 110 | :members: 111 | :private-members: 112 | 113 | .. automethod:: __init__ 114 | 115 | 116 | References 117 | ========== 118 | 119 | .. [1] López de Prado, M. (2016). `Building Diversified Portfolios that Outperform Out of Sample `_. The Journal of Portfolio Management, 42(4), 59–69. 120 | .. [2] Bailey and Loópez de Prado (2013). `An Open-Source Implementation of the Critical-Line Algorithm for Portfolio Optimization `_ 121 | -------------------------------------------------------------------------------- /docs/Plotting.rst: -------------------------------------------------------------------------------- 1 | .. _plotting: 2 | 3 | ######## 4 | Plotting 5 | ######## 6 | 7 | All of the optimization functions in :py:class:`EfficientFrontier` produce a single optimal portfolio. 8 | However, you may want to plot the entire efficient frontier. This efficient frontier can be thought 9 | of in several different ways: 10 | 11 | 1. The set of all :py:func:`efficient_risk` portfolios for a range of target risks 12 | 2. The set of all :py:func:`efficient_return` portfolios for a range of target returns 13 | 3. The set of all :py:func:`max_quadratic_utility` portfolios for a range of risk aversions. 14 | 15 | The :py:mod:`plotting` module provides support for all three of these approaches. To produce 16 | a plot of the efficient frontier, you should instantiate your :py:class:`EfficientFrontier` object 17 | and add constraints like you normally would, but *before* calling an optimization function (e.g with 18 | :py:func:`ef.max_sharpe`), you should pass this the instantiated object into :py:func:`plot.plot_efficient_frontier`:: 19 | 20 | ef = EfficientFrontier(mu, S, weight_bounds=(None, None)) 21 | ef.add_constraint(lambda w: w[0] >= 0.2) 22 | ef.add_constraint(lambda w: w[2] == 0.15) 23 | ef.add_constraint(lambda w: w[3] + w[4] <= 0.10) 24 | 25 | fig, ax = plt.subplots() 26 | plotting.plot_efficient_frontier(ef, ax=ax, show_assets=True) 27 | plt.show() 28 | 29 | This produces the following plot: 30 | 31 | .. image:: ../media/ef_plot.png 32 | :width: 80% 33 | :align: center 34 | :alt: the Efficient Frontier 35 | 36 | You can explicitly pass a range of parameters (risk, utility, or returns) to generate a frontier:: 37 | 38 | # 100 portfolios with risks between 0.10 and 0.30 39 | risk_range = np.linspace(0.10, 0.40, 100) 40 | plotting.plot_efficient_frontier(ef, ef_param="risk", ef_param_range=risk_range, 41 | show_assets=True, showfig=True) 42 | 43 | 44 | We can easily generate more complex plots. The following script plots both the efficient frontier and 45 | randomly generated (suboptimal) portfolios, coloured by the Sharpe ratio:: 46 | 47 | fig, ax = plt.subplots() 48 | ef_max_sharpe = ef.deepcopy() 49 | plotting.plot_efficient_frontier(ef, ax=ax, show_assets=False) 50 | 51 | # Find the tangency portfolio 52 | ef_max_sharpe.max_sharpe() 53 | ret_tangent, std_tangent, _ = ef_max_sharpe.portfolio_performance() 54 | ax.scatter(std_tangent, ret_tangent, marker="*", s=100, c="r", label="Max Sharpe") 55 | 56 | # Generate random portfolios 57 | n_samples = 10000 58 | w = np.random.dirichlet(np.ones(ef.n_assets), n_samples) 59 | rets = w.dot(ef.expected_returns) 60 | stds = np.sqrt(np.diag(w @ ef.cov_matrix @ w.T)) 61 | sharpes = rets / stds 62 | ax.scatter(stds, rets, marker=".", c=sharpes, cmap="viridis_r") 63 | 64 | # Output 65 | ax.set_title("Efficient Frontier with random portfolios") 66 | ax.legend() 67 | plt.tight_layout() 68 | plt.savefig("ef_scatter.png", dpi=200) 69 | plt.show() 70 | 71 | This is the result: 72 | 73 | .. image:: ../media/ef_scatter.png 74 | :width: 80% 75 | :align: center 76 | :alt: the Efficient Frontier with random portfolios 77 | 78 | Documentation reference 79 | ======================= 80 | 81 | .. automodule:: pypfopt.plotting 82 | 83 | .. tip:: 84 | 85 | To save the plot, pass ``filename="somefile.png"`` as a keyword argument to any of 86 | the plotting functions. This (along with some other kwargs) get passed through 87 | :py:func:`_plot_io` before being returned. 88 | 89 | .. autofunction:: _plot_io 90 | 91 | .. autofunction:: plot_covariance 92 | 93 | .. image:: ../media/corrplot.png 94 | :align: center 95 | :width: 80% 96 | :alt: plot of the covariance matrix 97 | 98 | .. autofunction:: plot_dendrogram 99 | 100 | .. image:: ../media/dendrogram.png 101 | :width: 80% 102 | :align: center 103 | :alt: return clusters 104 | 105 | .. autofunction:: plot_efficient_frontier 106 | 107 | .. image:: ../media/cla_plot.png 108 | :width: 80% 109 | :align: center 110 | :alt: the Efficient Frontier 111 | 112 | .. autofunction:: plot_weights 113 | 114 | .. image:: ../media/weight_plot.png 115 | :width: 80% 116 | :align: center 117 | :alt: bar chart to show weights 118 | -------------------------------------------------------------------------------- /docs/Postprocessing.rst: -------------------------------------------------------------------------------- 1 | .. _post-processing: 2 | 3 | ####################### 4 | Post-processing weights 5 | ####################### 6 | 7 | After optimal weights have been generated, it is often necessary to do some 8 | post-processing before they can be used practically. In particular, you are 9 | likely using portfolio optimization techniques to generate a 10 | **portfolio allocation** – a list of tickers and corresponding integer quantities 11 | that you could go and purchase at a broker. 12 | 13 | However, it is not trivial to convert the continuous weights (output by any of our 14 | optimization methods) into an actionable allocation. For example, let us say that we 15 | have $10,000 that we would like to allocate. If we multiply the weights by this total 16 | portfolio value, the result will be dollar amounts of each asset. So if the optimal weight 17 | for Apple is 0.15, we need $1500 worth of Apple stock. However, Apple shares come 18 | in discrete units ($190 at the time of writing), so we will not be able to buy 19 | exactly $1500 of stock. The best we can do is to buy the number of shares that 20 | gets us closest to the desired dollar value. 21 | 22 | PyPortfolioOpt offers two ways of solving this problem: one using a simple greedy 23 | algorithm, the other using integer programming. 24 | 25 | Greedy algorithm 26 | ================ 27 | 28 | ``DiscreteAllocation.greedy_portfolio()`` proceeds in two 'rounds'. 29 | In the first round, we buy as many shares as we can for each asset without going over 30 | the desired weight. In the Apple example, :math:`1500/190 \approx 7.89`, so we buy 7 31 | shares at a cost of $1330. After iterating through all of the assets, we will have a 32 | lot of money left over (since we always rounded down). 33 | 34 | In the second round, we calculate how far the current weights deviate from the 35 | existing weights for each asset. We wanted Apple to form 15% of the portfolio 36 | (with total value $10,000), but we only bought $1330 worth of Apple stock, so 37 | there is a deviation of :math:`0.15 - 0.133`. Some assets will have a higher 38 | deviation from the ideal, so we will purchase shares of these first. We then 39 | repeat the process, always buying shares of the asset whose current weight is 40 | furthest away from the ideal weight. Though this algorithm will not guarantee 41 | the optimal solution, I have found that it allows us to generate discrete 42 | allocations with very little money left over (e.g $12 left on a $10,000 portfolio). 43 | 44 | That being said, we can see that on the test dataset (for a standard ``max_sharpe`` 45 | portfolio), the allocation method may deviate rather widely from the desired weights, 46 | particularly for companies with a high share price (e.g AMZN). 47 | 48 | .. code-block:: text 49 | 50 | Funds remaining: 12.15 51 | MA: allocated 0.242, desired 0.246 52 | FB: allocated 0.200, desired 0.199 53 | PFE: allocated 0.183, desired 0.184 54 | BABA: allocated 0.088, desired 0.096 55 | AAPL: allocated 0.086, desired 0.092 56 | AMZN: allocated 0.000, desired 0.072 57 | BBY: allocated 0.064, desired 0.061 58 | SBUX: allocated 0.036, desired 0.038 59 | GOOG: allocated 0.102, desired 0.013 60 | Allocation has RMSE: 0.038 61 | 62 | 63 | Integer programming 64 | =================== 65 | 66 | This method (credit to `Dingyuan Wang `_ for the first implementation) 67 | treats the discrete allocation as an integer programming problem. In effect, the integer 68 | programming approach searches the space of possible allocations to find the one that is 69 | closest to our desired weights. We will use the following notation: 70 | 71 | - :math:`T \in \mathbb{R}` is the total dollar value to be allocated 72 | - :math:`p \in \mathbb{R}^n` is the array of latest prices 73 | - :math:`w \in \mathbb{R}^n` is the set of target weights 74 | - :math:`x \in \mathbb{Z}^n` is the integer allocation (i.e the result) 75 | - :math:`r \in \mathbb{R}` is the remaining unallocated value, i.e :math:`r = T - x \cdot p`. 76 | 77 | The optimization problem is then given by: 78 | 79 | .. math:: 80 | 81 | \begin{equation*} 82 | \begin{aligned} 83 | & \underset{x \in \mathbb{Z}^n}{\text{minimise}} & & r + \lVert wT - x \odot p \rVert_1 \\ 84 | & \text{subject to} & & r + x \cdot p = T\\ 85 | \end{aligned} 86 | \end{equation*} 87 | 88 | This is straightforward to translate into ``cvxpy``. 89 | 90 | .. info:: 91 | 92 | Though ``lp_portfolio()`` produces allocations with a lower RMSE, some testing 93 | shows that it is between 100 and 1000 times slower than ``greedy_portfolio()``. 94 | This doesn't matter for small portfolios (it should still take less than a second), 95 | but the runtime for integer programs grows exponentially as the number of stocks, so 96 | for large portfolios you may have to use ``greedy_portfolio()``. 97 | 98 | .. warning:: 99 | 100 | PyPortfolioOpt uses ``ECOS_BB`` as a default solver for integer programming. ``ECOS_BB`` has known correctness issues (see `here `_ for a discussion). 101 | An alternative is to use ``GLPK_MI``, which comes packaged with ``cvxopt``. 102 | 103 | Dealing with shorts 104 | =================== 105 | 106 | As of v0.4, ``DiscreteAllocation`` automatically deals with shorts by finding separate discrete 107 | allocations for the long-only and short-only portions. If your portfolio has shorts, 108 | you should pass a short ratio. The default is 0.30, corresponding to a 130/30 long-short balance. 109 | Practically, this means that you would go long $10,000 of some stocks, short $3000 of some other 110 | stocks, then use the proceeds from the shorts to go long another $3000. 111 | Thus the total value of the resulting portfolio would be $13,000. 112 | 113 | Documentation reference 114 | ======================== 115 | 116 | .. automodule:: pypfopt.discrete_allocation 117 | 118 | .. autoclass:: DiscreteAllocation 119 | :members: 120 | :private-members: 121 | 122 | .. automethod:: __init__ 123 | 124 | 125 | -------------------------------------------------------------------------------- /docs/RiskModels.rst: -------------------------------------------------------------------------------- 1 | .. _risk-models: 2 | 3 | ########### 4 | Risk Models 5 | ########### 6 | 7 | In addition to the expected returns, mean-variance optimization requires a 8 | **risk model**, some way of quantifying asset risk. The most commonly-used risk model 9 | is the covariance matrix, which describes asset volatilities and their co-dependence. This is 10 | important because one of the principles of diversification is that risk can be 11 | reduced by making many uncorrelated bets (correlation is just normalised 12 | covariance). 13 | 14 | .. image:: ../media/corrplot.png 15 | :align: center 16 | :width: 60% 17 | :alt: plot of the covariance matrix 18 | 19 | 20 | In many ways, the subject of risk models is far more important than that of 21 | expected returns because historical variance is generally a much more persistent 22 | statistic than mean historical returns. In fact, research by Kritzman et 23 | al. (2010) [1]_ suggests that minimum variance portfolios, formed by optimising 24 | without providing expected returns, actually perform much better out of sample. 25 | 26 | The problem, however, is that in practice we do not have access to the covariance 27 | matrix (in the same way that we don't have access to expected returns) – the only 28 | thing we can do is to make estimates based on past data. The most straightforward 29 | approach is to just calculate the **sample covariance matrix** based on historical 30 | returns, but relatively recent (post-2000) research indicates that there are much 31 | more robust statistical estimators of the covariance matrix. In addition to 32 | providing a wrapper around the estimators in ``sklearn``, PyPortfolioOpt 33 | provides some experimental alternatives such as semicovariance and exponentially weighted 34 | covariance. 35 | 36 | .. attention:: 37 | 38 | Estimation of the covariance matrix is a very deep and actively-researched 39 | topic that involves statistics, econometrics, and numerical/computational 40 | approaches. PyPortfolioOpt implements several options, but there is a lot of room 41 | for more sophistication. 42 | 43 | 44 | .. automodule:: pypfopt.risk_models 45 | 46 | .. note:: 47 | 48 | For any of these methods, if you would prefer to pass returns (the default is prices), 49 | set the boolean flag ``returns_data=True`` 50 | 51 | .. autofunction:: risk_matrix 52 | 53 | .. autofunction:: fix_nonpositive_semidefinite 54 | 55 | Not all the calculated covariance matrices will be positive semidefinite (PSD). This method 56 | checks if a matrix is PSD and fixes it if not. 57 | 58 | .. autofunction:: sample_cov 59 | 60 | This is the textbook default approach. The 61 | entries in the sample covariance matrix (which we denote as *S*) are the sample 62 | covariances between the *i* th and *j* th asset (the diagonals consist of 63 | variances). Although the sample covariance matrix is an unbiased estimator of the 64 | covariance matrix, i.e :math:`E(S) = \Sigma`, in practice it suffers from 65 | misspecification error and a lack of robustness. This is particularly problematic 66 | in mean-variance optimization, because the optimizer may give extra credence to 67 | the erroneous values. 68 | 69 | .. note:: 70 | 71 | This should *not* be your default choice! Please use a shrinkage estimator 72 | instead. 73 | 74 | .. autofunction:: semicovariance 75 | 76 | The semivariance is the variance of all returns which are below some benchmark *B* 77 | (typically the risk-free rate) – it is a common measure of downside risk. There are multiple 78 | possible ways of defining a semicovariance matrix, the main differences lying in 79 | the 'pairwise' nature, i.e whether we should sum over :math:`\min(r_i,B)\min(r_j,B)` 80 | or :math:`\min(r_ir_j, B)`. In this implementation, we have followed the advice of 81 | Estrada (2007) [2]_, preferring: 82 | 83 | .. math:: 84 | \frac{1}{n}\sum_{i = 1}^n {\sum_{j = 1}^n {\min \left( {{r_i},B} \right)} } 85 | \min \left( {{r_j},B} \right) 86 | 87 | .. autofunction:: exp_cov 88 | 89 | The exponential covariance matrix is a novel way of giving more weight to 90 | recent data when calculating covariance, in the same way that the exponential 91 | moving average price is often preferred to the simple average price. For a full 92 | explanation of how this estimator works, please refer to the 93 | `blog post `_ 94 | on my academic website. 95 | 96 | .. autofunction:: cov_to_corr 97 | 98 | .. autofunction:: corr_to_cov 99 | 100 | 101 | 102 | Shrinkage estimators 103 | ==================== 104 | 105 | A great starting point for those interested in understanding shrinkage estimators is 106 | *Honey, I Shrunk the Sample Covariance Matrix* [3]_ by Ledoit and Wolf, which does a 107 | good job at capturing the intuition behind them – we will adopt the 108 | notation used therein. I have written a summary of this article, which is available 109 | on my `website `_. 110 | A more rigorous reference can be found in Ledoit and Wolf (2001) [4]_. 111 | 112 | The essential idea is that the unbiased but often poorly estimated sample covariance can be 113 | combined with a structured estimator :math:`F`, using the below formula (where 114 | :math:`\delta` is the shrinkage constant): 115 | 116 | .. math:: 117 | \hat{\Sigma} = \delta F + (1-\delta) S 118 | 119 | It is called shrinkage because it can be thought of as "shrinking" the sample 120 | covariance matrix towards the other estimator, which is accordingly called the 121 | **shrinkage target**. The shrinkage target may be significantly biased but has little 122 | estimation error. There are many possible options for the target, and each one will 123 | result in a different optimal shrinkage constant :math:`\delta`. PyPortfolioOpt offers 124 | the following shrinkage methods: 125 | 126 | - Ledoit-Wolf shrinkage: 127 | 128 | - ``constant_variance`` shrinkage, i.e the target is the diagonal matrix with the mean of 129 | asset variances on the diagonals and zeroes elsewhere. This is the shrinkage offered 130 | by ``sklearn.LedoitWolf``. 131 | - ``single_factor`` shrinkage. Based on Sharpe's single-index model which effectively uses 132 | a stock's beta to the market as a risk model. See Ledoit and Wolf 2001 [4]_. 133 | - ``constant_correlation`` shrinkage, in which all pairwise correlations are set to 134 | the average correlation (sample variances are unchanged). See Ledoit and Wolf 2003 [3]_ 135 | 136 | - Oracle approximating shrinkage (OAS), invented by Chen et al. (2010) [5]_, which 137 | has a lower mean-squared error than Ledoit-Wolf shrinkage when samples are 138 | Gaussian or near-Gaussian. 139 | 140 | .. tip:: 141 | 142 | For most use cases, I would just go with Ledoit Wolf shrinkage, as recommended by 143 | `Quantopian `_ in their lecture series on quantitative 144 | finance. 145 | 146 | 147 | My implementations have been translated from the Matlab code on 148 | `Michael Wolf's webpage `_, with 149 | the help of `xtuanta `_. 150 | 151 | 152 | .. autoclass:: CovarianceShrinkage 153 | :members: 154 | 155 | .. automethod:: __init__ 156 | 157 | 158 | References 159 | ========== 160 | 161 | .. [1] Kritzman, Page & Turkington (2010) `In defense of optimization: The fallacy of 1/N `_. Financial Analysts Journal, 66(2), 31-39. 162 | .. [2] Estrada (2006), `Mean-Semivariance Optimization: A Heuristic Approach `_ 163 | .. [3] Ledoit, O., & Wolf, M. (2003). `Honey, I Shrunk the Sample Covariance Matrix `_ The Journal of Portfolio Management, 30(4), 110–119. https://doi.org/10.3905/jpm.2004.110 164 | .. [4] Ledoit, O., & Wolf, M. (2001). `Improved estimation of the covariance matrix of stock returns with an application to portfolio selection `_, 10, 603–621. 165 | .. [5] Chen et al. (2010), `Shrinkage Algorithms for MMSE Covariance Estimation `_, IEEE Transactions on Signals Processing, 58(10), 5016-5029. 166 | -------------------------------------------------------------------------------- /docs/Roadmap.rst: -------------------------------------------------------------------------------- 1 | .. _roadmap: 2 | 3 | ##################### 4 | Roadmap and Changelog 5 | ##################### 6 | 7 | 8 | Roadmap 9 | ======= 10 | 11 | PyPortfolioOpt is now a "mature" package – it is stable and I don't intend to implement major new functionality (though I will endeavour to fix bugs). 12 | 13 | 1.5.0 14 | ===== 15 | 16 | - Major redesign of the backend, thanks to `Philipp Schiele `_ 17 | - Because we use ``cp.Parameter``, we can efficiently re-run optimisation problems with different constants (e.g risk targets) 18 | - This leads to a significant improvement in plotting performance as we no longer have to repeatedly re-instantiate ``EfficientFrontier``. 19 | - Several misc bug fixes (thanks to `Eric Armbruster `_ and `Ayoub Ennassiri `_) 20 | 21 | 1.5.1 22 | ----- 23 | 24 | Mucked up the versioning on the 1.5.0 launch. Sorry! 25 | 26 | 1.5.2 27 | ----- 28 | 29 | Minor bug fixes 30 | 31 | 1.5.3 32 | ----- 33 | 34 | - Reworked packaging: ``cvxpy`` is no longer a requirement as we default to ``ECOS_BB`` for discrete allocation. 35 | - Bumped minimum python version to ``3.8``. I would love to keep as many versions compatible (and I think most of the 36 | functionality *should* still work with ``3.6, 3.7`` but the dependencies have gotten too tricky to manage). 37 | - Changed to numpy pseudoinverse to allow for "cash" assets 38 | - Ticker labels for efficient frontier plot 39 | 40 | 1.5.4 41 | ----- 42 | 43 | - Fixed ``cvxpy`` deprecating deepcopy. Thanks to Philipp for the fix! 44 | - Several other tiny checks and bug fixes. Cheers to everyone for the PRs! 45 | 46 | 1.5.5 47 | ----- 48 | 49 | - `Tuan Tran `_ is now the primary maintainer for PyPortfolioOpt 50 | - Wide range of bug fixes and code improvements. 51 | 52 | 1.5.6 53 | ----- 54 | 55 | - Various bug fixes 56 | 57 | 1.4.0 58 | ===== 59 | 60 | - Finally implemented CVaR optimization! This has been one of the most requested features. Many thanks 61 | to `Nicolas Knudde `_ for the initial draft. 62 | - Re-architected plotting so users can pass an ax, allowing for complex plots (see cookbook). 63 | - Helper method to compute the max-return portfolio (thanks to `Philipp Schiele `_) 64 | for the suggestion). 65 | - Several bug fixes and test improvements (thanks to `Carl Peasnell `_). 66 | 67 | 1.4.1 68 | ----- 69 | 70 | - 100% test coverage 71 | - Reorganised docs; added FAQ page 72 | - Reorganised module structure to make it more scalable 73 | - Python 3.9 support, dockerfile versioning, misc packaging improvements (e.g cvxopt optional) 74 | 75 | 1.4.2 76 | ----- 77 | 78 | - Implemented CDaR optimization – full credit to `Nicolas Knudde `_. 79 | - Misc bug fixes 80 | 81 | 82 | 1.3.0 83 | ===== 84 | 85 | - Significantly improved plotting functionality: can now plot constrained efficient frontier! 86 | - Efficient semivariance portfolios (thanks to `Philipp Schiele `_) 87 | - Improved functionality for portfolios with short positions (thanks to `Rich Caputo `_). 88 | - Significant improvement in test coverage (thanks to `Carl Peasnell `_). 89 | - Several bug fixes and usability improvements. 90 | - Migrated from TravisCI to Github Actions. 91 | 92 | 1.3.1 93 | ----- 94 | 95 | - Minor cleanup (forgotten commits from v1.3.0). 96 | 97 | 98 | 1.2.0 99 | ===== 100 | 101 | - Added Idzorek's method for calculating the ``omega`` matrix given percentage confidences. 102 | - Fixed max sharpe to allow for custom constraints 103 | - Grouped sector constraints 104 | - Improved error tracebacks 105 | - Adding new cookbook for examples (in progress). 106 | - Packaging: added bettter instructions for windows, added docker support. 107 | 108 | 1.2.1 109 | ----- 110 | 111 | Fixed critical ordering bug in sector constraints 112 | 113 | 1.2.2 114 | ----- 115 | 116 | Matplotlib now required dependency; support for pandas 1.0. 117 | 118 | 1.2.3 119 | ----- 120 | 121 | - Added support for changing solvers and verbose output 122 | - Changed dict to OrderedDict to support python 3.5 123 | - Improved packaging/dependencies: simplified requirements.txt, improved processes before pushing. 124 | 125 | 1.2.4 126 | ----- 127 | 128 | - Fixed bug in Ledoit-Wolf shrinkage calculation. 129 | - Fixed bug in plotting docs that caused them not to render. 130 | 131 | 1.2.5 132 | ----- 133 | 134 | - Fixed compounding in ``expected_returns`` (thanks to `Aditya Bhutra `_). 135 | - Improvements in advanced cvxpy API (thanks to `Pat Newell `_). 136 | - Deprecating James-Stein 137 | - Exposed ``linkage_method`` in HRP. 138 | - Added support for cvxpy 1.1. 139 | - Added an error check for ``efficient_risk``. 140 | - Small improvements to docs. 141 | 142 | 1.2.6 143 | ----- 144 | 145 | - Fixed order-dependence bug in Black-Litterman ``market_implied_prior_returns`` 146 | - Fixed inaccuracy in BL cookbook. 147 | - Fixed bug in exponential covariance. 148 | 149 | 1.2.7 150 | ----- 151 | 152 | - Fixed bug which required conservative risk targets for long/short portfolios. 153 | 154 | 155 | 1.1.0 156 | ===== 157 | 158 | - Multiple additions and improvements to ``risk_models``: 159 | 160 | - Introduced a new API, in which the function ``risk_models.risk_matrix(method="...")`` allows 161 | all the different risk models to be called. This should make testing easier. 162 | - All methods now accept returns data instead of prices, if you set the flag ``returns_data=True``. 163 | - Automatically fix non-positive semidefinite covariance matrices! 164 | 165 | - Additions and improvements to ``expected_returns``: 166 | 167 | - Introduced a new API, in which the function ``expected_returns.return_model(method="...")`` allows 168 | all the different return models to be called. This should make testing easier. 169 | - Added option to 'properly' compound returns. 170 | - Added the CAPM return model. 171 | 172 | - ``from pypfopt import plotting``: moved all plotting functionality into a new class and added 173 | new plots. All other plotting functions (scattered in different classes) have been retained, 174 | but are now deprecated. 175 | 176 | 177 | 1.0.0 178 | ===== 179 | 180 | - Migrated backend from ``scipy`` to ``cvxpy`` and made significant breaking changes to the API 181 | 182 | - PyPortfolioOpt is now significantly more robust and numerically stable. 183 | - These changes will not affect basic users, who can still access features like ``max_sharpe()``. 184 | - However, additional objectives and constraints (including L2 regularisation) are now 185 | explicitly added before optimising some 'primary' objective. 186 | 187 | - Added basic plotting capabilities for the efficient frontier, hierarchical clusters, 188 | and HRP dendrograms. 189 | - Added a basic transaction cost objective. 190 | - Made breaking changes to some modules and classes so that PyPortfolioOpt is easier to extend 191 | in future: 192 | 193 | - Replaced ``BaseScipyOptimizer`` with ``BaseConvexOptimizer`` 194 | - ``hierarchical_risk_parity`` was replaced by ``hierarchical_portfolios`` to leave the door open for other hierarchical methods. 195 | - Sadly, removed CVaR optimization for the time being until I can properly fix it. 196 | 197 | 1.0.1 198 | ----- 199 | 200 | Fixed minor issues in CLA: weight bound bug, ``efficient_frontier`` needed weights to be called, ``set_weights`` not needed. 201 | 202 | 1.0.2 203 | ----- 204 | 205 | Fixed small but important bug where passing ``expected_returns=None`` fails. According to the docs, users 206 | should be able to only pass covariance if they want to only optimize min volatility. 207 | 208 | 209 | 0.5.0 210 | ===== 211 | 212 | - Black-Litterman model and docs. 213 | - Custom bounds per asset 214 | - Improved ``BaseOptimizer``, adding a method that writes weights 215 | to text and fixing a bug in ``set_weights``. 216 | - Unconstrained quadratic utility optimization (analytic) 217 | - Revamped docs, with information on types of attributes and 218 | more examples. 219 | 220 | 0.5.1 221 | ----- 222 | 223 | Fixed an error with dot products by amending the pandas requirements. 224 | 225 | 0.5.2 226 | ----- 227 | 228 | Made PuLP, sklearn, noisyopt optional dependencies to improve installation 229 | experience. 230 | 231 | 0.5.3 232 | ----- 233 | 234 | - Fixed an optimization bug in ``EfficientFrontier.efficient_risk``. An error is now 235 | thrown if optimization fails. 236 | - Added a hidden API to change the scipy optimizer method. 237 | 238 | 0.5.4 239 | ----- 240 | 241 | - Improved the Black-Litterman linear algebra to avoid inverting the uncertainty matrix. 242 | It is now possible to have 100% confidence in views. 243 | - Clarified regarding the role of tau. 244 | - Added a ``pipfile`` for ``pipenv`` users. 245 | - Removed Value-at-risk from docs to discourage usage until it is properly fixed. 246 | 247 | 0.5.5 248 | ----- 249 | 250 | Began migration to cvxpy by changing the discrete allocation backend from PuLP to cvxpy. 251 | 252 | 0.4.0 253 | ===== 254 | 255 | - Major improvements to ``discrete_allocation``. Added functionality to allocate shorts; 256 | modified the linear programming method suggested by `Dingyuan Wang `_; 257 | added postprocessing section to User Guide. 258 | - Further refactoring and docs for ``HRPOpt``. 259 | - Major documentation update, e.g to support custom optimizers 260 | 261 | 0.4.1 262 | ----- 263 | 264 | - Added CLA back in after getting permission from Dr Marcos López de Prado 265 | - Added more tests for different risk models. 266 | 267 | 0.4.2 268 | ----- 269 | 270 | - Minor fix for ``clean_weights`` 271 | - Removed official support for python 3.4. 272 | - Minor improvement to semicovariance, thanks to `Felipe Schneider `_. 273 | 274 | 0.4.3 275 | ----- 276 | 277 | - Added ``prices_from_returns`` utility function and provided better docs for ``returns_from_prices``. 278 | - Added ``cov_to_corr`` method to produce correlation matrices from covariance matrices. 279 | - Fixed readme examples. 280 | 281 | 282 | 283 | 0.3.0 284 | ===== 285 | 286 | - Merged an amazing PR from `Dingyuan Wang `_ that rearchitects 287 | the project to make it more self-consistent and extensible. 288 | - New algorithm: ML de Prado's CLA 289 | - New algorithms for converting continuous allocation to discrete (using linear 290 | programming). 291 | - Merged a `PR `__ implementing Single Factor and 292 | Constant Correlation shrinkage. 293 | 294 | 0.3.1 295 | ----- 296 | 297 | Merged `PR `__ from `TommyBark `_ 298 | fixing a bug in the arguments of a call to ``portfolio_performance``. 299 | 300 | 0.3.3 301 | ----- 302 | 303 | Migrated the project internally to use the ``poetry`` dependency manager. Will still keep ``setup.py`` and ``requirements.txt``, but ``poetry`` is now the recommended way to interact with PyPortfolioOpt. 304 | 305 | 0.3.4 306 | ----- 307 | 308 | Refactored shrinkage models, including single factor and constant correlation. 309 | 310 | 311 | 312 | 0.2.0 313 | ===== 314 | 315 | - Hierarchical Risk Parity optimization 316 | - Semicovariance matrix 317 | - Exponential covariance matrix 318 | - CVaR optimization 319 | - Better support for custom objective functions 320 | - Multiple bug fixes (including minimum volatility vs minimum variance) 321 | - Refactored so all optimizers inherit from a ``BaseOptimizer``. 322 | 323 | 0.2.1 324 | ----- 325 | 326 | - Included python 3.7 in travis build 327 | - Merged PR from `schneiderfelipe `_ to fix an error message. 328 | 329 | 330 | 0.1.0 331 | ===== 332 | 333 | Initial release: 334 | 335 | - Efficient frontier (max sharpe, min variance, target risk/return) 336 | - L2 regularisation 337 | - Discrete allocation 338 | - Mean historical returns, exponential mean returns 339 | - Sample covariance, sklearn wrappers. 340 | - Tests 341 | - Docs 342 | 343 | 0.1.1 344 | ----- 345 | 346 | Minor bug fixes and documentation 347 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # package_template documentation build configuration file, created by 5 | # sphinx-quickstart on Fri May 13 14:31:17 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import os 17 | import sys 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | # sys.path.insert(0, os.path.abspath("../pypfopt")) 23 | sys.path.insert(0, os.path.abspath("..")) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.mathjax", "sphinx.ext.viewcode"] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ["_templates"] 37 | 38 | # The suffix(es) of source filenames. 39 | # You can specify multiple suffix as a list of string: 40 | # source_suffix = ['.rst', '.md'] 41 | source_suffix = ".rst" 42 | 43 | # The encoding of source files. 44 | # source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = "index" 48 | 49 | # General information about the project. 50 | project = "PyPortfolioOpt" 51 | copyright = "2018, Robert Andrew Martin" 52 | author = "Robert Andrew Martin" 53 | 54 | # The version info for the project you're documenting, acts as replacement for 55 | # |version| and |release|, also used in various other places throughout the 56 | # built documents. 57 | # 58 | # The short X.Y version. 59 | version = "1.5" 60 | # The full version, including alpha/beta/rc tags. 61 | release = "1.5.6" 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # There are two options for replacing |today|: either, you set today to some 71 | # non-false value, then it is used: 72 | # today = '' 73 | # Else, today_fmt is used as the format for a strftime call. 74 | # today_fmt = '%B %d, %Y' 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | exclude_patterns = ["_build"] 79 | 80 | # The reST default role (used for this markup: `text`) to use for all 81 | # documents. 82 | # default_role = None 83 | 84 | # If true, '()' will be appended to :func: etc. cross-reference text. 85 | # add_function_parentheses = True 86 | 87 | # If true, the current module name will be prepended to all description 88 | # unit titles (such as .. function::). 89 | # add_module_names = True 90 | 91 | # If true, sectionauthor and moduleauthor directives will be shown in the 92 | # output. They are ignored by default. 93 | # show_authors = False 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | # pygments_style = "sphinx" 97 | 98 | # A list of ignored prefixes for module index sorting. 99 | # modindex_common_prefix = [] 100 | 101 | # If true, keep warnings as "system message" paragraphs in the built documents. 102 | # keep_warnings = False 103 | 104 | # If true, `todo` and `todoList` produce output, else they produce nothing. 105 | todo_include_todos = False 106 | 107 | 108 | # -- Options for HTML output ---------------------------------------------- 109 | 110 | # The theme to use for HTML and HTML Help pages. See the documentation for 111 | # a list of builtin themes. 112 | html_theme = "sphinx_rtd_theme" 113 | 114 | # Theme options are theme-specific and customize the look and feel of a theme 115 | # further. For a list of options available for each theme, see the 116 | # documentation. 117 | # html_theme_options = {} 118 | 119 | # Add any paths that contain custom themes here, relative to this directory. 120 | # html_theme_path = [] 121 | 122 | # The name for this set of Sphinx documents. If None, it defaults to 123 | # " v documentation". 124 | # html_title = None 125 | 126 | # A shorter title for the navigation bar. Default is the same as html_title. 127 | # html_short_title = None 128 | 129 | # The name of an image file (relative to this directory) to place at the top 130 | # of the sidebar. 131 | # html_logo = None 132 | 133 | # The name of an image file (within the static path) to use as favicon of the 134 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 135 | # pixels large. 136 | # html_favicon = None 137 | 138 | # Add any paths that contain custom static files (such as style sheets) here, 139 | # relative to this directory. They are copied after the builtin static files, 140 | # so a file named "default.css" will overwrite the builtin "default.css". 141 | html_static_path = [] 142 | 143 | # Add any extra paths that contain custom files (such as robots.txt or 144 | # .htaccess) here, relative to this directory. These files are copied 145 | # directly to the root of the documentation. 146 | # html_extra_path = [] 147 | 148 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 149 | # using the given strftime format. 150 | # html_last_updated_fmt = '%b %d, %Y' 151 | 152 | # If true, SmartyPants will be used to convert quotes and dashes to 153 | # typographically correct entities. 154 | # html_use_smartypants = True 155 | 156 | # Custom sidebar templates, maps document names to template names. 157 | # html_sidebars = {} 158 | 159 | # Additional templates that should be rendered to pages, maps page names to 160 | # template names. 161 | # html_additional_pages = {} 162 | 163 | # If false, no module index is generated. 164 | # html_domain_indices = True 165 | 166 | # If false, no index is generated. 167 | # html_use_index = True 168 | 169 | # If true, the index is split into individual pages for each letter. 170 | # html_split_index = False 171 | 172 | # If true, links to the reST sources are added to the pages. 173 | # html_show_sourcelink = True 174 | 175 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 176 | # html_show_sphinx = True 177 | 178 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 179 | # html_show_copyright = True 180 | 181 | # If true, an OpenSearch description file will be output, and all pages will 182 | # contain a tag referring to it. The value of this option must be the 183 | # base URL from which the finished HTML is served. 184 | # html_use_opensearch = '' 185 | 186 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 187 | # html_file_suffix = None 188 | 189 | # Language to be used for generating the HTML full-text search index. 190 | # Sphinx supports the following languages: 191 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 192 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 193 | # html_search_language = 'en' 194 | 195 | # A dictionary with options for the search language support, empty by default. 196 | # Now only 'ja' uses this config value 197 | # html_search_options = {'type': 'default'} 198 | 199 | # The name of a javascript file (relative to the configuration directory) that 200 | # implements a search results scorer. If empty, the default will be used. 201 | # html_search_scorer = 'scorer.js' 202 | 203 | # Output file base name for HTML help builder. 204 | htmlhelp_basename = "package_templatedoc" 205 | 206 | # -- Options for LaTeX output --------------------------------------------- 207 | 208 | latex_elements = { 209 | # The paper size ('letterpaper' or 'a4paper'). 210 | # 'papersize': 'letterpaper', 211 | # The font size ('10pt', '11pt' or '12pt'). 212 | # 'pointsize': '10pt', 213 | # Additional stuff for the LaTeX preamble. 214 | # 'preamble': '', 215 | # Latex figure (float) alignment 216 | # 'figure_align': 'htbp', 217 | } 218 | 219 | # Grouping the document tree into LaTeX files. List of tuples 220 | # (source start file, target name, title, 221 | # author, documentclass [howto, manual, or own class]). 222 | latex_documents = [ 223 | ( 224 | master_doc, 225 | "package_template.tex", 226 | "package\\_template Documentation", 227 | "Computational Modelling Group", 228 | "manual", 229 | ) 230 | ] 231 | 232 | # The name of an image file (relative to this directory) to place at the top of 233 | # the title page. 234 | # latex_logo = None 235 | 236 | # For "manual" documents, if this is true, then toplevel headings are parts, 237 | # not chapters. 238 | # latex_use_parts = False 239 | 240 | # If true, show page references after internal links. 241 | # latex_show_pagerefs = False 242 | 243 | # If true, show URL addresses after external links. 244 | # latex_show_urls = False 245 | 246 | # Documents to append as an appendix to all manuals. 247 | # latex_appendices = [] 248 | 249 | # If false, no module index is generated. 250 | # latex_domain_indices = True 251 | 252 | 253 | # -- Options for manual page output --------------------------------------- 254 | 255 | # One entry per manual page. List of tuples 256 | # (source start file, name, description, authors, manual section). 257 | man_pages = [ 258 | (master_doc, "package_template", "package_template Documentation", [author], 1) 259 | ] 260 | 261 | # If true, show URL addresses after external links. 262 | # man_show_urls = False 263 | 264 | 265 | # -- Options for Texinfo output ------------------------------------------- 266 | 267 | # Grouping the document tree into Texinfo files. List of tuples 268 | # (source start file, target name, title, author, 269 | # dir menu entry, description, category) 270 | texinfo_documents = [ 271 | ( 272 | master_doc, 273 | "package_template", 274 | "package_template Documentation", 275 | author, 276 | "package_template", 277 | "One line description of project.", 278 | "Miscellaneous", 279 | ) 280 | ] 281 | 282 | # Documents to append as an appendix to all manuals. 283 | # texinfo_appendices = [] 284 | 285 | # If false, no module index is generated. 286 | # texinfo_domain_indices = True 287 | 288 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 289 | # texinfo_show_urls = 'footnote' 290 | 291 | # If true, do not generate a @detailmenu in the "Top" node's menu. 292 | # texinfo_no_detailmenu = False 293 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. image:: ../media/logo_v1-grey.png 2 | :scale: 40 % 3 | :align: center 4 | :alt: PyPortfolioOpt 5 | 6 | .. raw:: html 7 | 8 | 9 | 10 | 11 | 12 | 13 |

14 | 15 | python   17 | 18 | python   20 | 21 | MIT license   23 | 24 | MIT license   26 | 27 | DOI badge   29 |

30 | 31 | 32 | 33 | PyPortfolioOpt is a library that implements portfolio optimization methods, including 34 | classical efficient frontier techniques and Black-Litterman allocation, as well as more 35 | recent developments in the field like shrinkage and Hierarchical Risk Parity, along with 36 | some novel experimental features like exponentially-weighted covariance matrices. 37 | 38 | It is **extensive** yet easily 39 | **extensible**, and can be useful for both the casual investor and the serious 40 | practitioner. Whether you are a fundamentals-oriented investor who has identified a 41 | handful of undervalued picks, or an algorithmic trader who has a basket of 42 | strategies, PyPortfolioOpt can help you combine your alpha sources 43 | in a risk-efficient way. 44 | 45 | 46 | Installation 47 | ============ 48 | 49 | If you would like to play with PyPortfolioOpt interactively in your browser, you may launch Binder 50 | `here `__. It takes a 51 | while to set up, but it lets you try out the cookbook recipes without having to install anything. 52 | 53 | Prior to installing PyPortfolioOpt, you need to install C++. On macOS, this means that you need 54 | to install XCode Command Line Tools (see `here `__). 55 | 56 | For Windows users, download Visual Studio `here `__, 57 | with additional instructions `here `__. 58 | 59 | Installation can then be done via pip:: 60 | 61 | pip install PyPortfolioOpt 62 | 63 | (you may need to follow separate installation instructions for `cvxopt `__ and `cvxpy `__). 64 | 65 | For the sake of best practice, it is good to do this with a dependency manager. I suggest you 66 | set yourself up with `poetry `_, then within a new poetry project 67 | run: 68 | 69 | .. code-block:: text 70 | 71 | poetry add PyPortfolioOpt 72 | 73 | The alternative is to clone/download the project, then in the project directory run 74 | 75 | .. code-block:: text 76 | 77 | python setup.py install 78 | 79 | Thanks to Thomas Schmelzer, PyPortfolioOpt now supports Docker (requires 80 | **make**, **docker**, **docker-compose**). Build your first container with 81 | ``make build``; run tests with ``make test``. For more information, please read 82 | `this guide `_. 83 | 84 | 85 | .. note:: 86 | If any of these methods don't work, please `raise an issue 87 | `_ with the 'packaging' label on GitHub 88 | 89 | 90 | 91 | For developers 92 | -------------- 93 | 94 | If you are planning on using PyPortfolioOpt as a starting template for significant 95 | modifications, it probably makes sense to clone the repository and to just use the 96 | source code 97 | 98 | .. code-block:: text 99 | 100 | git clone https://github.com/robertmartin8/PyPortfolioOpt 101 | 102 | Alternatively, if you still want the convenience of a global ``from pypfopt import x``, 103 | you should try 104 | 105 | .. code-block:: text 106 | 107 | pip install -e git+https://github.com/robertmartin8/PyPortfolioOpt.git 108 | 109 | 110 | A Quick Example 111 | =============== 112 | 113 | This section contains a quick look at what PyPortfolioOpt can do. For a guided tour, 114 | please check out the :ref:`user-guide`. For even more examples, check out the Jupyter 115 | notebooks in the `cookbook `_. 116 | 117 | If you already have expected returns ``mu`` and a risk model ``S`` for your set of 118 | assets, generating an optimal portfolio is as easy as:: 119 | 120 | from pypfopt.efficient_frontier import EfficientFrontier 121 | 122 | ef = EfficientFrontier(mu, S) 123 | weights = ef.max_sharpe() 124 | 125 | However, if you would like to use PyPortfolioOpt's built-in methods for 126 | calculating the expected returns and covariance matrix from historical data, 127 | that's fine too:: 128 | 129 | import pandas as pd 130 | from pypfopt.efficient_frontier import EfficientFrontier 131 | from pypfopt import risk_models 132 | from pypfopt import expected_returns 133 | 134 | # Read in price data 135 | df = pd.read_csv("tests/resources/stock_prices.csv", parse_dates=True, index_col="date") 136 | 137 | # Calculate expected returns and sample covariance 138 | mu = expected_returns.mean_historical_return(df) 139 | S = risk_models.sample_cov(df) 140 | 141 | # Optimize for maximal Sharpe ratio 142 | ef = EfficientFrontier(mu, S) 143 | weights = ef.max_sharpe() 144 | ef.portfolio_performance(verbose=True) 145 | 146 | This outputs the following: 147 | 148 | .. code-block:: text 149 | 150 | Expected annual return: 33.0% 151 | Annual volatility: 21.7% 152 | Sharpe Ratio: 1.43 153 | 154 | 155 | Contents 156 | ======== 157 | 158 | .. toctree:: 159 | :maxdepth: 2 160 | 161 | UserGuide 162 | ExpectedReturns 163 | RiskModels 164 | MeanVariance 165 | GeneralEfficientFrontier 166 | BlackLitterman 167 | OtherOptimizers 168 | Postprocessing 169 | Plotting 170 | 171 | .. toctree:: 172 | :maxdepth: 1 173 | :caption: Other information 174 | 175 | FAQ 176 | Roadmap 177 | Citing 178 | Contributing 179 | About 180 | 181 | Project principles and design decisions 182 | ======================================= 183 | 184 | - It should be easy to swap out individual components of the optimization process 185 | with the user's proprietary improvements. 186 | - Usability is everything: it is better to be self-explanatory than consistent. 187 | - There is no point in portfolio optimization unless it can be practically 188 | applied to real asset prices. 189 | - Everything that has been implemented should be tested. 190 | - Inline documentation is good: dedicated (separate) documentation is better. 191 | The two are not mutually exclusive. 192 | - Formatting should never get in the way of good code: because of this, 193 | I have deferred **all** formatting decisions to `Black 194 | `_. 195 | 196 | 197 | Advantages over existing implementations 198 | ======================================== 199 | 200 | - Includes both classical methods (Markowitz 1952 and Black-Litterman), suggested best practices 201 | (e.g covariance shrinkage), along with many recent developments and novel 202 | features, like L2 regularisation, exponential covariance, hierarchical risk parity. 203 | - Native support for pandas dataframes: easily input your daily prices data. 204 | - Extensive practical tests, which use real-life data. 205 | - Easy to combine with your proprietary strategies and models. 206 | - Robust to missing data, and price-series of different lengths (e.g FB data 207 | only goes back to 2012 whereas AAPL data goes back to 1980). 208 | 209 | 210 | Contributors 211 | ============= 212 | 213 | This is a non-exhaustive unordered list of contributors. I am sincerely grateful for all 214 | of your efforts! 215 | 216 | - Philipp Schiele 217 | - Carl Peasnell 218 | - Felipe Schneider 219 | - Dingyuan Wang 220 | - Pat Newell 221 | - Aditya Bhutra 222 | - Thomas Schmelzer 223 | - Rich Caputo 224 | - Nicolas Knudde 225 | 226 | 227 | Indices and tables 228 | ================== 229 | 230 | * :ref:`genindex` 231 | * :ref:`modindex` 232 | * :ref:`search` 233 | -------------------------------------------------------------------------------- /example/examples.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | from pypfopt import ( 5 | CLA, 6 | BlackLittermanModel, 7 | EfficientFrontier, 8 | HRPOpt, 9 | black_litterman, 10 | expected_returns, 11 | plotting, 12 | risk_models, 13 | ) 14 | 15 | # Reading in the data; preparing expected returns and a risk model 16 | df = pd.read_csv("tests/resources/stock_prices.csv", parse_dates=True, index_col="date") 17 | returns = df.pct_change().dropna() 18 | mu = expected_returns.mean_historical_return(df) 19 | S = risk_models.sample_cov(df) 20 | 21 | 22 | # Now try with a nonconvex objective from Kolm et al (2014) 23 | def deviation_risk_parity(w, cov_matrix): 24 | diff = w * np.dot(cov_matrix, w) - (w * np.dot(cov_matrix, w)).reshape(-1, 1) 25 | return (diff**2).sum().sum() 26 | 27 | 28 | ef = EfficientFrontier(mu, S) 29 | weights = ef.nonconvex_objective(deviation_risk_parity, ef.cov_matrix) 30 | ef.portfolio_performance(verbose=True) 31 | 32 | """ 33 | Expected annual return: 22.9% 34 | Annual volatility: 19.2% 35 | Sharpe Ratio: 1.09 36 | """ 37 | 38 | # Black-Litterman 39 | spy_prices = pd.read_csv( 40 | "tests/resources/spy_prices.csv", parse_dates=True, index_col=0, squeeze=True 41 | ) 42 | delta = black_litterman.market_implied_risk_aversion(spy_prices) 43 | 44 | mcaps = { 45 | "GOOG": 927e9, 46 | "AAPL": 1.19e12, 47 | "FB": 574e9, 48 | "BABA": 533e9, 49 | "AMZN": 867e9, 50 | "GE": 96e9, 51 | "AMD": 43e9, 52 | "WMT": 339e9, 53 | "BAC": 301e9, 54 | "GM": 51e9, 55 | "T": 61e9, 56 | "UAA": 78e9, 57 | "SHLD": 0, 58 | "XOM": 295e9, 59 | "RRC": 1e9, 60 | "BBY": 22e9, 61 | "MA": 288e9, 62 | "PFE": 212e9, 63 | "JPM": 422e9, 64 | "SBUX": 102e9, 65 | } 66 | prior = black_litterman.market_implied_prior_returns(mcaps, delta, S) 67 | 68 | # 1. SBUX will drop by 20% 69 | # 2. GOOG outperforms FB by 10% 70 | # 3. BAC and JPM will outperform T and GE by 15% 71 | views = np.array([-0.20, 0.10, 0.15]).reshape(-1, 1) 72 | picking = np.array( 73 | [ 74 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], 75 | [1, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 76 | [0, 0, 0, 0, 0, -0.5, 0, 0, 0.5, 0, -0.5, 0, 0, 0, 0, 0, 0, 0, 0.5, 0], 77 | ] 78 | ) 79 | bl = BlackLittermanModel(S, Q=views, P=picking, pi=prior, tau=0.01) 80 | rets = bl.bl_returns() 81 | ef = EfficientFrontier(rets, S) 82 | ef.max_sharpe() 83 | print(ef.clean_weights()) 84 | ef.portfolio_performance(verbose=True) 85 | 86 | """ 87 | {'GOOG': 0.2015, 88 | 'AAPL': 0.2368, 89 | 'FB': 0.0, 90 | 'BABA': 0.06098, 91 | 'AMZN': 0.17148, 92 | 'GE': 0.0, 93 | 'AMD': 0.0, 94 | 'WMT': 0.0, 95 | 'BAC': 0.18545, 96 | 'GM': 0.0, 97 | 'T': 0.0, 98 | 'UAA': 0.0, 99 | 'SHLD': 0.0, 100 | 'XOM': 0.0, 101 | 'RRC': 0.0, 102 | 'BBY': 0.0, 103 | 'MA': 0.0, 104 | 'PFE': 0.0, 105 | 'JPM': 0.14379, 106 | 'SBUX': 0.0} 107 | 108 | Expected annual return: 15.3% 109 | Annual volatility: 28.7% 110 | Sharpe Ratio: 0.46 111 | """ 112 | 113 | 114 | # Hierarchical risk parity 115 | hrp = HRPOpt(returns) 116 | weights = hrp.optimize() 117 | hrp.portfolio_performance(verbose=True) 118 | print(weights) 119 | plotting.plot_dendrogram(hrp) # to plot dendrogram 120 | 121 | """ 122 | Expected annual return: 10.8% 123 | Annual volatility: 13.2% 124 | Sharpe Ratio: 0.66 125 | 126 | {'AAPL': 0.022258941278778397, 127 | 'AMD': 0.02229402179669211, 128 | 'AMZN': 0.016086842079875, 129 | 'BABA': 0.07963382071794091, 130 | 'BAC': 0.014409222455552262, 131 | 'BBY': 0.0340641943824504, 132 | 'FB': 0.06272994714663534, 133 | 'GE': 0.05519063444162849, 134 | 'GM': 0.05557666024185722, 135 | 'GOOG': 0.049560084289929286, 136 | 'JPM': 0.017675709092515708, 137 | 'MA': 0.03812737349732021, 138 | 'PFE': 0.07786528342813454, 139 | 'RRC': 0.03161528695094597, 140 | 'SBUX': 0.039844436656239136, 141 | 'SHLD': 0.027113184241298865, 142 | 'T': 0.11138956508836476, 143 | 'UAA': 0.02711590957075009, 144 | 'WMT': 0.10569551148587905, 145 | 'XOM': 0.11175337115721229} 146 | """ 147 | 148 | 149 | # Crticial Line Algorithm 150 | cla = CLA(mu, S) 151 | print(cla.max_sharpe()) 152 | cla.portfolio_performance(verbose=True) 153 | plotting.plot_efficient_frontier(cla) # to plot 154 | 155 | """ 156 | {'GOOG': 0.020889868669945022, 157 | 'AAPL': 0.08867994115132602, 158 | 'FB': 0.19417572932251745, 159 | 'BABA': 0.10492386821217001, 160 | 'AMZN': 0.0644908140418782, 161 | 'GE': 0.0, 162 | 'AMD': 0.0, 163 | 'WMT': 0.0034898157701416382, 164 | 'BAC': 0.0, 165 | 'GM': 0.0, 166 | 'T': 2.4138966206946562e-19, 167 | 'UAA': 0.0, 168 | 'SHLD': 0.0, 169 | 'XOM': 0.0005100736411646903, 170 | 'RRC': 0.0, 171 | 'BBY': 0.05967818998203106, 172 | 'MA': 0.23089949598834422, 173 | 'PFE': 0.19125123325029705, 174 | 'JPM': 0.0, 175 | 'SBUX': 0.041010969970184656} 176 | 177 | Expected annual return: 32.5% 178 | Annual volatility: 21.3% 179 | Sharpe Ratio: 1.43 180 | """ 181 | -------------------------------------------------------------------------------- /media/cla_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/cla_plot.png -------------------------------------------------------------------------------- /media/conceptual_flowchart_v1-grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/conceptual_flowchart_v1-grey.png -------------------------------------------------------------------------------- /media/conceptual_flowchart_v1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/conceptual_flowchart_v1.png -------------------------------------------------------------------------------- /media/conceptual_flowchart_v2-grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/conceptual_flowchart_v2-grey.png -------------------------------------------------------------------------------- /media/conceptual_flowchart_v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/conceptual_flowchart_v2.png -------------------------------------------------------------------------------- /media/corrplot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/corrplot.png -------------------------------------------------------------------------------- /media/corrplot_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/corrplot_white.png -------------------------------------------------------------------------------- /media/dendrogram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/dendrogram.png -------------------------------------------------------------------------------- /media/ef_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/ef_plot.png -------------------------------------------------------------------------------- /media/ef_scatter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/ef_scatter.png -------------------------------------------------------------------------------- /media/efficient_frontier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/efficient_frontier.png -------------------------------------------------------------------------------- /media/efficient_frontier_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/efficient_frontier_white.png -------------------------------------------------------------------------------- /media/logo_v0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/logo_v0.png -------------------------------------------------------------------------------- /media/logo_v1-grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/logo_v1-grey.png -------------------------------------------------------------------------------- /media/logo_v1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/logo_v1.png -------------------------------------------------------------------------------- /media/weight_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/media/weight_plot.png -------------------------------------------------------------------------------- /pypfopt/__init__.py: -------------------------------------------------------------------------------- 1 | from .black_litterman import ( 2 | BlackLittermanModel, 3 | market_implied_prior_returns, 4 | market_implied_risk_aversion, 5 | ) 6 | from .cla import CLA 7 | from .discrete_allocation import DiscreteAllocation, get_latest_prices 8 | from .efficient_frontier import ( 9 | EfficientCDaR, 10 | EfficientCVaR, 11 | EfficientFrontier, 12 | EfficientSemivariance, 13 | ) 14 | from .hierarchical_portfolio import HRPOpt 15 | from .risk_models import CovarianceShrinkage 16 | 17 | __version__ = "1.5.6" 18 | 19 | __all__ = [ 20 | "market_implied_prior_returns", 21 | "market_implied_risk_aversion", 22 | "BlackLittermanModel", 23 | "CLA", 24 | "get_latest_prices", 25 | "DiscreteAllocation", 26 | "EfficientFrontier", 27 | "EfficientSemivariance", 28 | "EfficientCVaR", 29 | "EfficientCDaR", 30 | "HRPOpt", 31 | "CovarianceShrinkage", 32 | ] 33 | -------------------------------------------------------------------------------- /pypfopt/efficient_frontier/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The ``efficient_frontier`` module houses the EfficientFrontier class and its descendants, 3 | which generate optimal portfolios for various possible objective functions and parameters. 4 | """ 5 | 6 | from .efficient_cdar import EfficientCDaR 7 | from .efficient_cvar import EfficientCVaR 8 | from .efficient_frontier import EfficientFrontier 9 | from .efficient_semivariance import EfficientSemivariance 10 | 11 | __all__ = [ 12 | "EfficientFrontier", 13 | "EfficientCVaR", 14 | "EfficientSemivariance", 15 | "EfficientCDaR", 16 | ] 17 | -------------------------------------------------------------------------------- /pypfopt/efficient_frontier/efficient_cdar.py: -------------------------------------------------------------------------------- 1 | """ 2 | The ``efficient_cdar`` submodule houses the EfficientCDaR class, which 3 | generates portfolios along the mean-CDaR (conditional drawdown-at-risk) frontier. 4 | """ 5 | 6 | import warnings 7 | 8 | import cvxpy as cp 9 | import numpy as np 10 | 11 | from .. import objective_functions 12 | from .efficient_frontier import EfficientFrontier 13 | 14 | 15 | class EfficientCDaR(EfficientFrontier): 16 | """ 17 | The EfficientCDaR class allows for optimisation along the mean-CDaR frontier, using the 18 | formulation of Chekhlov, Ursayev and Zabarankin (2005). 19 | 20 | Instance variables: 21 | 22 | - Inputs: 23 | 24 | - ``n_assets`` - int 25 | - ``tickers`` - str list 26 | - ``bounds`` - float tuple OR (float tuple) list 27 | - ``returns`` - pd.DataFrame 28 | - ``expected_returns`` - np.ndarray 29 | - ``solver`` - str 30 | - ``solver_options`` - {str: str} dict 31 | 32 | - Output: ``weights`` - np.ndarray 33 | 34 | Public methods: 35 | 36 | - ``min_cdar()`` minimises the CDaR 37 | - ``efficient_risk()`` maximises return for a given CDaR 38 | - ``efficient_return()`` minimises CDaR for a given target return 39 | - ``add_objective()`` adds a (convex) objective to the optimisation problem 40 | - ``add_constraint()`` adds a (linear) constraint to the optimisation problem 41 | 42 | - ``portfolio_performance()`` calculates the expected return and CDaR of the portfolio 43 | - ``set_weights()`` creates self.weights (np.ndarray) from a weights dict 44 | - ``clean_weights()`` rounds the weights and clips near-zeros. 45 | - ``save_weights_to_file()`` saves the weights to csv, json, or txt. 46 | """ 47 | 48 | def __init__( 49 | self, 50 | expected_returns, 51 | returns, 52 | beta=0.95, 53 | weight_bounds=(0, 1), 54 | solver=None, 55 | verbose=False, 56 | solver_options=None, 57 | ): 58 | """ 59 | :param expected_returns: expected returns for each asset. Can be None if 60 | optimising for CDaR only. 61 | :type expected_returns: pd.Series, list, np.ndarray 62 | :param returns: (historic) returns for all your assets (no NaNs). 63 | See ``expected_returns.returns_from_prices``. 64 | :type returns: pd.DataFrame or np.array 65 | :param beta: confidence level, defaults to 0.95 (i.e expected drawdown on the worst (1-beta) days). 66 | :param weight_bounds: minimum and maximum weight of each asset OR single min/max pair 67 | if all identical, defaults to (0, 1). Must be changed to (-1, 1) 68 | for portfolios with shorting. 69 | :type weight_bounds: tuple OR tuple list, optional 70 | :param solver: name of solver. list available solvers with: `cvxpy.installed_solvers()` 71 | :type solver: str 72 | :param verbose: whether performance and debugging info should be printed, defaults to False 73 | :type verbose: bool, optional 74 | :param solver_options: parameters for the given solver 75 | :type solver_options: dict, optional 76 | :raises TypeError: if ``expected_returns`` is not a series, list or array 77 | """ 78 | super().__init__( 79 | expected_returns=expected_returns, 80 | cov_matrix=np.zeros((len(expected_returns),) * 2), # dummy 81 | weight_bounds=weight_bounds, 82 | solver=solver, 83 | verbose=verbose, 84 | solver_options=solver_options, 85 | ) 86 | 87 | self.returns = self._validate_returns(returns) 88 | self._beta = self._validate_beta(beta) 89 | self._alpha = cp.Variable() 90 | self._u = cp.Variable(len(self.returns) + 1) 91 | self._z = cp.Variable(len(self.returns)) 92 | 93 | def set_weights(self, input_weights): 94 | raise NotImplementedError("Method not available in EfficientCDaR.") 95 | 96 | @staticmethod 97 | def _validate_beta(beta): 98 | if not (0 <= beta < 1): 99 | raise ValueError("beta must be between 0 and 1") 100 | if beta <= 0.2: 101 | warnings.warn( 102 | "Warning: beta is the confidence-level, not the quantile. Typical values are 80%, 90%, 95%.", 103 | UserWarning, 104 | ) 105 | return beta 106 | 107 | def min_volatility(self): 108 | raise NotImplementedError("Please use min_cdar instead.") 109 | 110 | def max_sharpe(self, risk_free_rate=0.0): 111 | raise NotImplementedError("Method not available in EfficientCDaR.") 112 | 113 | def max_quadratic_utility(self, risk_aversion=1, market_neutral=False): 114 | raise NotImplementedError("Method not available in EfficientCDaR.") 115 | 116 | def min_cdar(self, market_neutral=False): 117 | """ 118 | Minimise portfolio CDaR (see docs for further explanation). 119 | 120 | :param market_neutral: whether the portfolio should be market neutral (weights sum to zero), 121 | defaults to False. Requires negative lower weight bound. 122 | :param market_neutral: bool, optional 123 | :return: asset weights for the volatility-minimising portfolio 124 | :rtype: OrderedDict 125 | """ 126 | self._objective = self._alpha + 1.0 / ( 127 | len(self.returns) * (1 - self._beta) 128 | ) * cp.sum(self._z) 129 | 130 | for obj in self._additional_objectives: 131 | self._objective += obj 132 | 133 | self._add_cdar_constraints() 134 | self._make_weight_sum_constraint(market_neutral) 135 | return self._solve_cvxpy_opt_problem() 136 | 137 | def efficient_return(self, target_return, market_neutral=False): 138 | """ 139 | Minimise CDaR for a given target return. 140 | 141 | :param target_return: the desired return of the resulting portfolio. 142 | :type target_return: float 143 | :param market_neutral: whether the portfolio should be market neutral (weights sum to zero), 144 | defaults to False. Requires negative lower weight bound. 145 | :type market_neutral: bool, optional 146 | :raises ValueError: if ``target_return`` is not a positive float 147 | :raises ValueError: if no portfolio can be found with return equal to ``target_return`` 148 | :return: asset weights for the optimal portfolio 149 | :rtype: OrderedDict 150 | """ 151 | 152 | update_existing_parameter = self.is_parameter_defined("target_return") 153 | if update_existing_parameter: 154 | self._validate_market_neutral(market_neutral) 155 | self.update_parameter_value("target_return", target_return) 156 | return self._solve_cvxpy_opt_problem() 157 | else: 158 | ret = self.expected_returns.T @ self._w 159 | target_return_par = cp.Parameter( 160 | value=target_return, name="target_return", nonneg=True 161 | ) 162 | self.add_constraint(lambda _: ret >= target_return_par) 163 | return self.min_cdar(market_neutral) 164 | 165 | def efficient_risk(self, target_cdar, market_neutral=False): 166 | """ 167 | Maximise return for a target CDaR. 168 | The resulting portfolio will have a CDaR less than the target 169 | (but not guaranteed to be equal). 170 | 171 | :param target_cdar: the desired maximum CDaR of the resulting portfolio. 172 | :type target_cdar: float 173 | :param market_neutral: whether the portfolio should be market neutral (weights sum to zero), 174 | defaults to False. Requires negative lower weight bound. 175 | :param market_neutral: bool, optional 176 | :return: asset weights for the efficient risk portfolio 177 | :rtype: OrderedDict 178 | """ 179 | 180 | update_existing_parameter = self.is_parameter_defined("target_cdar") 181 | if update_existing_parameter: 182 | self._validate_market_neutral(market_neutral) 183 | self.update_parameter_value("target_cdar", target_cdar) 184 | else: 185 | self._objective = objective_functions.portfolio_return( 186 | self._w, self.expected_returns 187 | ) 188 | for obj in self._additional_objectives: 189 | self._objective += obj 190 | 191 | cdar = self._alpha + 1.0 / (len(self.returns) * (1 - self._beta)) * cp.sum( 192 | self._z 193 | ) 194 | target_cdar_par = cp.Parameter( 195 | value=target_cdar, name="target_cdar", nonneg=True 196 | ) 197 | self.add_constraint(lambda _: cdar <= target_cdar_par) 198 | 199 | self._add_cdar_constraints() 200 | 201 | self._make_weight_sum_constraint(market_neutral) 202 | return self._solve_cvxpy_opt_problem() 203 | 204 | def _add_cdar_constraints(self) -> None: 205 | self.add_constraint(lambda _: self._z >= self._u[1:] - self._alpha) 206 | self.add_constraint( 207 | lambda w: self._u[1:] >= self._u[:-1] - self.returns.values @ w 208 | ) 209 | self.add_constraint(lambda _: self._u[0] == 0) 210 | self.add_constraint(lambda _: self._z >= 0) 211 | self.add_constraint(lambda _: self._u[1:] >= 0) 212 | 213 | def portfolio_performance(self, verbose=False): 214 | """ 215 | After optimising, calculate (and optionally print) the performance of the optimal 216 | portfolio, specifically: expected return, CDaR 217 | 218 | :param verbose: whether performance should be printed, defaults to False 219 | :type verbose: bool, optional 220 | :raises ValueError: if weights have not been calculated yet 221 | :return: expected return, CDaR. 222 | :rtype: (float, float) 223 | """ 224 | mu = objective_functions.portfolio_return( 225 | self.weights, self.expected_returns, negative=False 226 | ) 227 | 228 | cdar = self._alpha + 1.0 / (len(self.returns) * (1 - self._beta)) * cp.sum( 229 | self._z 230 | ) 231 | cdar_val = cdar.value 232 | 233 | if verbose: 234 | print("Expected annual return: {:.1f}%".format(100 * mu)) 235 | print("Conditional Drawdown at Risk: {:.2f}%".format(100 * cdar_val)) 236 | 237 | return mu, cdar_val 238 | -------------------------------------------------------------------------------- /pypfopt/efficient_frontier/efficient_cvar.py: -------------------------------------------------------------------------------- 1 | """ 2 | The ``efficient_cvar`` submodule houses the EfficientCVaR class, which 3 | generates portfolios along the mean-CVaR frontier. 4 | """ 5 | 6 | import warnings 7 | 8 | import cvxpy as cp 9 | import numpy as np 10 | 11 | from .. import objective_functions 12 | from .efficient_frontier import EfficientFrontier 13 | 14 | 15 | class EfficientCVaR(EfficientFrontier): 16 | """ 17 | The EfficientCVaR class allows for optimization along the mean-CVaR frontier, using the 18 | formulation of Rockafellar and Ursayev (2001). 19 | 20 | Instance variables: 21 | 22 | - Inputs: 23 | 24 | - ``n_assets`` - int 25 | - ``tickers`` - str list 26 | - ``bounds`` - float tuple OR (float tuple) list 27 | - ``returns`` - pd.DataFrame 28 | - ``expected_returns`` - np.ndarray 29 | - ``solver`` - str 30 | - ``solver_options`` - {str: str} dict 31 | 32 | 33 | - Output: ``weights`` - np.ndarray 34 | 35 | Public methods: 36 | 37 | - ``min_cvar()`` minimises the CVaR 38 | - ``efficient_risk()`` maximises return for a given CVaR 39 | - ``efficient_return()`` minimises CVaR for a given target return 40 | - ``add_objective()`` adds a (convex) objective to the optimization problem 41 | - ``add_constraint()`` adds a constraint to the optimization problem 42 | 43 | - ``portfolio_performance()`` calculates the expected return and CVaR of the portfolio 44 | - ``set_weights()`` creates self.weights (np.ndarray) from a weights dict 45 | - ``clean_weights()`` rounds the weights and clips near-zeros. 46 | - ``save_weights_to_file()`` saves the weights to csv, json, or txt. 47 | """ 48 | 49 | def __init__( 50 | self, 51 | expected_returns, 52 | returns, 53 | beta=0.95, 54 | weight_bounds=(0, 1), 55 | solver=None, 56 | verbose=False, 57 | solver_options=None, 58 | ): 59 | """ 60 | :param expected_returns: expected returns for each asset. Can be None if 61 | optimising for conditional value at risk only. 62 | :type expected_returns: pd.Series, list, np.ndarray 63 | :param returns: (historic) returns for all your assets (no NaNs). 64 | See ``expected_returns.returns_from_prices``. 65 | :type returns: pd.DataFrame or np.array 66 | :param beta: confidence level, defauls to 0.95 (i.e expected loss on the worst (1-beta) days). 67 | :param weight_bounds: minimum and maximum weight of each asset OR single min/max pair 68 | if all identical, defaults to (0, 1). Must be changed to (-1, 1) 69 | for portfolios with shorting. 70 | :type weight_bounds: tuple OR tuple list, optional 71 | :param solver: name of solver. list available solvers with: `cvxpy.installed_solvers()` 72 | :type solver: str 73 | :param verbose: whether performance and debugging info should be printed, defaults to False 74 | :type verbose: bool, optional 75 | :param solver_options: parameters for the given solver 76 | :type solver_options: dict, optional 77 | :raises TypeError: if ``expected_returns`` is not a series, list or array 78 | """ 79 | super().__init__( 80 | expected_returns=expected_returns, 81 | cov_matrix=np.zeros((returns.shape[1],) * 2), # dummy 82 | weight_bounds=weight_bounds, 83 | solver=solver, 84 | verbose=verbose, 85 | solver_options=solver_options, 86 | ) 87 | 88 | self.returns = self._validate_returns(returns) 89 | self._beta = self._validate_beta(beta) 90 | self._alpha = cp.Variable() 91 | self._u = cp.Variable(len(self.returns)) 92 | 93 | def set_weights(self, input_weights): 94 | raise NotImplementedError("Method not available in EfficientCVaR.") 95 | 96 | @staticmethod 97 | def _validate_beta(beta): 98 | if not (0 <= beta < 1): 99 | raise ValueError("beta must be between 0 and 1") 100 | if beta <= 0.2: 101 | warnings.warn( 102 | "Warning: beta is the confidence-level, not the quantile. Typical values are 80%, 90%, 95%.", 103 | UserWarning, 104 | ) 105 | return beta 106 | 107 | def min_volatility(self): 108 | raise NotImplementedError("Please use min_cvar instead.") 109 | 110 | def max_sharpe(self, risk_free_rate=0.0): 111 | raise NotImplementedError("Method not available in EfficientCVaR.") 112 | 113 | def max_quadratic_utility(self, risk_aversion=1, market_neutral=False): 114 | raise NotImplementedError("Method not available in EfficientCVaR.") 115 | 116 | def min_cvar(self, market_neutral=False): 117 | """ 118 | Minimise portfolio CVaR (see docs for further explanation). 119 | 120 | :param market_neutral: whether the portfolio should be market neutral (weights sum to zero), 121 | defaults to False. Requires negative lower weight bound. 122 | :param market_neutral: bool, optional 123 | :return: asset weights for the volatility-minimising portfolio 124 | :rtype: OrderedDict 125 | """ 126 | self._objective = self._alpha + 1.0 / ( 127 | len(self.returns) * (1 - self._beta) 128 | ) * cp.sum(self._u) 129 | 130 | for obj in self._additional_objectives: 131 | self._objective += obj 132 | 133 | self.add_constraint(lambda _: self._u >= 0.0) 134 | self.add_constraint( 135 | lambda w: self.returns.values @ w + self._alpha + self._u >= 0.0 136 | ) 137 | 138 | self._make_weight_sum_constraint(market_neutral) 139 | return self._solve_cvxpy_opt_problem() 140 | 141 | def efficient_return(self, target_return, market_neutral=False): 142 | """ 143 | Minimise CVaR for a given target return. 144 | 145 | :param target_return: the desired return of the resulting portfolio. 146 | :type target_return: float 147 | :param market_neutral: whether the portfolio should be market neutral (weights sum to zero), 148 | defaults to False. Requires negative lower weight bound. 149 | :type market_neutral: bool, optional 150 | :raises ValueError: if ``target_return`` is not a positive float 151 | :raises ValueError: if no portfolio can be found with return equal to ``target_return`` 152 | :return: asset weights for the optimal portfolio 153 | :rtype: OrderedDict 154 | """ 155 | update_existing_parameter = self.is_parameter_defined("target_return") 156 | if update_existing_parameter: 157 | self._validate_market_neutral(market_neutral) 158 | self.update_parameter_value("target_return", target_return) 159 | else: 160 | self._objective = self._alpha + 1.0 / ( 161 | len(self.returns) * (1 - self._beta) 162 | ) * cp.sum(self._u) 163 | 164 | for obj in self._additional_objectives: 165 | self._objective += obj 166 | 167 | self.add_constraint(lambda _: self._u >= 0.0) 168 | self.add_constraint( 169 | lambda w: self.returns.values @ w + self._alpha + self._u >= 0.0 170 | ) 171 | 172 | ret = self.expected_returns.T @ self._w 173 | target_return_par = cp.Parameter(name="target_return", value=target_return) 174 | self.add_constraint(lambda _: ret >= target_return_par) 175 | 176 | self._make_weight_sum_constraint(market_neutral) 177 | return self._solve_cvxpy_opt_problem() 178 | 179 | def efficient_risk(self, target_cvar, market_neutral=False): 180 | """ 181 | Maximise return for a target CVaR. 182 | The resulting portfolio will have a CVaR less than the target 183 | (but not guaranteed to be equal). 184 | 185 | :param target_cvar: the desired conditional value at risk of the resulting portfolio. 186 | :type target_cvar: float 187 | :param market_neutral: whether the portfolio should be market neutral (weights sum to zero), 188 | defaults to False. Requires negative lower weight bound. 189 | :param market_neutral: bool, optional 190 | :return: asset weights for the efficient risk portfolio 191 | :rtype: OrderedDict 192 | """ 193 | update_existing_parameter = self.is_parameter_defined("target_cvar") 194 | if update_existing_parameter: 195 | self._validate_market_neutral(market_neutral) 196 | self.update_parameter_value("target_cvar", target_cvar) 197 | else: 198 | self._objective = objective_functions.portfolio_return( 199 | self._w, self.expected_returns 200 | ) 201 | for obj in self._additional_objectives: 202 | self._objective += obj 203 | 204 | cvar = self._alpha + 1.0 / (len(self.returns) * (1 - self._beta)) * cp.sum( 205 | self._u 206 | ) 207 | target_cvar_par = cp.Parameter( 208 | value=target_cvar, name="target_cvar", nonneg=True 209 | ) 210 | 211 | self.add_constraint(lambda _: cvar <= target_cvar_par) 212 | self.add_constraint(lambda _: self._u >= 0.0) 213 | self.add_constraint( 214 | lambda w: self.returns.values @ w + self._alpha + self._u >= 0.0 215 | ) 216 | 217 | self._make_weight_sum_constraint(market_neutral) 218 | return self._solve_cvxpy_opt_problem() 219 | 220 | def portfolio_performance(self, verbose=False): 221 | """ 222 | After optimising, calculate (and optionally print) the performance of the optimal 223 | portfolio, specifically: expected return, CVaR 224 | 225 | :param verbose: whether performance should be printed, defaults to False 226 | :type verbose: bool, optional 227 | :raises ValueError: if weights have not been calculated yet 228 | :return: expected return, CVaR. 229 | :rtype: (float, float) 230 | """ 231 | mu = objective_functions.portfolio_return( 232 | self.weights, self.expected_returns, negative=False 233 | ) 234 | 235 | cvar = self._alpha + 1.0 / (len(self.returns) * (1 - self._beta)) * cp.sum( 236 | self._u 237 | ) 238 | cvar_val = cvar.value 239 | 240 | if verbose: 241 | print("Expected annual return: {:.1f}%".format(100 * mu)) 242 | print("Conditional Value at Risk: {:.2f}%".format(100 * cvar_val)) 243 | 244 | return mu, cvar_val 245 | -------------------------------------------------------------------------------- /pypfopt/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | The ``exceptions`` module houses custom exceptions. Currently implemented: 3 | 4 | - OptimizationError 5 | """ 6 | 7 | 8 | class OptimizationError(Exception): 9 | """ 10 | When an optimization routine fails – usually, this means 11 | that cvxpy has not returned the "optimal" flag. 12 | """ 13 | 14 | def __init__(self, *args, **kwargs): 15 | default_message = ( 16 | "Please check your objectives/constraints or use a different solver." 17 | ) 18 | super().__init__(default_message, *args, **kwargs) 19 | 20 | 21 | class InstantiationError(Exception): 22 | """ 23 | Errors related to the instantiation of pypfopt objects, e.g adding constraints to an 24 | already-solved problem 25 | """ 26 | 27 | pass 28 | -------------------------------------------------------------------------------- /pypfopt/expected_returns.py: -------------------------------------------------------------------------------- 1 | """ 2 | The ``expected_returns`` module provides functions for estimating the expected returns of 3 | the assets, which is a required input in mean-variance optimization. 4 | 5 | By convention, the output of these methods is expected *annual* returns. It is assumed that 6 | *daily* prices are provided, though in reality the functions are agnostic 7 | to the time period (just change the ``frequency`` parameter). Asset prices must be given as 8 | a pandas dataframe, as per the format described in the :ref:`user-guide`. 9 | 10 | All of the functions process the price data into percentage returns data, before 11 | calculating their respective estimates of expected returns. 12 | 13 | Currently implemented: 14 | 15 | - general return model function, allowing you to run any return model from one function. 16 | - mean historical return 17 | - exponentially weighted mean historical return 18 | - CAPM estimate of returns 19 | 20 | Additionally, we provide utility functions to convert from returns to prices and vice-versa. 21 | """ 22 | 23 | import warnings 24 | 25 | import numpy as np 26 | import pandas as pd 27 | 28 | 29 | def _check_returns(returns): 30 | # Check NaNs excluding leading NaNs 31 | if np.any(np.isnan(returns.mask(returns.ffill().isnull(), 0))): 32 | warnings.warn( 33 | "Some returns are NaN. Please check your price data.", UserWarning 34 | ) 35 | if np.any(np.isinf(returns)): 36 | warnings.warn( 37 | "Some returns are infinite. Please check your price data.", UserWarning 38 | ) 39 | 40 | 41 | def returns_from_prices(prices, log_returns=False): 42 | """ 43 | Calculate the returns given prices. 44 | 45 | :param prices: adjusted (daily) closing prices of the asset, each row is a 46 | date and each column is a ticker/id. 47 | :type prices: pd.DataFrame 48 | :param log_returns: whether to compute using log returns 49 | :type log_returns: bool, defaults to False 50 | :return: (daily) returns 51 | :rtype: pd.DataFrame 52 | """ 53 | if log_returns: 54 | returns = np.log(1 + prices.pct_change(fill_method=None)).dropna(how="all") 55 | else: 56 | returns = prices.pct_change(fill_method=None).dropna(how="all") 57 | return returns 58 | 59 | 60 | def prices_from_returns(returns, log_returns=False): 61 | """ 62 | Calculate the pseudo-prices given returns. These are not true prices because 63 | the initial prices are all set to 1, but it behaves as intended when passed 64 | to any PyPortfolioOpt method. 65 | 66 | :param returns: (daily) percentage returns of the assets 67 | :type returns: pd.DataFrame 68 | :param log_returns: whether to compute using log returns 69 | :type log_returns: bool, defaults to False 70 | :return: (daily) pseudo-prices. 71 | :rtype: pd.DataFrame 72 | """ 73 | if log_returns: 74 | ret = np.exp(returns) 75 | else: 76 | ret = 1 + returns 77 | ret.iloc[0] = 1 # set first day pseudo-price 78 | return ret.cumprod() 79 | 80 | 81 | def return_model(prices, method="mean_historical_return", **kwargs): 82 | """ 83 | Compute an estimate of future returns, using the return model specified in ``method``. 84 | 85 | :param prices: adjusted closing prices of the asset, each row is a date 86 | and each column is a ticker/id. 87 | :type prices: pd.DataFrame 88 | :param returns_data: if true, the first argument is returns instead of prices. 89 | :type returns_data: bool, defaults to False. 90 | :param method: the return model to use. Should be one of: 91 | 92 | - ``mean_historical_return`` 93 | - ``ema_historical_return`` 94 | - ``capm_return`` 95 | 96 | :type method: str, optional 97 | :raises NotImplementedError: if the supplied method is not recognised 98 | :return: annualised sample covariance matrix 99 | :rtype: pd.DataFrame 100 | """ 101 | if method == "mean_historical_return": 102 | return mean_historical_return(prices, **kwargs) 103 | elif method == "ema_historical_return": 104 | return ema_historical_return(prices, **kwargs) 105 | elif method == "capm_return": 106 | return capm_return(prices, **kwargs) 107 | else: 108 | raise NotImplementedError("Return model {} not implemented".format(method)) 109 | 110 | 111 | def mean_historical_return( 112 | prices, returns_data=False, compounding=True, frequency=252, log_returns=False 113 | ): 114 | """ 115 | Calculate annualised mean (daily) historical return from input (daily) asset prices. 116 | Use ``compounding`` to toggle between the default geometric mean (CAGR) and the 117 | arithmetic mean. 118 | 119 | :param prices: adjusted closing prices of the asset, each row is a date 120 | and each column is a ticker/id. 121 | :type prices: pd.DataFrame 122 | :param returns_data: if true, the first argument is returns instead of prices. 123 | These **should not** be log returns. 124 | :type returns_data: bool, defaults to False. 125 | :param compounding: computes geometric mean returns if True, 126 | arithmetic otherwise, optional. 127 | :type compounding: bool, defaults to True 128 | :param frequency: number of time periods in a year, defaults to 252 (the number 129 | of trading days in a year) 130 | :type frequency: int, optional 131 | :param log_returns: whether to compute using log returns 132 | :type log_returns: bool, defaults to False 133 | :return: annualised mean (daily) return for each asset 134 | :rtype: pd.Series 135 | """ 136 | if not isinstance(prices, pd.DataFrame): 137 | warnings.warn("prices are not in a dataframe", RuntimeWarning) 138 | prices = pd.DataFrame(prices) 139 | if returns_data: 140 | returns = prices 141 | else: 142 | returns = returns_from_prices(prices, log_returns) 143 | 144 | _check_returns(returns) 145 | if compounding: 146 | return (1 + returns).prod() ** (frequency / returns.count()) - 1 147 | else: 148 | return returns.mean() * frequency 149 | 150 | 151 | def ema_historical_return( 152 | prices, 153 | returns_data=False, 154 | compounding=True, 155 | span=500, 156 | frequency=252, 157 | log_returns=False, 158 | ): 159 | """ 160 | Calculate the exponentially-weighted mean of (daily) historical returns, giving 161 | higher weight to more recent data. 162 | 163 | :param prices: adjusted closing prices of the asset, each row is a date 164 | and each column is a ticker/id. 165 | :type prices: pd.DataFrame 166 | :param returns_data: if true, the first argument is returns instead of prices. 167 | These **should not** be log returns. 168 | :type returns_data: bool, defaults to False. 169 | :param compounding: computes geometric mean returns if True, 170 | arithmetic otherwise, optional. 171 | :type compounding: bool, defaults to True 172 | :param frequency: number of time periods in a year, defaults to 252 (the number 173 | of trading days in a year) 174 | :type frequency: int, optional 175 | :param span: the time-span for the EMA, defaults to 500-day EMA. 176 | :type span: int, optional 177 | :param log_returns: whether to compute using log returns 178 | :type log_returns: bool, defaults to False 179 | :return: annualised exponentially-weighted mean (daily) return of each asset 180 | :rtype: pd.Series 181 | """ 182 | if not isinstance(prices, pd.DataFrame): 183 | warnings.warn("prices are not in a dataframe", RuntimeWarning) 184 | prices = pd.DataFrame(prices) 185 | 186 | if returns_data: 187 | returns = prices 188 | else: 189 | returns = returns_from_prices(prices, log_returns) 190 | 191 | _check_returns(returns) 192 | if compounding: 193 | return (1 + returns.ewm(span=span).mean().iloc[-1]) ** frequency - 1 194 | else: 195 | return returns.ewm(span=span).mean().iloc[-1] * frequency 196 | 197 | 198 | def capm_return( 199 | prices, 200 | market_prices=None, 201 | returns_data=False, 202 | risk_free_rate=0.0, 203 | compounding=True, 204 | frequency=252, 205 | log_returns=False, 206 | ): 207 | """ 208 | Compute a return estimate using the Capital Asset Pricing Model. Under the CAPM, 209 | asset returns are equal to market returns plus a :math:`\beta` term encoding 210 | the relative risk of the asset. 211 | 212 | .. math:: 213 | 214 | R_i = R_f + \\beta_i (E(R_m) - R_f) 215 | 216 | 217 | :param prices: adjusted closing prices of the asset, each row is a date 218 | and each column is a ticker/id. 219 | :type prices: pd.DataFrame 220 | :param market_prices: adjusted closing prices of the benchmark, defaults to None 221 | :type market_prices: pd.DataFrame, optional 222 | :param returns_data: if true, the first arguments are returns instead of prices. 223 | :type returns_data: bool, defaults to False. 224 | :param risk_free_rate: risk-free rate of borrowing/lending, defaults to 0.0. 225 | You should use the appropriate time period, corresponding 226 | to the frequency parameter. 227 | :type risk_free_rate: float, optional 228 | :param compounding: computes geometric mean returns if True, 229 | arithmetic otherwise, optional. 230 | :type compounding: bool, defaults to True 231 | :param frequency: number of time periods in a year, defaults to 252 (the number 232 | of trading days in a year) 233 | :type frequency: int, optional 234 | :param log_returns: whether to compute using log returns 235 | :type log_returns: bool, defaults to False 236 | :return: annualised return estimate 237 | :rtype: pd.Series 238 | """ 239 | if not isinstance(prices, pd.DataFrame): 240 | warnings.warn("prices are not in a dataframe", RuntimeWarning) 241 | prices = pd.DataFrame(prices) 242 | 243 | market_returns = None 244 | 245 | if returns_data: 246 | returns = prices.copy() 247 | if market_prices is not None: 248 | market_returns = market_prices 249 | else: 250 | returns = returns_from_prices(prices, log_returns) 251 | 252 | if market_prices is not None: 253 | if not isinstance(market_prices, pd.DataFrame): 254 | warnings.warn("market prices are not in a dataframe", RuntimeWarning) 255 | market_prices = pd.DataFrame(market_prices) 256 | 257 | market_returns = returns_from_prices(market_prices, log_returns) 258 | # Use the equally-weighted dataset as a proxy for the market 259 | if market_returns is None: 260 | # Append market return to right and compute sample covariance matrix 261 | returns["mkt"] = returns.mean(axis=1) 262 | else: 263 | market_returns.columns = ["mkt"] 264 | returns = returns.join(market_returns, how="left") 265 | 266 | _check_returns(returns) 267 | 268 | # Compute covariance matrix for the new dataframe (including markets) 269 | cov = returns.cov() 270 | # The far-right column of the cov matrix is covariances to market 271 | betas = cov["mkt"] / cov.loc["mkt", "mkt"] 272 | betas = betas.drop("mkt") 273 | # Find mean market return on a given time period 274 | if compounding: 275 | mkt_mean_ret = (1 + returns["mkt"]).prod() ** ( 276 | frequency / returns["mkt"].count() 277 | ) - 1 278 | else: 279 | mkt_mean_ret = returns["mkt"].mean() * frequency 280 | 281 | # CAPM formula 282 | return risk_free_rate + betas * (mkt_mean_ret - risk_free_rate) 283 | -------------------------------------------------------------------------------- /pypfopt/hierarchical_portfolio.py: -------------------------------------------------------------------------------- 1 | """ 2 | The ``hierarchical_portfolio`` module seeks to implement one of the recent advances in 3 | portfolio optimization – the application of hierarchical clustering models in allocation. 4 | 5 | All of the hierarchical classes have a similar API to ``EfficientFrontier``, though since 6 | many hierarchical models currently don't support different objectives, the actual allocation 7 | happens with a call to `optimize()`. 8 | 9 | Currently implemented: 10 | 11 | - ``HRPOpt`` implements the Hierarchical Risk Parity (HRP) portfolio. Code reproduced with 12 | permission from Marcos Lopez de Prado (2016). 13 | """ 14 | 15 | import collections 16 | 17 | import numpy as np 18 | import pandas as pd 19 | import scipy.cluster.hierarchy as sch 20 | import scipy.spatial.distance as ssd 21 | 22 | from . import base_optimizer, risk_models 23 | 24 | 25 | class HRPOpt(base_optimizer.BaseOptimizer): 26 | """ 27 | A HRPOpt object (inheriting from BaseOptimizer) constructs a hierarchical 28 | risk parity portfolio. 29 | 30 | Instance variables: 31 | 32 | - Inputs 33 | 34 | - ``n_assets`` - int 35 | - ``tickers`` - str list 36 | - ``returns`` - pd.DataFrame 37 | 38 | - Output: 39 | 40 | - ``weights`` - np.ndarray 41 | - ``clusters`` - linkage matrix corresponding to clustered assets. 42 | 43 | Public methods: 44 | 45 | - ``optimize()`` calculates weights using HRP 46 | - ``portfolio_performance()`` calculates the expected return, volatility and Sharpe ratio for 47 | the optimized portfolio. 48 | - ``set_weights()`` creates self.weights (np.ndarray) from a weights dict 49 | - ``clean_weights()`` rounds the weights and clips near-zeros. 50 | - ``save_weights_to_file()`` saves the weights to csv, json, or txt. 51 | """ 52 | 53 | def __init__(self, returns=None, cov_matrix=None): 54 | """ 55 | :param returns: asset historical returns 56 | :type returns: pd.DataFrame 57 | :param cov_matrix: covariance of asset returns 58 | :type cov_matrix: pd.DataFrame. 59 | :raises TypeError: if ``returns`` is not a dataframe 60 | """ 61 | if returns is None and cov_matrix is None: 62 | raise ValueError("Either returns or cov_matrix must be provided") 63 | 64 | if returns is not None and not isinstance(returns, pd.DataFrame): 65 | raise TypeError("returns are not a dataframe") 66 | 67 | self.returns = returns 68 | self.cov_matrix = cov_matrix 69 | self.clusters = None 70 | 71 | if returns is None: 72 | tickers = list(cov_matrix.columns) 73 | else: 74 | tickers = list(returns.columns) 75 | super().__init__(len(tickers), tickers) 76 | 77 | @staticmethod 78 | def _get_cluster_var(cov, cluster_items): 79 | """ 80 | Compute the variance per cluster 81 | 82 | :param cov: covariance matrix 83 | :type cov: np.ndarray 84 | :param cluster_items: tickers in the cluster 85 | :type cluster_items: list 86 | :return: the variance per cluster 87 | :rtype: float 88 | """ 89 | # Compute variance per cluster 90 | cov_slice = cov.loc[cluster_items, cluster_items] 91 | weights = 1 / np.diag(cov_slice) # Inverse variance weights 92 | weights /= weights.sum() 93 | return np.linalg.multi_dot((weights, cov_slice, weights)) 94 | 95 | @staticmethod 96 | def _get_quasi_diag(link): 97 | """ 98 | Sort clustered items by distance 99 | 100 | :param link: linkage matrix after clustering 101 | :type link: np.ndarray 102 | :return: sorted list of indices 103 | :rtype: list 104 | """ 105 | return sch.to_tree(link, rd=False).pre_order() 106 | 107 | @staticmethod 108 | def _raw_hrp_allocation(cov, ordered_tickers): 109 | """ 110 | Given the clusters, compute the portfolio that minimises risk by 111 | recursively traversing the hierarchical tree from the top. 112 | 113 | :param cov: covariance matrix 114 | :type cov: np.ndarray 115 | :param ordered_tickers: list of tickers ordered by distance 116 | :type ordered_tickers: str list 117 | :return: raw portfolio weights 118 | :rtype: pd.Series 119 | """ 120 | w = pd.Series(1.0, index=ordered_tickers) 121 | cluster_items = [ordered_tickers] # initialize all items in one cluster 122 | 123 | while len(cluster_items) > 0: 124 | cluster_items = [ 125 | i[j:k] 126 | for i in cluster_items 127 | for j, k in ((0, len(i) // 2), (len(i) // 2, len(i))) 128 | if len(i) > 1 129 | ] # bi-section 130 | # For each pair, optimize locally. 131 | for i in range(0, len(cluster_items), 2): 132 | first_cluster = cluster_items[i] 133 | second_cluster = cluster_items[i + 1] 134 | # Form the inverse variance portfolio for this pair 135 | first_variance = HRPOpt._get_cluster_var(cov, first_cluster) 136 | second_variance = HRPOpt._get_cluster_var(cov, second_cluster) 137 | alpha = 1 - first_variance / (first_variance + second_variance) 138 | w[first_cluster] *= alpha # weight 1 139 | w[second_cluster] *= 1 - alpha # weight 2 140 | return w 141 | 142 | def optimize(self, linkage_method="single"): 143 | """ 144 | Construct a hierarchical risk parity portfolio, using Scipy hierarchical clustering 145 | (see `here `_) 146 | 147 | :param linkage_method: which scipy linkage method to use 148 | :type linkage_method: str 149 | :return: weights for the HRP portfolio 150 | :rtype: OrderedDict 151 | """ 152 | if linkage_method not in sch._LINKAGE_METHODS: 153 | raise ValueError("linkage_method must be one recognised by scipy") 154 | 155 | if self.returns is None: 156 | cov = self.cov_matrix 157 | corr = risk_models.cov_to_corr(self.cov_matrix).round(6) 158 | else: 159 | corr, cov = self.returns.corr(), self.returns.cov() 160 | 161 | # Compute distance matrix, with ClusterWarning fix as 162 | # per https://stackoverflow.com/questions/18952587/ 163 | 164 | # this can avoid some nasty floating point issues 165 | matrix = np.sqrt(np.clip((1.0 - corr) / 2.0, a_min=0.0, a_max=1.0)) 166 | dist = ssd.squareform(matrix, checks=False) 167 | 168 | self.clusters = sch.linkage(dist, linkage_method) 169 | sort_ix = HRPOpt._get_quasi_diag(self.clusters) 170 | ordered_tickers = corr.index[sort_ix].tolist() 171 | hrp = HRPOpt._raw_hrp_allocation(cov, ordered_tickers) 172 | weights = collections.OrderedDict(hrp.sort_index()) 173 | self.set_weights(weights) 174 | return weights 175 | 176 | def portfolio_performance(self, verbose=False, risk_free_rate=0.0, frequency=252): 177 | """ 178 | After optimising, calculate (and optionally print) the performance of the optimal 179 | portfolio. Currently calculates expected return, volatility, and the Sharpe ratio 180 | assuming returns are daily 181 | 182 | :param verbose: whether performance should be printed, defaults to False 183 | :type verbose: bool, optional 184 | :param risk_free_rate: risk-free rate of borrowing/lending, defaults to 0.0. 185 | The period of the risk-free rate should correspond to the 186 | frequency of expected returns. 187 | :type risk_free_rate: float, optional 188 | :param frequency: number of time periods in a year, defaults to 252 (the number 189 | of trading days in a year) 190 | :type frequency: int, optional 191 | :raises ValueError: if weights have not been calculated yet 192 | :return: expected return, volatility, Sharpe ratio. 193 | :rtype: (float, float, float) 194 | """ 195 | if self.returns is None: 196 | cov = self.cov_matrix 197 | mu = None 198 | else: 199 | cov = self.returns.cov() * frequency 200 | mu = self.returns.mean() * frequency 201 | 202 | return base_optimizer.portfolio_performance( 203 | self.weights, mu, cov, verbose, risk_free_rate 204 | ) 205 | -------------------------------------------------------------------------------- /pypfopt/objective_functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | The ``objective_functions`` module provides optimization objectives, including the actual 3 | objective functions called by the ``EfficientFrontier`` object's optimization methods. 4 | These methods are primarily designed for internal use during optimization and each requires 5 | a different signature (which is why they have not been factored into a class). 6 | For obvious reasons, any objective function must accept ``weights`` 7 | as an argument, and must also have at least one of ``expected_returns`` or ``cov_matrix``. 8 | 9 | The objective functions either compute the objective given a numpy array of weights, or they 10 | return a cvxpy *expression* when weights are a ``cp.Variable``. In this way, the same objective 11 | function can be used both internally for optimization and externally for computing the objective 12 | given weights. ``_objective_value()`` automatically chooses between the two behaviours. 13 | 14 | ``objective_functions`` defaults to objectives for minimisation. In the cases of objectives 15 | that clearly should be maximised (e.g Sharpe Ratio, portfolio return), the objective function 16 | actually returns the negative quantity, since minimising the negative is equivalent to maximising 17 | the positive. This behaviour is controlled by the ``negative=True`` optional argument. 18 | 19 | Currently implemented: 20 | 21 | - Portfolio variance (i.e square of volatility) 22 | - Portfolio return 23 | - Sharpe ratio 24 | - L2 regularisation (minimising this reduces nonzero weights) 25 | - Quadratic utility 26 | - Transaction cost model (a simple one) 27 | - Ex-ante (squared) tracking error 28 | - Ex-post (squared) tracking error 29 | """ 30 | 31 | import cvxpy as cp 32 | import numpy as np 33 | 34 | 35 | def _objective_value(w, obj): 36 | """ 37 | Helper method to return either the value of the objective function 38 | or the objective function as a cvxpy object depending on whether 39 | w is a cvxpy variable or np array. 40 | 41 | :param w: weights 42 | :type w: np.ndarray OR cp.Variable 43 | :param obj: objective function expression 44 | :type obj: cp.Expression 45 | :return: value of the objective function OR objective function expression 46 | :rtype: float OR cp.Expression 47 | """ 48 | if isinstance(w, np.ndarray): 49 | if np.isscalar(obj): 50 | return obj 51 | elif np.isscalar(obj.value): 52 | return obj.value 53 | else: 54 | return obj.value.item() 55 | else: 56 | return obj 57 | 58 | 59 | def portfolio_variance(w, cov_matrix): 60 | """ 61 | Calculate the total portfolio variance (i.e square volatility). 62 | 63 | :param w: asset weights in the portfolio 64 | :type w: np.ndarray OR cp.Variable 65 | :param cov_matrix: covariance matrix 66 | :type cov_matrix: np.ndarray 67 | :return: value of the objective function OR objective function expression 68 | :rtype: float OR cp.Expression 69 | """ 70 | variance = cp.quad_form(w, cov_matrix, assume_PSD=True) 71 | return _objective_value(w, variance) 72 | 73 | 74 | def portfolio_return(w, expected_returns, negative=True): 75 | """ 76 | Calculate the (negative) mean return of a portfolio 77 | 78 | :param w: asset weights in the portfolio 79 | :type w: np.ndarray OR cp.Variable 80 | :param expected_returns: expected return of each asset 81 | :type expected_returns: np.ndarray 82 | :param negative: whether quantity should be made negative (so we can minimise) 83 | :type negative: boolean 84 | :return: negative mean return 85 | :rtype: float 86 | """ 87 | sign = -1 if negative else 1 88 | mu = w @ expected_returns 89 | return _objective_value(w, sign * mu) 90 | 91 | 92 | def sharpe_ratio(w, expected_returns, cov_matrix, risk_free_rate=0.0, negative=True): 93 | """ 94 | Calculate the (negative) Sharpe ratio of a portfolio 95 | 96 | :param w: asset weights in the portfolio 97 | :type w: np.ndarray OR cp.Variable 98 | :param expected_returns: expected return of each asset 99 | :type expected_returns: np.ndarray 100 | :param cov_matrix: covariance matrix 101 | :type cov_matrix: np.ndarray 102 | :param risk_free_rate: risk-free rate of borrowing/lending, defaults to 0.0. 103 | The period of the risk-free rate should correspond to the 104 | frequency of expected returns. 105 | :type risk_free_rate: float, optional 106 | :param negative: whether quantity should be made negative (so we can minimise) 107 | :type negative: boolean 108 | :return: (negative) Sharpe ratio 109 | :rtype: float 110 | """ 111 | mu = w @ expected_returns 112 | sigma = cp.sqrt(cp.quad_form(w, cov_matrix, assume_PSD=True)) 113 | sign = -1 if negative else 1 114 | sharpe = (mu - risk_free_rate) / sigma 115 | return _objective_value(w, sign * sharpe) 116 | 117 | 118 | def L2_reg(w, gamma=1): 119 | r""" 120 | L2 regularisation, i.e :math:`\gamma ||w||^2`, to increase the number of nonzero weights. 121 | 122 | Example:: 123 | 124 | ef = EfficientFrontier(mu, S) 125 | ef.add_objective(objective_functions.L2_reg, gamma=2) 126 | ef.min_volatility() 127 | 128 | :param w: asset weights in the portfolio 129 | :type w: np.ndarray OR cp.Variable 130 | :param gamma: L2 regularisation parameter, defaults to 1. Increase if you want more 131 | non-negligible weights 132 | :type gamma: float, optional 133 | :return: value of the objective function OR objective function expression 134 | :rtype: float OR cp.Expression 135 | """ 136 | L2_reg = gamma * cp.sum_squares(w) 137 | return _objective_value(w, L2_reg) 138 | 139 | 140 | def quadratic_utility(w, expected_returns, cov_matrix, risk_aversion, negative=True): 141 | r""" 142 | Quadratic utility function, i.e :math:`\mu - \frac 1 2 \delta w^T \Sigma w`. 143 | 144 | :param w: asset weights in the portfolio 145 | :type w: np.ndarray OR cp.Variable 146 | :param expected_returns: expected return of each asset 147 | :type expected_returns: np.ndarray 148 | :param cov_matrix: covariance matrix 149 | :type cov_matrix: np.ndarray 150 | :param risk_aversion: risk aversion coefficient. Increase to reduce risk. 151 | :type risk_aversion: float 152 | :param negative: whether quantity should be made negative (so we can minimise). 153 | :type negative: boolean 154 | :return: value of the objective function OR objective function expression 155 | :rtype: float OR cp.Expression 156 | """ 157 | sign = -1 if negative else 1 158 | mu = w @ expected_returns 159 | variance = cp.quad_form(w, cov_matrix, assume_PSD=True) 160 | 161 | risk_aversion_par = cp.Parameter( 162 | value=risk_aversion, name="risk_aversion", nonneg=True 163 | ) 164 | utility = mu - 0.5 * risk_aversion_par * variance 165 | return _objective_value(w, sign * utility) 166 | 167 | 168 | def transaction_cost(w, w_prev, k=0.001): 169 | """ 170 | A very simple transaction cost model: sum all the weight changes 171 | and multiply by a given fraction (default to 10bps). This simulates 172 | a fixed percentage commission from your broker. 173 | 174 | :param w: asset weights in the portfolio 175 | :type w: np.ndarray OR cp.Variable 176 | :param w_prev: previous weights 177 | :type w_prev: np.ndarray 178 | :param k: fractional cost per unit weight exchanged 179 | :type k: float 180 | :return: value of the objective function OR objective function expression 181 | :rtype: float OR cp.Expression 182 | """ 183 | return _objective_value(w, k * cp.norm(w - w_prev, 1)) 184 | 185 | 186 | def ex_ante_tracking_error(w, cov_matrix, benchmark_weights): 187 | """ 188 | Calculate the (square of) the ex-ante Tracking Error, i.e 189 | :math:`(w - w_b)^T \\Sigma (w-w_b)`. 190 | 191 | :param w: asset weights in the portfolio 192 | :type w: np.ndarray OR cp.Variable 193 | :param cov_matrix: covariance matrix 194 | :type cov_matrix: np.ndarray 195 | :param benchmark_weights: asset weights in the benchmark 196 | :type benchmark_weights: np.ndarray 197 | :return: value of the objective function OR objective function expression 198 | :rtype: float OR cp.Expression 199 | """ 200 | relative_weights = w - benchmark_weights 201 | tracking_error = cp.quad_form(relative_weights, cov_matrix) 202 | return _objective_value(w, tracking_error) 203 | 204 | 205 | def ex_post_tracking_error(w, historic_returns, benchmark_returns): 206 | """ 207 | Calculate the (square of) the ex-post Tracking Error, i.e :math:`Var(r - r_b)`. 208 | 209 | :param w: asset weights in the portfolio 210 | :type w: np.ndarray OR cp.Variable 211 | :param historic_returns: historic asset returns 212 | :type historic_returns: np.ndarray 213 | :param benchmark_returns: historic benchmark returns 214 | :type benchmark_returns: pd.Series or np.ndarray 215 | :return: value of the objective function OR objective function expression 216 | :rtype: float OR cp.Expression 217 | """ 218 | if not isinstance(historic_returns, np.ndarray): 219 | historic_returns = np.array(historic_returns) 220 | if not isinstance(benchmark_returns, np.ndarray): 221 | benchmark_returns = np.array(benchmark_returns) 222 | 223 | x_i = w @ historic_returns.T - benchmark_returns 224 | mean = cp.sum(x_i) / len(benchmark_returns) 225 | tracking_error = cp.sum_squares(x_i - mean) 226 | return _objective_value(w, tracking_error) 227 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pyportfolioopt" 3 | version = "1.5.6" 4 | description = "Financial portfolio optimization in python" 5 | license = "MIT" 6 | authors = ["Robert Andrew Martin "] 7 | readme = "README.md" 8 | repository = "https://github.com/robertmartin8/PyPortfolioOpt" 9 | documentation = "https://pyportfolioopt.readthedocs.io/en/latest/" 10 | keywords= ["finance", "portfolio", "optimization", "quant", "investing"] 11 | classifiers=[ 12 | "Development Status :: 4 - Beta", 13 | "Environment :: Console", 14 | "Intended Audience :: Financial and Insurance Industry", 15 | "Intended Audience :: Science/Research", 16 | "License :: OSI Approved :: MIT License", 17 | "Natural Language :: English", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Topic :: Office/Business :: Financial", 26 | "Topic :: Office/Business :: Financial :: Investment", 27 | ] 28 | packages = [ {include = "pypfopt"} ] 29 | 30 | [tool.poetry.urls] 31 | "Issues" = "https://github.com/robertmartin8/PyPortfolioOpt/issues" 32 | "Personal website" = "https://reasonabledeviations.com" 33 | 34 | [tool.poetry.dependencies] 35 | python = ">=3.9" 36 | scipy = ">=1.3" 37 | pandas = ">=0.19" 38 | cvxpy = ">=1.1.19" 39 | numpy = ">=1.26.0" 40 | matplotlib = { version=">=3.2.0", optional=true } 41 | scikit-learn = { version=">=0.24.1", optional=true } 42 | ecos = { version="^2.0.14", optional=true } 43 | plotly = { version="^5.0.0", optional=true } 44 | 45 | [tool.poetry.dev-dependencies] 46 | pytest = ">=7.1.2" 47 | flake8 = ">=4.0.1" 48 | jupyterlab = ">=3.4.2" 49 | black = ">=22.3.0" 50 | ipykernel = ">=6.13.0" 51 | jedi = ">=0.18.1" 52 | pytest-cov = ">=3.0.0" 53 | yfinance = ">=0.1.70" 54 | 55 | [tool.poetry.extras] 56 | optionals = ["scikit-learn", "matplotlib", "cvxopt"] 57 | 58 | [build-system] 59 | requires = ["poetry-core>=1.0.0"] 60 | build-backend = "poetry.core.masonry.api" 61 | 62 | 63 | [tool.black] 64 | line-length = 88 65 | 66 | [tool.isort] 67 | profile = "black" 68 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | python: 2 | version: 3 3 | pip_install: true 4 | 5 | # For more fields that can be specified here, see: 6 | # http://docs.readthedocs.io/en/latest/yaml-config.html 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cvxpy>=1.1.19 2 | matplotlib>=3.2.0 3 | numpy>=1.0.0 4 | pandas>=0.19 5 | scikit-learn>=0.24.1 6 | scipy>=1.3.0 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import setuptools 4 | 5 | with open("README.md", "r") as f: 6 | desc = f.read() 7 | desc = desc.split("")[-1] 8 | desc = re.sub("<[^<]+?>", "", desc) # Remove html 9 | 10 | if __name__ == "__main__": 11 | setuptools.setup( 12 | name="pyportfolioopt", 13 | version="1.5.6", 14 | description="Financial portfolio optimization in python", 15 | long_description=desc, 16 | long_description_content_type="text/markdown", 17 | url="https://github.com/robertmartin8/PyPortfolioOpt", 18 | author="Robert Andrew Martin", 19 | author_email="martin.robertandrew@gmail.com", 20 | license="MIT", 21 | packages=["pypfopt"], 22 | classifiers=[ 23 | "Development Status :: 4 - Beta", 24 | "Environment :: Console", 25 | "Intended Audience :: Financial and Insurance Industry", 26 | "Intended Audience :: Science/Research", 27 | "License :: OSI Approved :: MIT License", 28 | "Natural Language :: English", 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.9", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: 3 :: Only", 36 | "Topic :: Office/Business :: Financial", 37 | "Topic :: Office/Business :: Financial :: Investment", 38 | ], 39 | keywords="portfolio finance optimization quant trading investing", 40 | install_requires=[ 41 | "cvxpy", 42 | "matplotlib", 43 | "numpy", 44 | "pandas", 45 | "scikit-learn", 46 | "scipy", 47 | ], 48 | setup_requires=["pytest-runner"], 49 | tests_require=["pytest"], 50 | python_requires=">=3.8", 51 | project_urls={ 52 | "Documentation": "https://pyportfolioopt.readthedocs.io/en/latest/", 53 | "Issues": "https://github.com/robertmartin8/PyPortfolioOpt/issues", 54 | "Personal website": "https://reasonabledeviations.com", 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertmartin8/PyPortfolioOpt/65cabcee11d91777d63628744c36869ec64a1c85/tests/__init__.py -------------------------------------------------------------------------------- /tests/resources/weights_hrp.csv: -------------------------------------------------------------------------------- 1 | ,0 2 | AAPL,0.022258941278778387 3 | AMD,0.022294021796692116 4 | AMZN,0.016086842079874982 5 | BABA,0.07963382071794088 6 | BAC,0.014409222455552267 7 | BBY,0.03406419438245041 8 | FB,0.06272994714663536 9 | GE,0.05519063444162851 10 | GM,0.055576660241857236 11 | GOOG,0.04956008428992926 12 | JPM,0.017675709092515715 13 | MA,0.038127373497320226 14 | PFE,0.07786528342813452 15 | RRC,0.03161528695094598 16 | SBUX,0.039844436656239136 17 | SHLD,0.027113184241298872 18 | T,0.11138956508836473 19 | UAA,0.027115909570750083 20 | WMT,0.10569551148587902 21 | XOM,0.11175337115721229 22 | -------------------------------------------------------------------------------- /tests/test_base_optimizer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | 5 | import cvxpy as cp 6 | import numpy as np 7 | import pandas as pd 8 | import pytest 9 | 10 | from pypfopt import EfficientFrontier, exceptions, objective_functions 11 | from pypfopt.base_optimizer import BaseOptimizer, portfolio_performance 12 | from tests.utilities_for_tests import get_data, setup_efficient_frontier 13 | 14 | 15 | def test_base_optimizer(): 16 | # Test tickers not provided 17 | bo = BaseOptimizer(2) 18 | assert bo.tickers == [0, 1] 19 | w = {0: 0.4, 1: 0.6} 20 | bo.set_weights(w) 21 | assert dict(bo.clean_weights()) == w 22 | 23 | 24 | def test_custom_bounds(): 25 | ef = setup_efficient_frontier(weight_bounds=(0.02, 0.13)) 26 | ef.min_volatility() 27 | np.testing.assert_allclose(ef._lower_bounds, np.array([0.02] * ef.n_assets)) 28 | np.testing.assert_allclose(ef._upper_bounds, np.array([0.13] * ef.n_assets)) 29 | 30 | assert ef.weights.min() >= 0.02 31 | assert ef.weights.max() <= 0.13 32 | np.testing.assert_almost_equal(ef.weights.sum(), 1) 33 | 34 | 35 | def test_custom_bounds_different_values(): 36 | bounds = [(0.01, 0.13), (0.02, 0.11)] * 10 37 | ef = setup_efficient_frontier(weight_bounds=bounds) 38 | ef.min_volatility() 39 | assert (0.01 <= ef.weights[::2]).all() and (ef.weights[::2] <= 0.13).all() 40 | assert (0.02 <= ef.weights[1::2]).all() and (ef.weights[1::2] <= 0.11).all() 41 | np.testing.assert_almost_equal(ef.weights.sum(), 1) 42 | 43 | bounds = ((0.01, 0.13), (0.02, 0.11)) * 10 44 | assert setup_efficient_frontier(weight_bounds=bounds) 45 | 46 | 47 | def test_weight_bounds_minus_one_to_one(): 48 | ef = setup_efficient_frontier(weight_bounds=(-1, 1)) 49 | assert ef.max_sharpe() 50 | ef2 = setup_efficient_frontier(weight_bounds=(-1, 1)) 51 | assert ef2.min_volatility() 52 | 53 | 54 | def test_none_bounds(): 55 | ef = setup_efficient_frontier(weight_bounds=(None, 0.3)) 56 | ef.min_volatility() 57 | w1 = ef.weights 58 | 59 | ef = setup_efficient_frontier(weight_bounds=(-1, 0.3)) 60 | ef.min_volatility() 61 | w2 = ef.weights 62 | 63 | np.testing.assert_array_almost_equal(w1, w2) 64 | 65 | 66 | def test_bound_input_types(): 67 | bounds = [0.01, 0.13] 68 | ef = setup_efficient_frontier(weight_bounds=bounds) 69 | assert ef 70 | np.testing.assert_allclose(ef._lower_bounds, np.array([0.01] * ef.n_assets)) 71 | np.testing.assert_allclose(ef._upper_bounds, np.array([0.13] * ef.n_assets)) 72 | 73 | lb = np.array([0.01, 0.02] * 10) 74 | ub = np.array([0.07, 0.2] * 10) 75 | assert setup_efficient_frontier(weight_bounds=(lb, ub)) 76 | 77 | bounds = ((0.01, 0.13), (0.02, 0.11)) * 10 78 | assert setup_efficient_frontier(weight_bounds=bounds) 79 | 80 | 81 | def test_bound_failure(): 82 | # Ensure optimization fails when lower bound is too high or upper bound is too low 83 | ef = setup_efficient_frontier(weight_bounds=(0.06, 0.13)) 84 | with pytest.raises(exceptions.OptimizationError): 85 | ef.min_volatility() 86 | 87 | ef = setup_efficient_frontier(weight_bounds=(0, 0.04)) 88 | with pytest.raises(exceptions.OptimizationError): 89 | ef.min_volatility() 90 | 91 | 92 | def test_bounds_errors(): 93 | assert setup_efficient_frontier(weight_bounds=(0, 1)) 94 | 95 | with pytest.raises(TypeError): 96 | setup_efficient_frontier(weight_bounds=(0.06, 1, 3)) 97 | 98 | with pytest.raises(TypeError): 99 | # Not enough bounds 100 | bounds = [(0.01, 0.13), (0.02, 0.11)] * 5 101 | setup_efficient_frontier(weight_bounds=bounds) 102 | 103 | 104 | def test_clean_weights(): 105 | ef = setup_efficient_frontier() 106 | ef.min_volatility() 107 | number_tiny_weights = sum(ef.weights < 1e-4) 108 | cleaned = ef.clean_weights(cutoff=1e-4, rounding=5) 109 | cleaned_weights = cleaned.values() 110 | clean_number_tiny_weights = sum(i < 1e-4 for i in cleaned_weights) 111 | assert clean_number_tiny_weights == number_tiny_weights 112 | # Check rounding 113 | cleaned_weights_str_length = [len(str(i)) for i in cleaned_weights] 114 | assert all([length == 7 or length == 3 for length in cleaned_weights_str_length]) 115 | 116 | 117 | def test_clean_weights_short(): 118 | ef = setup_efficient_frontier(weight_bounds=(-1, 1)) 119 | ef.min_volatility() 120 | # In practice we would never use such a high cutoff 121 | number_tiny_weights = sum(np.abs(ef.weights) < 0.05) 122 | cleaned = ef.clean_weights(cutoff=0.05) 123 | cleaned_weights = cleaned.values() 124 | clean_number_tiny_weights = sum(abs(i) < 0.05 for i in cleaned_weights) 125 | assert clean_number_tiny_weights == number_tiny_weights 126 | 127 | 128 | def test_clean_weights_error(): 129 | ef = setup_efficient_frontier() 130 | with pytest.raises(AttributeError): 131 | ef.clean_weights() 132 | ef.min_volatility() 133 | with pytest.raises(ValueError): 134 | ef.clean_weights(rounding=1.3) 135 | with pytest.raises(ValueError): 136 | ef.clean_weights(rounding=0) 137 | assert ef.clean_weights(rounding=3) 138 | 139 | 140 | def test_clean_weights_no_rounding(): 141 | ef = setup_efficient_frontier() 142 | ef.min_volatility() 143 | # ensure the call does not fail 144 | # in previous commits, this call would raise a ValueError 145 | cleaned = ef.clean_weights(rounding=None, cutoff=0) 146 | assert cleaned 147 | np.testing.assert_array_almost_equal( 148 | np.sort(ef.weights), np.sort(list(cleaned.values())) 149 | ) 150 | 151 | 152 | def test_efficient_frontier_init_errors(): 153 | df = get_data() 154 | mean_returns = df.pct_change().dropna(how="all").mean() 155 | with pytest.raises(TypeError): 156 | EfficientFrontier("test", "string") 157 | 158 | with pytest.raises(TypeError): 159 | EfficientFrontier(mean_returns, mean_returns) 160 | 161 | 162 | def test_set_weights(): 163 | ef1 = setup_efficient_frontier() 164 | w1 = ef1.min_volatility() 165 | test_weights = ef1.weights 166 | ef2 = setup_efficient_frontier() 167 | ef2.min_volatility() 168 | ef2.set_weights(w1) 169 | np.testing.assert_array_almost_equal(test_weights, ef2.weights) 170 | 171 | 172 | def test_save_weights_to_file(): 173 | ef = setup_efficient_frontier() 174 | ef.min_volatility() 175 | 176 | temp_folder = tempfile.TemporaryDirectory() 177 | temp_folder_path = temp_folder.name 178 | 179 | test_file_path_txt = os.path.join(temp_folder_path, "test.txt") 180 | test_file_path_json = os.path.join(temp_folder_path, "test.json") 181 | test_file_path_csv = os.path.join(temp_folder_path, "test.csv") 182 | test_file_path_xml = os.path.join(temp_folder_path, "test.xml") 183 | 184 | # Convert weights to regular floats before saving 185 | weights = {k: float(v) for k, v in ef.clean_weights().items()} 186 | 187 | # Save the converted weights 188 | with open(test_file_path_txt, "w") as f: 189 | json.dump(weights, f) 190 | 191 | # Test reading 192 | with open(test_file_path_txt, "r") as f: 193 | parsed = json.load(f) 194 | assert ef.clean_weights() == parsed 195 | 196 | ef.save_weights_to_file(test_file_path_json) 197 | with open(test_file_path_json, "r") as f: 198 | parsed = json.load(f) 199 | assert ef.clean_weights() == parsed 200 | 201 | ef.save_weights_to_file(test_file_path_csv) 202 | with open(test_file_path_csv, "r") as f: 203 | df = pd.read_csv( 204 | f, 205 | header=None, 206 | names=["ticker", "weight"], 207 | index_col=0, 208 | float_precision="high", 209 | ) 210 | parsed = df["weight"].to_dict() 211 | assert ef.clean_weights() == parsed 212 | 213 | with pytest.raises(NotImplementedError): 214 | ef.save_weights_to_file(test_file_path_xml) 215 | 216 | temp_folder.cleanup() 217 | 218 | 219 | def test_portfolio_performance(): 220 | """ 221 | Cover logic in base_optimizer.portfolio_performance not covered elsewhere. 222 | """ 223 | ef = setup_efficient_frontier() 224 | ef.min_volatility() 225 | expected = ef.portfolio_performance() 226 | 227 | # Cover verbose logic 228 | assert ( 229 | portfolio_performance(ef.weights, ef.expected_returns, ef.cov_matrix, True) 230 | == expected 231 | ) 232 | # including when used without expected returns too. 233 | assert portfolio_performance(ef.weights, None, ef.cov_matrix, True) == ( 234 | None, 235 | expected[1], 236 | None, 237 | ) 238 | # Internal ticker creations when weights param is a dict and ... 239 | w_dict = dict(zip(ef.tickers, ef.weights)) 240 | # ... expected_returns is a Series 241 | er = pd.Series(ef.expected_returns, index=ef.tickers) 242 | assert portfolio_performance(w_dict, er, ef.cov_matrix) == expected 243 | # ... cov_matrix is a DataFrame 244 | cov = pd.DataFrame(data=ef.cov_matrix, index=ef.tickers, columns=ef.tickers) 245 | assert portfolio_performance(w_dict, ef.expected_returns, cov) == expected 246 | 247 | # Will only support 'tickers' as dict keys that are ints starting from zero. 248 | w_dict = dict(zip(range(len(ef.weights)), ef.weights)) 249 | assert portfolio_performance(w_dict, ef.expected_returns, ef.cov_matrix) == expected 250 | 251 | # Weights must not sum to zero. 252 | w_dict = dict(zip(range(len(ef.weights)), np.zeros(len(ef.weights)))) 253 | with pytest.raises(ValueError): 254 | portfolio_performance(w_dict, ef.expected_returns, ef.cov_matrix) 255 | 256 | 257 | def test_add_constraint_exception(): 258 | ef = setup_efficient_frontier() 259 | # Must be callable. 260 | with pytest.raises(TypeError): 261 | ef.add_constraint(42) 262 | 263 | 264 | def test_problem_access(): 265 | ef = setup_efficient_frontier() 266 | ef.max_sharpe() 267 | assert isinstance(ef._opt, cp.Problem) 268 | 269 | 270 | def test_exception_immutability(): 271 | ef = setup_efficient_frontier() 272 | ef.efficient_return(0.2) 273 | 274 | with pytest.raises( 275 | Exception, 276 | match="Adding constraints to an already solved problem might have unintended consequences", 277 | ): 278 | ef.min_volatility() 279 | 280 | ef = setup_efficient_frontier() 281 | ef.efficient_return(0.2) 282 | with pytest.raises( 283 | Exception, 284 | match="Adding constraints to an already solved problem might have unintended consequences", 285 | ): 286 | ef.add_constraint(lambda w: w >= 0.1) 287 | 288 | ef = setup_efficient_frontier() 289 | ef.efficient_return(0.2) 290 | prev_w = np.array([1 / ef.n_assets] * ef.n_assets) 291 | with pytest.raises( 292 | Exception, 293 | match="Adding objectives to an already solved problem might have unintended consequences", 294 | ): 295 | ef.add_objective(objective_functions.transaction_cost, w_prev=prev_w) 296 | 297 | ef = setup_efficient_frontier() 298 | ef.efficient_return(0.2) 299 | ef._constraints += [ef._w >= 0.1] 300 | with pytest.raises( 301 | Exception, match="The constraints were changed after the initial optimization" 302 | ): 303 | ef.efficient_return(0.2) 304 | 305 | ef = setup_efficient_frontier() 306 | ef.efficient_return(0.2, market_neutral=True) 307 | with pytest.raises( 308 | Exception, match="A new instance must be created when changing market_neutral" 309 | ): 310 | ef.efficient_return(0.2, market_neutral=False) 311 | -------------------------------------------------------------------------------- /tests/test_cla.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pypfopt import risk_models 5 | from pypfopt.cla import CLA 6 | from tests.utilities_for_tests import get_data, setup_cla 7 | 8 | 9 | def test_portfolio_performance(): 10 | cla = setup_cla() 11 | with pytest.raises(ValueError): 12 | cla.portfolio_performance() 13 | cla.max_sharpe() 14 | assert cla.portfolio_performance() 15 | 16 | 17 | def test_cla_inheritance(): 18 | cla = setup_cla() 19 | assert cla.clean_weights 20 | assert cla.set_weights 21 | 22 | 23 | def test_cla_max_sharpe_long_only(): 24 | cla = setup_cla() 25 | w = cla.max_sharpe() 26 | assert isinstance(w, dict) 27 | assert set(w.keys()) == set(cla.tickers) 28 | np.testing.assert_almost_equal(cla.weights.sum(), 1) 29 | 30 | np.testing.assert_allclose( 31 | cla.portfolio_performance(risk_free_rate=0.02), 32 | (0.2994470912768992, 0.21764331657015668, 1.283968171780824), 33 | ) 34 | 35 | 36 | def test_cla_max_sharpe_short(): 37 | cla = setup_cla(weight_bounds=(-1, 1)) 38 | w = cla.max_sharpe() 39 | assert isinstance(w, dict) 40 | assert set(w.keys()) == set(cla.tickers) 41 | np.testing.assert_almost_equal(cla.weights.sum(), 1) 42 | np.testing.assert_allclose( 43 | cla.portfolio_performance(risk_free_rate=0.02), 44 | (0.44859872371106785, 0.26762066559448255, 1.601515797589826), 45 | ) 46 | sharpe = cla.portfolio_performance(risk_free_rate=0.02)[2] 47 | 48 | cla_long_only = setup_cla() 49 | cla_long_only.max_sharpe() 50 | long_only_sharpe = cla_long_only.portfolio_performance(risk_free_rate=0.02)[2] 51 | 52 | assert sharpe > long_only_sharpe 53 | 54 | 55 | def test_cla_custom_bounds(): 56 | bounds = [(0.01, 0.13), (0.02, 0.11)] * 10 57 | cla = setup_cla(weight_bounds=bounds) 58 | df = get_data() 59 | cla.cov_matrix = risk_models.exp_cov(df).values 60 | w = cla.min_volatility() 61 | assert isinstance(w, dict) 62 | assert set(w.keys()) == set(cla.tickers) 63 | np.testing.assert_almost_equal(cla.weights.sum(), 1) 64 | assert (0.01 <= cla.weights[::2]).all() and (cla.weights[::2] <= 0.13).all() 65 | assert (0.02 <= cla.weights[1::2]).all() and (cla.weights[1::2] <= 0.11).all() 66 | # Test polymorphism of the weight_bounds param. 67 | bounds2 = ([bounds[0][0], bounds[1][0]] * 10, [bounds[0][1], bounds[1][1]] * 10) 68 | cla2 = setup_cla(weight_bounds=bounds2) 69 | cla2.cov_matrix = risk_models.exp_cov(df).values 70 | w2 = cla2.min_volatility() 71 | assert dict(w2) == dict(w) 72 | 73 | 74 | def test_cla_min_volatility(): 75 | cla = setup_cla() 76 | w = cla.min_volatility() 77 | assert isinstance(w, dict) 78 | assert set(w.keys()) == set(cla.tickers) 79 | np.testing.assert_almost_equal(cla.weights.sum(), 1) 80 | np.testing.assert_allclose( 81 | cla.portfolio_performance(risk_free_rate=0.02), 82 | (0.1505682139948257, 0.15915084514118688, 0.8204054077060994), 83 | ) 84 | 85 | 86 | def test_cla_error(): 87 | cla = setup_cla() 88 | w = cla.min_volatility() 89 | with pytest.raises(NotImplementedError): 90 | cla.set_weights(w) 91 | 92 | 93 | def test_cla_two_assets(): 94 | mu = np.array([[0.02569294], [0.16203987]]) 95 | cov = np.array([[0.0012765, -0.00212724], [-0.00212724, 0.01616983]]) 96 | assert CLA(mu, cov) 97 | 98 | 99 | def test_cla_max_sharpe_semicovariance(): 100 | df = get_data() 101 | cla = setup_cla() 102 | cla.cov_matrix = risk_models.semicovariance(df, benchmark=0).values 103 | w = cla.max_sharpe() 104 | assert isinstance(w, dict) 105 | assert set(w.keys()) == set(cla.tickers) 106 | np.testing.assert_almost_equal(cla.weights.sum(), 1) 107 | np.testing.assert_allclose( 108 | cla.portfolio_performance(risk_free_rate=0.02), 109 | (0.2721798377099145, 0.07258537193305141, 3.474251505420551), 110 | atol=1e-4, 111 | rtol=1e-4, 112 | ) 113 | 114 | 115 | def test_cla_max_sharpe_exp_cov(): 116 | df = get_data() 117 | cla = setup_cla() 118 | cla.cov_matrix = risk_models.exp_cov(df).values 119 | w = cla.max_sharpe() 120 | assert isinstance(w, dict) 121 | assert set(w.keys()) == set(cla.tickers) 122 | np.testing.assert_almost_equal(cla.weights.sum(), 1) 123 | np.testing.assert_allclose( 124 | cla.portfolio_performance(risk_free_rate=0.02), 125 | (0.32971891062187103, 0.17670121760851704, 1.7527831149871063), 126 | ) 127 | 128 | 129 | def test_cla_min_volatility_exp_cov_short(): 130 | cla = setup_cla(weight_bounds=(-1, 1)) 131 | df = get_data() 132 | cla.cov_matrix = risk_models.exp_cov(df).values 133 | w = cla.min_volatility() 134 | assert isinstance(w, dict) 135 | assert set(w.keys()) == set(cla.tickers) 136 | np.testing.assert_almost_equal(cla.weights.sum(), 1) 137 | np.testing.assert_allclose( 138 | cla.portfolio_performance(risk_free_rate=0.02), 139 | (0.23215576461823062, 0.1325959061825329, 1.6000174569958052), 140 | ) 141 | 142 | 143 | def test_cla_efficient_frontier(): 144 | cla = setup_cla() 145 | 146 | cla.efficient_frontier() 147 | 148 | mu, sigma, weights = cla.efficient_frontier() 149 | assert len(mu) == len(sigma) and len(sigma) == len(weights) 150 | # higher return = higher risk 151 | assert sigma[-1] < sigma[0] and mu[-1] < mu[0] 152 | assert weights[0].shape == (20, 1) 153 | -------------------------------------------------------------------------------- /tests/test_expected_returns.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | import pytest 4 | 5 | from pypfopt import expected_returns 6 | from tests.utilities_for_tests import get_benchmark_data, get_data 7 | 8 | 9 | def test_returns_dataframe(): 10 | df = get_data() 11 | returns_df = expected_returns.returns_from_prices(df) 12 | assert isinstance(returns_df, pd.DataFrame) 13 | assert returns_df.shape[1] == 20 14 | assert len(returns_df) == 7125 15 | assert not ((returns_df > 1) & returns_df.notnull()).any().any() 16 | 17 | 18 | def test_prices_from_returns(): 19 | df = get_data() 20 | returns_df = df.pct_change() # keep NaN row 21 | 22 | # convert pseudo-price to price 23 | pseudo_prices = expected_returns.prices_from_returns(returns_df) 24 | initial_prices = df.bfill().iloc[0] 25 | test_prices = pseudo_prices * initial_prices 26 | 27 | # check equality, robust to floating point issues 28 | assert ((test_prices[1:] - df[1:]).fillna(0) < 1e-10).all().all() 29 | 30 | 31 | def test_prices_from_log_returns(): 32 | df = get_data() 33 | returns_df = df.pct_change() # keep NaN row 34 | log_returns_df = np.log1p(returns_df) 35 | 36 | # convert pseudo-price to price 37 | pseudo_prices = expected_returns.prices_from_returns( 38 | log_returns_df, log_returns=True 39 | ) 40 | initial_prices = df.bfill().iloc[0] 41 | test_prices = pseudo_prices * initial_prices 42 | 43 | # check equality, robust to floating point issues 44 | assert ((test_prices[1:] - df[1:]).fillna(0) < 1e-5).all().all() 45 | 46 | 47 | def test_returns_from_prices(): 48 | df = get_data() 49 | returns_df = expected_returns.returns_from_prices(df) 50 | pd.testing.assert_series_equal(returns_df.iloc[-1], df.pct_change().iloc[-1]) 51 | 52 | 53 | def test_returns_warning(): 54 | df = get_data() 55 | df.iloc[3, :] = 0 # make some prices zero 56 | with pytest.warns(UserWarning): 57 | expected_returns.mean_historical_return(df) 58 | 59 | 60 | def test_log_returns_from_prices(): 61 | df = get_data() 62 | old_nan = df.isnull().sum(axis=1).sum() 63 | log_rets = expected_returns.returns_from_prices(df, log_returns=True) 64 | new_nan = log_rets.isnull().sum(axis=1).sum() 65 | assert new_nan == old_nan 66 | np.testing.assert_almost_equal(log_rets.iloc[-1, -1], 0.0001682740081102576) 67 | 68 | 69 | def test_mean_historical_returns_dummy(): 70 | data = pd.DataFrame( 71 | [ 72 | [4.0, 2.0, 0.6, -12], 73 | [4.2, 2.1, 0.59, -13.2], 74 | [3.9, 2.0, 0.58, -11.3], 75 | [4.3, 2.1, 0.62, -11.7], 76 | [4.1, 2.2, 0.63, -10.1], 77 | ] 78 | ) 79 | mean = expected_returns.mean_historical_return(data, frequency=1) 80 | test_answer = pd.Series([0.0061922, 0.0241137, 0.0122722, -0.0421775]) 81 | pd.testing.assert_series_equal(mean, test_answer, rtol=1e-3) 82 | 83 | mean = expected_returns.mean_historical_return(data, compounding=False, frequency=1) 84 | test_answer = pd.Series([0.0086560, 0.0250000, 0.0128697, -0.03632333]) 85 | pd.testing.assert_series_equal(mean, test_answer, rtol=1e-3) 86 | 87 | 88 | def test_mean_historical_returns(): 89 | df = get_data() 90 | mean = expected_returns.mean_historical_return(df) 91 | assert isinstance(mean, pd.Series) 92 | assert list(mean.index) == list(df.columns) 93 | assert mean.notnull().all() 94 | assert mean.dtype == "float64" 95 | correct_mean = np.array( 96 | [ 97 | 0.247967, 98 | 0.294304, 99 | 0.284037, 100 | 0.1923164, 101 | 0.371327, 102 | 0.1360093, 103 | 0.0328503, 104 | 0.1200115, 105 | 0.105540, 106 | 0.0423457, 107 | 0.1002559, 108 | 0.1442237, 109 | -0.0792602, 110 | 0.1430506, 111 | 0.0736356, 112 | 0.238835, 113 | 0.388665, 114 | 0.226717, 115 | 0.1561701, 116 | 0.2318153, 117 | ] 118 | ) 119 | np.testing.assert_array_almost_equal(mean.values, correct_mean) 120 | 121 | 122 | def test_mean_historical_returns_type_warning(): 123 | df = get_data() 124 | mean = expected_returns.mean_historical_return(df) 125 | 126 | with pytest.warns(RuntimeWarning) as w: 127 | mean_from_array = expected_returns.mean_historical_return(np.array(df)) 128 | assert len(w) == 1 129 | assert str(w[0].message) == "prices are not in a dataframe" 130 | 131 | np.testing.assert_array_almost_equal(mean.values, mean_from_array.values, decimal=6) 132 | 133 | 134 | def test_mean_historical_returns_frequency(): 135 | df = get_data() 136 | mean = expected_returns.mean_historical_return(df, compounding=False) 137 | mean2 = expected_returns.mean_historical_return(df, compounding=False, frequency=52) 138 | np.testing.assert_array_almost_equal(mean / 252, mean2 / 52) 139 | 140 | 141 | def test_ema_historical_return(): 142 | df = get_data() 143 | mean = expected_returns.ema_historical_return(df) 144 | assert isinstance(mean, pd.Series) 145 | assert list(mean.index) == list(df.columns) 146 | assert mean.notnull().all() 147 | assert mean.dtype == "float64" 148 | # Test the (warning triggering) case that input is not a dataFrame 149 | with pytest.warns(RuntimeWarning): 150 | mean_np = expected_returns.ema_historical_return(df.to_numpy()) 151 | mean_np.name = mean.name # These will differ. 152 | reset_mean = mean.reset_index(drop=True) # Index labels would be tickers. 153 | pd.testing.assert_series_equal(mean_np, reset_mean) 154 | 155 | 156 | def test_ema_historical_return_frequency(): 157 | df = get_data() 158 | mean = expected_returns.ema_historical_return(df, compounding=False) 159 | mean2 = expected_returns.ema_historical_return(df, compounding=False, frequency=52) 160 | np.testing.assert_array_almost_equal(mean / 252, mean2 / 52) 161 | 162 | 163 | def test_ema_historical_return_limit(): 164 | df = get_data() 165 | sma = expected_returns.mean_historical_return(df, compounding=False) 166 | ema = expected_returns.ema_historical_return(df, compounding=False, span=1e10) 167 | np.testing.assert_array_almost_equal(ema.values, sma.values) 168 | 169 | 170 | def test_capm_no_benchmark(): 171 | df = get_data() 172 | mu = expected_returns.capm_return(df, risk_free_rate=0.02) 173 | assert isinstance(mu, pd.Series) 174 | assert list(mu.index) == list(df.columns) 175 | assert mu.notnull().all() 176 | assert mu.dtype == "float64" 177 | correct_mu = np.array( 178 | [ 179 | 0.22148462799238577, 180 | 0.2835429647498704, 181 | 0.14693081977908462, 182 | 0.1488989354304723, 183 | 0.4162399750335195, 184 | 0.22716772604184535, 185 | 0.3970337136813829, 186 | 0.16733214988182069, 187 | 0.31791477659742146, 188 | 0.17279931642386534, 189 | 0.15271750464365566, 190 | 0.351778014382922, 191 | 0.32859883451716376, 192 | 0.1501938182844417, 193 | 0.268295486802897, 194 | 0.31632339201710874, 195 | 0.27753479916328516, 196 | 0.16959588523287855, 197 | 0.3089119447773357, 198 | 0.2558719211959501, 199 | ] 200 | ) 201 | np.testing.assert_array_almost_equal(mu.values, correct_mu) 202 | # Test the (warning triggering) case that input is not a dataFrame 203 | with pytest.warns(RuntimeWarning): 204 | mu_np = expected_returns.capm_return(df.to_numpy(), risk_free_rate=0.02) 205 | mu_np.name = mu.name # These will differ. 206 | mu_np.index = mu.index # Index labels would be tickers. 207 | pd.testing.assert_series_equal(mu_np, mu) 208 | 209 | 210 | def test_capm_with_benchmark(): 211 | df = get_data() 212 | mkt_df = get_benchmark_data() 213 | mu = expected_returns.capm_return( 214 | df, market_prices=mkt_df, compounding=True, risk_free_rate=0.02 215 | ) 216 | 217 | assert isinstance(mu, pd.Series) 218 | assert list(mu.index) == list(df.columns) 219 | assert mu.notnull().all() 220 | assert mu.dtype == "float64" 221 | correct_mu = np.array( 222 | [ 223 | 0.09115799375654746, 224 | 0.09905386632033128, 225 | 0.05676282405265752, 226 | 0.06291827346436336, 227 | 0.13147799781014877, 228 | 0.10239088012000815, 229 | 0.1311567086884512, 230 | 0.07339649698626659, 231 | 0.1301248935078549, 232 | 0.07620949056643983, 233 | 0.07629095442513395, 234 | 0.12163575425541985, 235 | 0.10400070536161658, 236 | 0.0781736030988492, 237 | 0.09185177050469516, 238 | 0.10245700691271296, 239 | 0.11268307946677197, 240 | 0.07870087187919145, 241 | 0.1275598841214107, 242 | 0.09536788741392595, 243 | ] 244 | ) 245 | np.testing.assert_array_almost_equal(mu.values, correct_mu) 246 | 247 | mu2 = expected_returns.capm_return( 248 | df, market_prices=mkt_df, compounding=False, risk_free_rate=0.02 249 | ) 250 | assert (mu2 >= mu).all() 251 | 252 | 253 | def test_risk_matrix_and_returns_data(): 254 | # Test the switcher method for simple calls 255 | df = get_data() 256 | 257 | for method in {"mean_historical_return", "ema_historical_return", "capm_return"}: 258 | mu = expected_returns.return_model(df, method=method) 259 | 260 | assert isinstance(mu, pd.Series) 261 | assert list(mu.index) == list(df.columns) 262 | assert mu.notnull().all() 263 | assert mu.dtype == "float64" 264 | 265 | mu2 = expected_returns.return_model( 266 | expected_returns.returns_from_prices(df), method=method, returns_data=True 267 | ) 268 | pd.testing.assert_series_equal(mu, mu2) 269 | 270 | 271 | def test_return_model_additional_kwargs(): 272 | df = get_data() 273 | mkt_prices = get_benchmark_data() 274 | 275 | mu1 = expected_returns.return_model( 276 | df, method="capm_return", market_prices=mkt_prices, risk_free_rate=0.03 277 | ) 278 | mu2 = expected_returns.capm_return( 279 | df, market_prices=mkt_prices, risk_free_rate=0.03 280 | ) 281 | pd.testing.assert_series_equal(mu1, mu2) 282 | 283 | 284 | def test_return_model_not_implemented(): 285 | df = get_data() 286 | with pytest.raises(NotImplementedError): 287 | expected_returns.return_model(df, method="fancy_new!") 288 | 289 | 290 | def test_log_return_passthrough(): 291 | # addresses #343 292 | df = get_data() 293 | 294 | for method in {"mean_historical_return", "ema_historical_return", "capm_return"}: 295 | mu1 = expected_returns.return_model(df, method=method, log_returns=False) 296 | mu2 = expected_returns.return_model(df, method=method, log_returns=True) 297 | try: 298 | pd.testing.assert_series_equal(mu1, mu2) 299 | except AssertionError: 300 | return 301 | assert False 302 | -------------------------------------------------------------------------------- /tests/test_hrp.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | import pytest 4 | 5 | from pypfopt import CovarianceShrinkage, HRPOpt 6 | from tests.utilities_for_tests import get_data, resource 7 | 8 | 9 | def test_hrp_errors(): 10 | with pytest.raises(ValueError): 11 | hrp = HRPOpt() 12 | 13 | df = get_data() 14 | returns = df.pct_change().dropna(how="all") 15 | returns_np = returns.to_numpy() 16 | with pytest.raises(TypeError): 17 | hrp = HRPOpt(returns_np) 18 | 19 | hrp = HRPOpt(returns) 20 | with pytest.raises(ValueError): 21 | hrp.optimize(linkage_method="blah") 22 | 23 | 24 | def test_hrp_portfolio(): 25 | df = get_data() 26 | returns = df.pct_change().dropna(how="all") 27 | hrp = HRPOpt(returns) 28 | w = hrp.optimize(linkage_method="single") 29 | 30 | # uncomment this line if you want generating a new file 31 | # pd.Series(w).to_csv(resource("weights_hrp.csv")) 32 | 33 | x = pd.read_csv(resource("weights_hrp.csv"), index_col=0).squeeze("columns") 34 | pd.testing.assert_series_equal(x, pd.Series(w), check_names=False, rtol=1e-2) 35 | 36 | assert isinstance(w, dict) 37 | assert set(w.keys()) == set(df.columns) 38 | np.testing.assert_almost_equal(sum(w.values()), 1) 39 | assert all([i >= 0 for i in w.values()]) 40 | 41 | 42 | def test_portfolio_performance(): 43 | df = get_data() 44 | returns = df.pct_change().dropna(how="all") 45 | hrp = HRPOpt(returns) 46 | with pytest.raises(ValueError): 47 | hrp.portfolio_performance() 48 | hrp.optimize(linkage_method="single") 49 | np.testing.assert_allclose( 50 | hrp.portfolio_performance(risk_free_rate=0.02), 51 | (0.21353402380950973, 0.17844159743748936, 1.084579081272277), 52 | ) 53 | 54 | 55 | def test_pass_cov_matrix(): 56 | df = get_data() 57 | S = CovarianceShrinkage(df).ledoit_wolf() 58 | hrp = HRPOpt(cov_matrix=S) 59 | hrp.optimize(linkage_method="single") 60 | perf = hrp.portfolio_performance() 61 | assert perf[0] is None and perf[2] is None 62 | np.testing.assert_almost_equal(perf[1], 0.10002783894982334) 63 | 64 | 65 | def test_cluster_var(): 66 | df = get_data() 67 | returns = df.pct_change().dropna(how="all") 68 | cov = returns.cov() 69 | tickers = ["SHLD", "AMD", "BBY", "RRC", "FB", "WMT", "T", "BABA", "PFE", "UAA"] 70 | var = HRPOpt._get_cluster_var(cov, tickers) 71 | np.testing.assert_almost_equal(var, 0.00012842967106653283) 72 | 73 | 74 | def test_quasi_dag(): 75 | df = get_data() 76 | returns = df.pct_change().dropna(how="all") 77 | hrp = HRPOpt(returns) 78 | hrp.optimize(linkage_method="single") 79 | clusters = hrp.clusters 80 | assert HRPOpt._get_quasi_diag(clusters)[:5] == [12, 6, 15, 14, 2] 81 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | def test_import_modules(): 2 | from pypfopt import ( 3 | base_optimizer, 4 | black_litterman, 5 | cla, 6 | discrete_allocation, 7 | exceptions, 8 | expected_returns, 9 | hierarchical_portfolio, 10 | objective_functions, 11 | plotting, 12 | risk_models, 13 | ) 14 | 15 | 16 | def test_explicit_import(): 17 | from pypfopt.black_litterman import ( 18 | BlackLittermanModel, 19 | market_implied_prior_returns, 20 | market_implied_risk_aversion, 21 | ) 22 | from pypfopt.cla import CLA 23 | from pypfopt.discrete_allocation import DiscreteAllocation, get_latest_prices 24 | from pypfopt.efficient_frontier import ( 25 | EfficientCVaR, 26 | EfficientFrontier, 27 | EfficientSemivariance, 28 | ) 29 | from pypfopt.hierarchical_portfolio import HRPOpt 30 | from pypfopt.risk_models import CovarianceShrinkage 31 | 32 | 33 | def test_import_toplevel(): 34 | from pypfopt import ( 35 | CLA, 36 | BlackLittermanModel, 37 | CovarianceShrinkage, 38 | DiscreteAllocation, 39 | EfficientCVaR, 40 | EfficientFrontier, 41 | EfficientSemivariance, 42 | HRPOpt, 43 | get_latest_prices, 44 | market_implied_prior_returns, 45 | market_implied_risk_aversion, 46 | ) 47 | -------------------------------------------------------------------------------- /tests/test_objective_functions.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | from pypfopt import objective_functions 5 | from pypfopt.expected_returns import mean_historical_return, returns_from_prices 6 | from pypfopt.risk_models import sample_cov 7 | from tests.utilities_for_tests import get_data 8 | 9 | 10 | def test_volatility_dummy(): 11 | w = np.array([0.4, 0.4, 0.2]) 12 | data = np.diag([0.5, 0.8, 0.9]) 13 | test_var = objective_functions.portfolio_variance(w, data) 14 | np.testing.assert_almost_equal(test_var, 0.244) 15 | 16 | 17 | def test_volatility(): 18 | df = get_data() 19 | S = sample_cov(df) 20 | w = np.array([1 / df.shape[1]] * df.shape[1]) 21 | var = objective_functions.portfolio_variance(w, S) 22 | np.testing.assert_almost_equal(var, 0.04498224489292057) 23 | 24 | 25 | def test_portfolio_return_dummy(): 26 | w = np.array([0.3, 0.1, 0.2, 0.25, 0.15]) 27 | e_rets = pd.Series([0.19, 0.08, 0.09, 0.23, 0.17]) 28 | 29 | mu = objective_functions.portfolio_return(w, e_rets, negative=False) 30 | assert isinstance(mu, float) 31 | assert mu > 0 32 | np.testing.assert_almost_equal(mu, w.dot(e_rets)) 33 | np.testing.assert_almost_equal(mu, (w * e_rets).sum()) 34 | 35 | 36 | def test_portfolio_return_real(): 37 | df = get_data() 38 | e_rets = mean_historical_return(df) 39 | w = np.array([1 / len(e_rets)] * len(e_rets)) 40 | negative_mu = objective_functions.portfolio_return(w, e_rets) 41 | assert isinstance(negative_mu, float) 42 | assert negative_mu < 0 43 | np.testing.assert_almost_equal(negative_mu, -w.dot(e_rets)) 44 | np.testing.assert_almost_equal(negative_mu, -(w * e_rets).sum()) 45 | np.testing.assert_almost_equal(-e_rets.sum() / len(e_rets), negative_mu) 46 | 47 | 48 | def test_sharpe_ratio(): 49 | df = get_data() 50 | e_rets = mean_historical_return(df) 51 | S = sample_cov(df) 52 | w = np.array([1 / len(e_rets)] * len(e_rets)) 53 | 54 | sharpe = objective_functions.sharpe_ratio(w, e_rets, S, risk_free_rate=0.02) 55 | assert isinstance(sharpe, float) 56 | assert sharpe < 0 57 | 58 | sigma = np.sqrt(np.dot(w, np.dot(S, w.T))) 59 | negative_mu = objective_functions.portfolio_return(w, e_rets) 60 | np.testing.assert_almost_equal(sharpe * sigma - 0.02, negative_mu) 61 | 62 | # Risk free rate increasing should lead to negative Sharpe increasing. 63 | assert sharpe < objective_functions.sharpe_ratio(w, e_rets, S, risk_free_rate=0.1) 64 | 65 | 66 | def test_L2_reg_dummy(): 67 | gamma = 2 68 | w = np.array([0.1, 0.2, 0.3, 0.4]) 69 | L2_reg = objective_functions.L2_reg(w, gamma=gamma) 70 | np.testing.assert_almost_equal(L2_reg, gamma * np.sum(w * w)) 71 | 72 | 73 | def test_quadratic_utility(): 74 | df = get_data() 75 | e_rets = mean_historical_return(df) 76 | S = sample_cov(df) 77 | w = np.array([1 / len(e_rets)] * len(e_rets)) 78 | utility = objective_functions.quadratic_utility(w, e_rets, S, risk_aversion=3) 79 | assert isinstance(utility, float) 80 | assert utility < 0 81 | 82 | mu = objective_functions.portfolio_return(w, e_rets, negative=False) 83 | variance = objective_functions.portfolio_variance(w, S) 84 | np.testing.assert_almost_equal(-utility + 3 / 2 * variance, mu) 85 | 86 | 87 | def test_transaction_costs(): 88 | old_w = np.array([0.1, 0.2, 0.3]) 89 | new_w = np.array([-0.3, 0.1, 0.2]) 90 | 91 | k = 0.1 92 | tx_cost = k * np.abs(old_w - new_w).sum() 93 | assert tx_cost == objective_functions.transaction_cost(new_w, old_w, k=k) 94 | 95 | 96 | def test_ex_ante_tracking_error_dummy(): 97 | bm_w = np.ones(5) / 5 98 | w = np.array([0.4, 0.4, 0, 0, 0]) 99 | S = pd.DataFrame(np.eye(5)) 100 | 101 | te = objective_functions.ex_ante_tracking_error(w, S, bm_w) 102 | np.testing.assert_almost_equal(te, 0.2) 103 | 104 | 105 | def test_ex_ante_tracking_error(): 106 | df = get_data() 107 | n_assets = df.shape[1] 108 | # Equal weight benchmark 109 | bm_w = np.ones(n_assets) / n_assets 110 | portfolio_w = np.zeros(n_assets) 111 | portfolio_w[:5] = 0.2 112 | 113 | S = sample_cov(df) 114 | 115 | te = objective_functions.ex_ante_tracking_error(portfolio_w, S, bm_w) 116 | np.testing.assert_almost_equal(te, 0.028297778946639436) 117 | 118 | 119 | def test_ex_post_tracking_error(): 120 | df = get_data() 121 | rets = returns_from_prices(df).dropna() 122 | bm_rets = rets.mean(axis=1) 123 | w = np.ones((len(df.columns),)) / len(df.columns) 124 | 125 | # TE with the mean should be zero 126 | te = objective_functions.ex_post_tracking_error(w, rets, bm_rets) 127 | np.testing.assert_almost_equal(te, 0) 128 | 129 | # Should increase 130 | prev_te = te 131 | for mult in range(2, 20, 4): 132 | bm_rets_new = bm_rets * mult 133 | te = objective_functions.ex_post_tracking_error(w, rets, bm_rets_new) 134 | assert te > prev_te 135 | prev_te = te 136 | -------------------------------------------------------------------------------- /tests/test_plotting.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import matplotlib 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | import pandas as pd 8 | import pytest 9 | 10 | from pypfopt import ( 11 | CLA, 12 | EfficientFrontier, 13 | HRPOpt, 14 | expected_returns, 15 | plotting, 16 | risk_models, 17 | ) 18 | from tests.utilities_for_tests import get_data, setup_efficient_frontier 19 | 20 | 21 | def test_correlation_plot(): 22 | plt.figure() 23 | df = get_data() 24 | S = risk_models.CovarianceShrinkage(df).ledoit_wolf() 25 | ax = plotting.plot_covariance(S, showfig=False) 26 | assert len(ax.findobj()) > 250 27 | plt.clf() 28 | ax = plotting.plot_covariance(S, plot_correlation=True, showfig=False) 29 | assert len(ax.findobj()) > 250 30 | plt.clf() 31 | ax = plotting.plot_covariance(S, show_tickers=False, showfig=False) 32 | assert len(ax.findobj()) > 130 33 | plt.clf() 34 | ax = plotting.plot_covariance( 35 | S, plot_correlation=True, show_tickers=False, showfig=False 36 | ) 37 | assert len(ax.findobj()) > 130 38 | plt.clf() 39 | 40 | temp_folder = tempfile.TemporaryDirectory() 41 | temp_folder_path = temp_folder.name 42 | plot_filename = os.path.join(temp_folder_path, "plot.png") 43 | ax = plotting.plot_covariance(S, filename=plot_filename, showfig=False) 44 | assert len(ax.findobj()) > 250 45 | assert os.path.exists(plot_filename) 46 | assert os.path.getsize(plot_filename) > 0 47 | temp_folder.cleanup() 48 | plt.clf() 49 | plt.close() 50 | 51 | 52 | def test_dendrogram_plot(): 53 | plt.figure() 54 | df = get_data() 55 | returns = df.pct_change().dropna(how="all") 56 | hrp = HRPOpt(returns) 57 | hrp.optimize() 58 | 59 | ax = plotting.plot_dendrogram(hrp, showfig=False) 60 | assert len(ax.findobj()) > 180 61 | assert type(ax.findobj()[0]) == matplotlib.collections.LineCollection 62 | plt.clf() 63 | 64 | ax = plotting.plot_dendrogram(hrp, show_tickers=False, showfig=False) 65 | assert len(ax.findobj()) > 60 66 | assert type(ax.findobj()[0]) == matplotlib.collections.LineCollection 67 | plt.clf() 68 | plt.close() 69 | 70 | # Test that passing an unoptimized HRPOpt works, but issues a warning as 71 | # this should already have been optimized according to the API. 72 | hrp = HRPOpt(returns) 73 | with pytest.warns(RuntimeWarning) as w: 74 | ax = plotting.plot_dendrogram(hrp, show_tickers=False, showfig=False) 75 | assert len(w) <= 2 # the second is FutureWarning if exists 76 | assert ( 77 | str(w[0].message) 78 | == "hrp param has not been optimized. Attempting optimization." 79 | ) 80 | assert len(ax.findobj()) > 60 81 | assert type(ax.findobj()[0]) == matplotlib.collections.LineCollection 82 | plt.clf() 83 | plt.close() 84 | 85 | 86 | def test_cla_plot(): 87 | plt.figure() 88 | df = get_data() 89 | rets = expected_returns.mean_historical_return(df) 90 | S = risk_models.exp_cov(df) 91 | cla = CLA(rets, S) 92 | 93 | ax = plotting.plot_efficient_frontier(cla, showfig=False) 94 | assert len(ax.findobj()) > 130 95 | plt.clf() 96 | 97 | ax = plotting.plot_efficient_frontier(cla, show_assets=False, showfig=False) 98 | assert len(ax.findobj()) > 150 99 | plt.clf() 100 | plt.close() 101 | 102 | 103 | def test_cla_plot_ax(): 104 | plt.figure() 105 | df = get_data() 106 | rets = expected_returns.mean_historical_return(df) 107 | S = risk_models.exp_cov(df) 108 | cla = CLA(rets, S) 109 | 110 | fig, ax = plt.subplots(figsize=(12, 10)) 111 | plotting.plot_efficient_frontier(cla, ax=ax) 112 | assert len(ax.findobj()) > 130 113 | plt.close() 114 | plt.close() 115 | 116 | 117 | def test_default_ef_plot(): 118 | plt.figure() 119 | ef = setup_efficient_frontier() 120 | ax = plotting.plot_efficient_frontier(ef, show_assets=True) 121 | assert len(ax.findobj()) > 120 122 | plt.clf() 123 | 124 | # with constraints 125 | ef = setup_efficient_frontier() 126 | ef.add_constraint(lambda x: x <= 0.15) 127 | ef.add_constraint(lambda x: x[0] == 0.05) 128 | ax = plotting.plot_efficient_frontier(ef) 129 | assert len(ax.findobj()) > 120 130 | plt.clf() 131 | plt.close() 132 | 133 | 134 | def test_default_ef_plot_labels(): 135 | plt.figure() 136 | ef = setup_efficient_frontier() 137 | ax = plotting.plot_efficient_frontier(ef, show_assets=True, show_tickers=True) 138 | assert len(ax.findobj()) > 125 139 | plt.clf() 140 | 141 | 142 | def test_ef_plot_utility(): 143 | plt.figure() 144 | ef = setup_efficient_frontier() 145 | delta_range = np.arange(0.001, 50, 1) 146 | ax = plotting.plot_efficient_frontier( 147 | ef, ef_param="utility", ef_param_range=delta_range, showfig=False 148 | ) 149 | assert len(ax.findobj()) > 120 150 | plt.clf() 151 | plt.close() 152 | 153 | 154 | def test_ef_plot_errors(): 155 | plt.figure() 156 | ef = setup_efficient_frontier() 157 | delta_range = np.arange(0.001, 50, 1) 158 | # Test invalid ef_param 159 | with pytest.raises(NotImplementedError): 160 | plotting.plot_efficient_frontier( 161 | ef, ef_param="blah", ef_param_range=delta_range, showfig=False 162 | ) 163 | # Test invalid optimizer 164 | with pytest.raises(NotImplementedError): 165 | plotting.plot_efficient_frontier( 166 | None, ef_param_range=delta_range, showfig=False 167 | ) 168 | plt.clf() 169 | plt.close() 170 | 171 | 172 | def test_ef_plot_risk(): 173 | plt.figure() 174 | ef = setup_efficient_frontier() 175 | ef.min_volatility() 176 | min_risk = ef.portfolio_performance()[1] 177 | 178 | ef = setup_efficient_frontier() 179 | risk_range = np.linspace(min_risk + 0.05, 0.5, 30) 180 | ax = plotting.plot_efficient_frontier( 181 | ef, ef_param="risk", ef_param_range=risk_range, showfig=False 182 | ) 183 | assert len(ax.findobj()) > 120 184 | plt.clf() 185 | plt.close() 186 | 187 | 188 | def test_ef_plot_return(): 189 | plt.figure() 190 | ef = setup_efficient_frontier() 191 | # Internally _max_return() is used, so subtract epsilon 192 | max_ret = ef.expected_returns.max() - 0.0001 193 | return_range = np.linspace(0, max_ret, 30) 194 | ax = plotting.plot_efficient_frontier( 195 | ef, ef_param="return", ef_param_range=return_range, showfig=False 196 | ) 197 | assert len(ax.findobj()) > 120 198 | plt.clf() 199 | plt.close() 200 | 201 | 202 | def test_ef_plot_utility_short(): 203 | plt.figure() 204 | ef = EfficientFrontier( 205 | *setup_efficient_frontier(data_only=True), weight_bounds=(None, None) 206 | ) 207 | delta_range = np.linspace(0.001, 20, 50) 208 | ax = plotting.plot_efficient_frontier( 209 | ef, ef_param="utility", ef_param_range=delta_range, showfig=False 210 | ) 211 | assert len(ax.findobj()) > 150 212 | plt.clf() 213 | plt.close() 214 | 215 | 216 | def test_constrained_ef_plot_utility(): 217 | plt.figure() 218 | ef = setup_efficient_frontier() 219 | ef.add_constraint(lambda w: w[0] >= 0.2) 220 | ef.add_constraint(lambda w: w[2] == 0.15) 221 | ef.add_constraint(lambda w: w[3] + w[4] <= 0.10) 222 | 223 | delta_range = np.linspace(0.001, 20, 50) 224 | ax = plotting.plot_efficient_frontier( 225 | ef, ef_param="utility", ef_param_range=delta_range, showfig=False 226 | ) 227 | assert len(ax.findobj()) > 120 228 | plt.clf() 229 | plt.close() 230 | 231 | 232 | def test_constrained_ef_plot_risk(): 233 | plt.figure() 234 | ef = EfficientFrontier( 235 | *setup_efficient_frontier(data_only=True), weight_bounds=(None, None) 236 | ) 237 | 238 | ef.add_constraint(lambda w: w[0] >= 0.2) 239 | ef.add_constraint(lambda w: w[2] == 0.15) 240 | ef.add_constraint(lambda w: w[3] + w[4] <= 0.10) 241 | 242 | # 100 portfolios with risks between 0.10 and 0.30 243 | risk_range = np.linspace(0.157, 0.40, 50) 244 | ax = plotting.plot_efficient_frontier( 245 | ef, ef_param="risk", ef_param_range=risk_range, show_assets=True, showfig=False 246 | ) 247 | assert len(ax.findobj()) > 130 248 | plt.clf() 249 | plt.close() 250 | 251 | 252 | def test_weight_plot(): 253 | plt.figure() 254 | df = get_data() 255 | returns = df.pct_change().dropna(how="all") 256 | hrp = HRPOpt(returns) 257 | w = hrp.optimize() 258 | 259 | ax = plotting.plot_weights(w, showfig=False) 260 | assert len(ax.findobj()) > 190 261 | plt.clf() 262 | plt.close() 263 | 264 | 265 | def test_weight_plot_multi(): 266 | ef = setup_efficient_frontier() 267 | w1 = ef.min_volatility() 268 | ef = setup_efficient_frontier() 269 | w2 = ef.max_sharpe() 270 | 271 | fig, (ax1, ax2) = plt.subplots(2) 272 | plotting.plot_weights(w1, ax1, showfig=False) 273 | plotting.plot_weights(w2, ax2, showfig=False) 274 | 275 | assert len(fig.axes) == 2 276 | assert len(fig.axes[0].findobj()) > 200 277 | assert len(fig.axes[1].findobj()) > 200 278 | plt.close() 279 | 280 | 281 | def test_weight_plot_add_attribute(): 282 | plt.figure() 283 | 284 | ef = setup_efficient_frontier() 285 | w = ef.min_volatility() 286 | ax = plotting.plot_weights(w) 287 | ax.set_title("Test") 288 | assert len(ax.findobj()) > 200 289 | plt.close() 290 | 291 | 292 | def test_plotting_edge_case(): 293 | # raised in issue #333 294 | mu = pd.Series([0.043389, 0.036194]) 295 | S = pd.DataFrame([[0.000562, 0.002273], [0.002273, 0.027710]]) 296 | ef = EfficientFrontier(mu, S) 297 | fig, ax = plt.subplots() 298 | 299 | with pytest.warns(UserWarning): 300 | plotting.plot_efficient_frontier( 301 | ef, 302 | ef_param="return", 303 | ef_param_range=np.linspace(0.036194, 0.043389, 10), 304 | ax=ax, 305 | show_assets=False, 306 | ) 307 | 308 | 309 | def test_plot_efficient_frontier(): 310 | ef = setup_efficient_frontier() 311 | ef.min_volatility() 312 | optimal_ret, optimal_risk, _ = ef.portfolio_performance(risk_free_rate=0.02) 313 | -------------------------------------------------------------------------------- /tests/utilities_for_tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numpy as np 4 | import pandas as pd 5 | 6 | from pypfopt import expected_returns, risk_models 7 | from pypfopt.cla import CLA 8 | from pypfopt.efficient_frontier import ( 9 | EfficientCDaR, 10 | EfficientCVaR, 11 | EfficientFrontier, 12 | EfficientSemivariance, 13 | ) 14 | 15 | 16 | def resource(name): 17 | return os.path.join(os.path.dirname(__file__), "resources", name) 18 | 19 | 20 | def get_data(): 21 | return pd.read_csv(resource("stock_prices.csv"), parse_dates=True, index_col="date") 22 | 23 | 24 | def get_benchmark_data(): 25 | return pd.read_csv(resource("spy_prices.csv"), parse_dates=True, index_col="date") 26 | 27 | 28 | def get_market_caps(): 29 | mcaps = { 30 | "GOOG": 927e9, 31 | "AAPL": 1.19e12, 32 | "FB": 574e9, 33 | "BABA": 533e9, 34 | "AMZN": 867e9, 35 | "GE": 96e9, 36 | "AMD": 43e9, 37 | "WMT": 339e9, 38 | "BAC": 301e9, 39 | "GM": 51e9, 40 | "T": 61e9, 41 | "UAA": 78e9, 42 | "SHLD": 0, 43 | "XOM": 295e9, 44 | "RRC": 1e9, 45 | "BBY": 22e9, 46 | "MA": 288e9, 47 | "PFE": 212e9, 48 | "JPM": 422e9, 49 | "SBUX": 102e9, 50 | } 51 | return mcaps 52 | 53 | 54 | def get_cov_matrix(): 55 | return pd.read_csv(resource("cov_matrix.csv"), index_col=0) 56 | 57 | 58 | def setup_efficient_frontier(data_only=False, *args, **kwargs): 59 | df = get_data() 60 | mean_return = expected_returns.mean_historical_return(df) 61 | sample_cov_matrix = risk_models.sample_cov(df) 62 | if data_only: 63 | return mean_return, sample_cov_matrix 64 | return EfficientFrontier( 65 | mean_return, sample_cov_matrix, verbose=True, *args, **kwargs 66 | ) 67 | 68 | 69 | def setup_efficient_semivariance(data_only=False, *args, **kwargs): 70 | df = get_data().dropna(axis=0, how="any") 71 | mean_return = expected_returns.mean_historical_return(df) 72 | historic_returns = expected_returns.returns_from_prices(df) 73 | if data_only: 74 | return mean_return, historic_returns 75 | return EfficientSemivariance( 76 | mean_return, historic_returns, verbose=True, *args, **kwargs 77 | ) 78 | 79 | 80 | def setup_efficient_cvar(data_only=False, *args, **kwargs): 81 | df = get_data().dropna(axis=0, how="any") 82 | mean_return = expected_returns.mean_historical_return(df) 83 | historic_returns = expected_returns.returns_from_prices(df) 84 | if data_only: 85 | return mean_return, historic_returns 86 | return EfficientCVaR(mean_return, historic_returns, verbose=True, *args, **kwargs) 87 | 88 | 89 | def setup_efficient_cdar(data_only=False, *args, **kwargs): 90 | df = get_data().dropna(axis=0, how="any") 91 | mean_return = expected_returns.mean_historical_return(df) 92 | historic_returns = expected_returns.returns_from_prices(df) 93 | if data_only: 94 | return mean_return, historic_returns 95 | return EfficientCDaR(mean_return, historic_returns, verbose=True, *args, **kwargs) 96 | 97 | 98 | def setup_cla(data_only=False, *args, **kwargs): 99 | df = get_data() 100 | mean_return = expected_returns.mean_historical_return(df) 101 | sample_cov_matrix = risk_models.sample_cov(df) 102 | if data_only: 103 | return mean_return, sample_cov_matrix 104 | return CLA(mean_return, sample_cov_matrix, *args, **kwargs) 105 | 106 | 107 | def simple_ef_weights(expected_returns, cov_matrix, target_return, weights_sum): 108 | """ 109 | Calculate weights to achieve target_return on the efficient frontier. 110 | The only constraint is the sum of the weights. 111 | Note: This is just a simple test utility, it does not support the generalised 112 | constraints that EfficientFrontier does and is used to check the results 113 | of EfficientFrontier in simple cases. In particular it is not capable of 114 | preventing negative weights (shorting). 115 | :param expected_returns: expected returns for each asset. 116 | :type expected_returns: np.ndarray 117 | :param cov_matrix: covariance of returns for each asset. 118 | :type cov_matrix: np.ndarray 119 | :param target_return: the target return for the portfolio to achieve. 120 | :type target_return: float 121 | :param weights_sum: the sum of the returned weights, optimization constraint. 122 | :type weights_sum: float 123 | :return: weight for each asset, which sum to 1.0 124 | :rtype: np.ndarray 125 | """ 126 | # Solve using Lagrangian and matrix inversion. 127 | r = expected_returns.reshape((-1, 1)) 128 | m = np.block( 129 | [ 130 | [cov_matrix, r, np.ones(r.shape)], 131 | [r.transpose(), 0, 0], 132 | [np.ones(r.shape).transpose(), 0, 0], 133 | ] 134 | ) 135 | y = np.block([[np.zeros(r.shape)], [target_return], [weights_sum]]) 136 | x = np.linalg.inv(m) @ y 137 | # Weights are all but the last 2 elements, which are the lambdas. 138 | w = x.flatten()[:-2] 139 | return w 140 | --------------------------------------------------------------------------------