├── .gitignore ├── LICENSE.txt ├── README.md ├── optimalportfolios ├── __init__.py ├── config.py ├── covar_estimation │ ├── __init__.py │ ├── config.py │ ├── covar_estimator.py │ ├── current_covar.py │ ├── rolling_covar.py │ └── utils.py ├── examples │ ├── crypto_allocation │ │ ├── README.md │ │ ├── article_figures.py │ │ ├── backtest_portfolios_for_article.py │ │ ├── data │ │ │ ├── BTC_from_2010.csv │ │ │ ├── CTA_Historical.xlsx │ │ │ ├── HFRX_historical_HFRXGL.csv │ │ │ ├── Macro_Trading_Index_Historical.xlsx │ │ │ ├── crypto_allocation_prices.csv │ │ │ └── crypto_allocation_prices_updated.csv │ │ ├── load_prices.py │ │ └── perf_crypto_portfolios.py │ ├── figures │ │ ├── MinVariance_multi_covar_estimator_backtest.PNG │ │ ├── example_customised_report.PNG │ │ ├── example_portfolio_factsheet1.PNG │ │ ├── example_portfolio_factsheet2.PNG │ │ ├── max_diversification_span.PNG │ │ └── multi_optimisers_backtest.PNG │ ├── lasso_covar_estimation.py │ ├── lasso_estimation.py │ ├── long_short_optimisation.py │ ├── multi_covar_estimation_backtest.py │ ├── multi_optimisers_backtest.py │ ├── optimal_portfolio_backtest.py │ ├── parameter_sensitivity_backtest.py │ ├── risk_contribution_balanced_portfolio.py │ ├── solvers │ │ ├── carra_mixture.py │ │ ├── max_diversification.py │ │ ├── max_sharpe.py │ │ ├── min_variance.py │ │ ├── risk_parity.py │ │ ├── target_return.py │ │ └── tracking_error.py │ ├── sp500_minvar.py │ └── universe.py ├── lasso │ ├── __init__.py │ └── lasso_model_estimator.py ├── local_path.py ├── optimization │ ├── __init__.py │ ├── constraints.py │ ├── solvers │ │ ├── __init__.py │ │ ├── carra_mixure.py │ │ ├── max_diversification.py │ │ ├── max_sharpe.py │ │ ├── quadratic.py │ │ ├── risk_budgeting.py │ │ ├── target_return.py │ │ └── tracking_error.py │ └── wrapper_rolling_portfolios.py ├── reports │ ├── __init__.py │ ├── backtest_alphas.py │ ├── config.py │ └── marginal_backtest.py ├── test_data.py └── utils │ ├── __init__.py │ ├── factor_alphas.py │ ├── filter_nans.py │ ├── gaussian_mixture.py │ ├── manager_alphas.py │ └── portfolio_funcs.py ├── pyproject.toml ├── pyrb ├── README.md ├── __init__.py ├── allocation.py ├── settings.py ├── solvers.py ├── tools.py └── validation.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # created by virtualenv automatically 2 | /quant_strats/ 3 | 4 | .idea/ 5 | Lib/ 6 | Scripts/ 7 | dist/ 8 | 9 | __pycache__/ 10 | 11 | # YAML 12 | *.yaml 13 | 14 | # Byte-compiled / optimized / DLL files 15 | docs/figures/ 16 | 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | downloads/ 28 | eggs/ 29 | .eggs/ 30 | lib/ 31 | lib64/ 32 | parts/ 33 | sdist/ 34 | var/ 35 | wheels/ 36 | pip-wheel-metadata/ 37 | share/python-wheels/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | MANIFEST 42 | .*xml 43 | .*iml 44 | 45 | # PyInstaller 46 | # Usually these files are written by a python script from a template 47 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 48 | *.manifest 49 | *.spec 50 | 51 | # Installer logs 52 | pip-log.txt 53 | pip-delete-this-directory.txt 54 | 55 | # Unit test / coverage reports 56 | htmlcov/ 57 | .tox/ 58 | .nox/ 59 | .coverage 60 | .coverage.* 61 | .cache 62 | nosetests.xml 63 | coverage.xml 64 | *.cover 65 | *.py,cover 66 | .hypothesis/ 67 | .pytest_cache/ 68 | 69 | # Translations 70 | *.mo 71 | *.pot 72 | 73 | # Django stuff: 74 | *.log 75 | local_settings.py 76 | db.sqlite3 77 | db.sqlite3-journal 78 | 79 | # Flask stuff: 80 | instance/ 81 | .webassets-cache 82 | 83 | # Scrapy stuff: 84 | .scrapy 85 | 86 | # Sphinx documentation 87 | docs/_build/ 88 | 89 | # PyBuilder 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # IPython 96 | profile_default/ 97 | ipython_config.py 98 | 99 | # pyenv 100 | .python-version 101 | 102 | # pipenv 103 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 104 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 105 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 106 | # install all needed dependencies. 107 | #Pipfile.lock 108 | 109 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 110 | __pypackages__/ 111 | 112 | # Celery stuff 113 | celerybeat-schedule 114 | celerybeat.pid 115 | 116 | # SageMath parsed files 117 | *.sage.py 118 | 119 | # Environments 120 | .env 121 | .venv 122 | env/ 123 | venv/ 124 | ENV/ 125 | env.bak/ 126 | venv.bak/ 127 | 128 | # Spyder project settings 129 | .spyderproject 130 | .spyproject 131 | 132 | # Rope project settings 133 | .ropeproject 134 | 135 | # mkdocs documentation 136 | /site 137 | 138 | # mypy 139 | .mypy_cache/ 140 | .dmypy.json 141 | dmypy.json 142 | 143 | # Pyre type checker 144 | .pyre/ 145 | 146 | /dist/ 147 | *.xml 148 | *.pyc 149 | /mac_portfolio_optimizer/ 150 | -------------------------------------------------------------------------------- /optimalportfolios/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import optimalportfolios.local_path 3 | 4 | from optimalportfolios.config import PortfolioObjective 5 | 6 | from optimalportfolios.utils.__init__ import * 7 | 8 | from optimalportfolios.lasso.__init__ import * 9 | 10 | from optimalportfolios.covar_estimation.__init__ import * 11 | 12 | from optimalportfolios.optimization.__init__ import * 13 | -------------------------------------------------------------------------------- /optimalportfolios/config.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class PortfolioObjective(Enum): 5 | """ 6 | implemented portfolios in rolling_engine 7 | """ 8 | # risk-based: 9 | MAX_DIVERSIFICATION = 'MaxDiversification' # maximum diversification measure 10 | EQUAL_RISK_CONTRIBUTION = 'EqualRisk' # implementation in risk_parity 11 | MIN_VARIANCE = 'MinVariance' # min w^t @ covar @ w 12 | # return-risk based 13 | QUADRATIC_UTILITY = 'QuadraticUtil' # max means^t*w- 0.5*gamma*w^t*covar*w 14 | MAXIMUM_SHARPE_RATIO = 'MaximumSharpe' # max means^t*w / sqrt(*w^t*covar*w) 15 | # return-skeweness based 16 | MAX_CARA_MIXTURE = 'MaxCarraMixture' # carra for mixture distributions 17 | 18 | 19 | -------------------------------------------------------------------------------- /optimalportfolios/covar_estimation/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from optimalportfolios.covar_estimation.covar_estimator import CovarEstimator 3 | 4 | from optimalportfolios.covar_estimation.config import CovarEstimatorType 5 | 6 | from optimalportfolios.covar_estimation.rolling_covar import (EstimatedRollingCovarData, 7 | wrapper_estimate_rolling_covar, 8 | estimate_rolling_ewma_covar, 9 | wrapper_estimate_rolling_lasso_covar, 10 | estimate_rolling_lasso_covar, 11 | estimate_rolling_lasso_covar_different_freq) 12 | 13 | from optimalportfolios.covar_estimation.current_covar import (EstimatedCurrentCovarData, 14 | wrapper_estimate_current_covar, 15 | estimate_current_ewma_covar, 16 | wrapper_estimate_current_lasso_covar, 17 | estimate_lasso_covar, 18 | estimate_lasso_covar_different_freq) -------------------------------------------------------------------------------- /optimalportfolios/covar_estimation/config.py: -------------------------------------------------------------------------------- 1 | 2 | from enum import Enum 3 | 4 | 5 | class CovarEstimatorType(Enum): 6 | EWMA = 1 7 | LASSO = 2 8 | -------------------------------------------------------------------------------- /optimalportfolios/covar_estimation/covar_estimator.py: -------------------------------------------------------------------------------- 1 | """ 2 | some utilities for estimation of covariance matrices 3 | """ 4 | from __future__ import annotations 5 | import pandas as pd 6 | import qis as qis 7 | from typing import Union, Optional, Dict, Any 8 | from dataclasses import dataclass, asdict, field 9 | 10 | # project 11 | from optimalportfolios.covar_estimation.config import CovarEstimatorType 12 | from optimalportfolios.lasso.lasso_model_estimator import LassoModel 13 | from optimalportfolios.covar_estimation.rolling_covar import EstimatedRollingCovarData, wrapper_estimate_rolling_covar 14 | from optimalportfolios.covar_estimation.current_covar import EstimatedCurrentCovarData, wrapper_estimate_current_covar 15 | 16 | 17 | @dataclass 18 | class CovarEstimator: 19 | """ 20 | specifies estimator specific parameters 21 | CovarEstimator supports: 22 | fit_rolling_covars() 23 | fit_covars() 24 | """ 25 | covar_estimator_type: CovarEstimatorType = CovarEstimatorType.EWMA 26 | lasso_model: LassoModel = None # for mandatory lasso estimator 27 | factor_returns_freq: str = 'W-WED' # for lasso estimator 28 | rebalancing_freq: str = 'QE' # sampling frequency for computing covariance matrix at rebalancing dates 29 | returns_freqs: Union[str, pd.Series] = 'ME' # frequency of returns for beta estimation 30 | span: int = 52 # span for ewma estimate 31 | is_apply_vol_normalised_returns: bool = False # for ewma 32 | demean: bool = True # adjust for mean 33 | squeeze_factor: Optional[float] = None # squeezing factor for ewma covars 34 | residual_var_weight: float = 1.0 # for lasso covars 35 | span_freq_dict: Optional[Dict[str, int]] = None # spans for different freqs 36 | var_scaler_freq_dict: Optional[Dict[str, float]] = None # var scaler for different freqs 37 | is_adjust_for_newey_west: bool = False 38 | num_lags_newey_west: Dict[str, int] = field(default_factory=lambda: {'ME': 0, 'QE': 2}) 39 | 40 | def to_dict(self) -> Dict[str, Any]: 41 | this = asdict(self) 42 | if self.lasso_model is not None: # need to make it dataclass 43 | this['lasso_model'] = LassoModel(**this['lasso_model']) 44 | return this 45 | 46 | def fit_rolling_covars(self, 47 | prices: pd.DataFrame, 48 | time_period: qis.TimePeriod, 49 | risk_factor_prices: pd.DataFrame = None, 50 | ) -> EstimatedRollingCovarData: 51 | """ 52 | fit rolling covars at rebalancing_freq 53 | time_period is for what period we need 54 | """ 55 | rolling_covar_data = wrapper_estimate_rolling_covar(prices=prices, 56 | risk_factor_prices=risk_factor_prices, 57 | time_period=time_period, 58 | returns_freq=self.factor_returns_freq, 59 | **self.to_dict()) 60 | return rolling_covar_data 61 | 62 | def fit_current_covars(self, 63 | prices: pd.DataFrame, 64 | risk_factor_prices: pd.DataFrame = None, 65 | ) -> EstimatedCurrentCovarData: 66 | """ 67 | fit rolling covars at rebalancing_freq 68 | time_period is for what period we need 69 | """ 70 | rolling_covar_data = wrapper_estimate_current_covar(prices=prices, 71 | risk_factor_prices=risk_factor_prices, 72 | **self.to_dict()) 73 | return rolling_covar_data 74 | -------------------------------------------------------------------------------- /optimalportfolios/covar_estimation/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Union, Optional 4 | 5 | import numpy as np 6 | import pandas as pd 7 | import qis as qis 8 | 9 | 10 | def squeeze_covariance_matrix(covar: Union[np.ndarray, pd.DataFrame], 11 | squeeze_factor: Optional[float] = 0.05, 12 | is_preserve_variance: bool = True 13 | ) -> Union[np.ndarray, pd.DataFrame]: 14 | """ 15 | Adjusts the covariance matrix by applying a squeezing factor to eigenvalues. 16 | Smaller eigenvalues are reduced to mitigate noise. 17 | for the methodology see SSRN paper 18 | Squeezing Financial Noise: A Novel Approach to Covariance Matrix Estimation 19 | https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4986939 20 | """ 21 | if squeeze_factor is None or np.isclose(squeeze_factor, 0.0): 22 | return covar 23 | 24 | # need to create pd.Dataframe for keeping track of good indices 25 | if isinstance(covar, pd.DataFrame): 26 | cov_matrix_pd = covar.copy() 27 | else: 28 | cov_matrix_pd = pd.DataFrame(covar) 29 | 30 | # filter out nans and zero variances 31 | variances = np.diag(cov_matrix_pd.to_numpy()) 32 | is_good_asset = np.where(np.logical_and(np.greater(variances, 0.0), np.isnan(variances) == False)) 33 | good_tickers = cov_matrix_pd.columns[is_good_asset] 34 | clean_covar_pd = cov_matrix_pd.loc[good_tickers, good_tickers] 35 | clean_covar_np = clean_covar_pd.to_numpy() 36 | 37 | # Eigen decomposition 38 | eigenvalues, eigenvectors = np.linalg.eigh(clean_covar_np) 39 | 40 | # Squeeze smaller eigenvalues (simple threshold-based squeezing) 41 | squeezed_eigenvalues = np.array([np.maximum(eigenvalue, squeeze_factor * np.max(eigenvalues)) 42 | for eigenvalue in eigenvalues]) 43 | 44 | # Reconstruct squeezed covariance matrix 45 | squeezed_cov_matrix = eigenvectors @ np.diag(squeezed_eigenvalues) @ eigenvectors.T 46 | 47 | if is_preserve_variance: 48 | # adjustment should be applied to off-dioagonal elements too otherwise we may end up with noncosistent matrix 49 | original_variance = np.diag(clean_covar_np) 50 | squeezed_variance = np.diag(squeezed_cov_matrix) 51 | adjustment_ratio = np.sqrt(original_variance / squeezed_variance) 52 | norm = np.outer(adjustment_ratio, adjustment_ratio) 53 | squeezed_cov_matrix = norm*squeezed_cov_matrix 54 | 55 | # now extend back 56 | squeezed_cov_matrix_pd = pd.DataFrame(squeezed_cov_matrix, index=good_tickers, columns=good_tickers) 57 | # reindex for all tickers and fill nans with zeros 58 | all_tickers = cov_matrix_pd.columns 59 | squeezed_cov_matrix = squeezed_cov_matrix_pd.reindex(index=all_tickers).reindex(columns=all_tickers).fillna(0.0) 60 | 61 | if isinstance(covar, np.ndarray): # match return to original type 62 | squeezed_cov_matrix = squeezed_cov_matrix.to_numpy() 63 | return squeezed_cov_matrix 64 | 65 | 66 | def compute_returns_from_prices(prices: pd.DataFrame, 67 | returns_freq: str = 'ME', 68 | demean: bool = True, 69 | span: Optional[int] = 52 70 | ) -> pd.DataFrame: 71 | """ 72 | compute returns for covar matrix estimation 73 | """ 74 | returns = qis.to_returns(prices=prices, is_log_returns=True, drop_first=True, freq=returns_freq) 75 | if demean: 76 | returns = returns - qis.compute_ewm(returns, span=span) 77 | # returns.iloc[0, :] will be zero so shift the period 78 | returns = returns.iloc[1:, :] 79 | return returns 80 | -------------------------------------------------------------------------------- /optimalportfolios/examples/crypto_allocation/README.md: -------------------------------------------------------------------------------- 1 | Implementation of simulations for paper: 2 | 3 | Sepp A. (2023) Optimal Allocation to Cryptocurrencies in Diversified Portfolios, 4 | Risk (October 2023, 1-6) Available at SSRN: https://ssrn.com/abstract=4217841 5 | 6 | The analysis presented in the paper can be replicated or extended using this module 7 | 8 | Implementation steps: 9 | 1) Populate the time series of asset prices in the investable universe using 10 | ```python 11 | optimaportfolios/examples/crypto_allocation/load_prices.py 12 | ``` 13 | 14 | Price data for some assets can be fetched from local csv files, some can be generated on the fly 15 | 16 | Run 17 | ```python 18 | update_prices() 19 | ``` 20 | 21 | 2) Generate article figures using unit tests in 22 | ```python 23 | optimaportfolios/examples/crypto_allocation/article_figures.py 24 | ``` 25 | 26 | 3) Generate reports of simulated investment portfolios as reported in the article 27 | ```python 28 | optimaportfolios/examples/crypto_allocation/backtest_portfolios_for_article.py 29 | ``` 30 | 31 | -------------------------------------------------------------------------------- /optimalportfolios/examples/crypto_allocation/data/CTA_Historical.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArturSepp/OptimalPortfolios/368e115bc3c9e8a8b698806b7302e2887210de6b/optimalportfolios/examples/crypto_allocation/data/CTA_Historical.xlsx -------------------------------------------------------------------------------- /optimalportfolios/examples/crypto_allocation/data/Macro_Trading_Index_Historical.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArturSepp/OptimalPortfolios/368e115bc3c9e8a8b698806b7302e2887210de6b/optimalportfolios/examples/crypto_allocation/data/Macro_Trading_Index_Historical.xlsx -------------------------------------------------------------------------------- /optimalportfolios/examples/crypto_allocation/load_prices.py: -------------------------------------------------------------------------------- 1 | """ 2 | generate and load prices for portfolio allocation problem 3 | update_prices_with_bloomberg() must used with Bloomberg open 4 | update_prices_with_yf() uses yfinance + some data uploaded manually 5 | NB: CmcScraper stopped working so now only option is to use bloomberg 6 | """ 7 | import pandas as pd 8 | import yfinance as yf 9 | import matplotlib.pyplot as plt 10 | from typing import List, Literal, Union, Optional 11 | from enum import Enum 12 | # from cryptocmd import CmcScraper its stopped working 13 | import qis 14 | 15 | # add the local path to data files 16 | LOCAL_PATH = 'C://Users//artur//OneDrive//analytics//my_github//OptimalPortfolios//optimalportfolios//examples//crypto_allocation//data//' 17 | 18 | # data sources 19 | BTC_PRICES_FROM_2010 = 'BTC_from_2010' # csv data with static BTC from2010 upto 31Jul2022 20 | HFRXGL_PRICE = 'HFRX_historical_HFRXGL' # global HF from https://www.hfr.com/indices/hfrx-global-hedge-fund-index - download daily data, remove top and bottom rows with descriptive data 21 | CTA_PRICE = 'CTA_Historical' # SG CTA Index from https://wholesale.banking.societegenerale.com/en/prime-services-indices/ 22 | MACRO_PRICE = 'Macro_Trading_Index_Historical' # Macro SCTas from https://wholesale.banking.societegenerale.com/fileadmin/indices_feeds/Macro_Trading_Index_Historical.xls 23 | 24 | PRICE_DATA_FILE = 'crypto_allocation_prices' 25 | PRICE_DATA_FILE_UPDATED = 'crypto_allocation_prices_updated' 26 | 27 | 28 | class Assets(Enum): 29 | # with name and bloomberg ticker 30 | BTC = 'BTC' 31 | ETH = 'ETH' 32 | BAL = '60/40' 33 | HF = 'HFs' 34 | PE = 'PE' 35 | RE = 'RealEstate' 36 | MACRO = 'Macro' 37 | CTA = 'SG CTA' 38 | GLD = 'Gold' 39 | COMS = 'Commodities' 40 | 41 | 42 | def update_prices_with_yf() -> pd.DataFrame: 43 | """ 44 | generate price data using yfinance 45 | """ 46 | btc = create_btc_price() 47 | eth = create_eth_price(btc_price=qis.load_df_from_csv(file_name=BTC_PRICES_FROM_2010, local_path=LOCAL_PATH).iloc[:, 0]) 48 | bal = create_balanced_price() 49 | 50 | # REET starts from Jul 08, 2014 - use IYR for backfill 51 | iyr = yf.download('IYR', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.RE.value) 52 | reet = yf.download('REET', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.RE.value) 53 | re = qis.bfill_timeseries(df_newer=reet, df_older=iyr, is_prices=True) 54 | 55 | # COMT starts from 2014-10-16, use GSG for backfill 56 | coms0 = yf.download('GSG', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.COMS.value) 57 | coms1 = yf.download('COMT', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.COMS.value) 58 | coms = qis.bfill_timeseries(df_newer=coms1, df_older=coms0, is_prices=True) 59 | 60 | # use local copies 61 | hf = qis.load_df_from_csv(file_name=HFRXGL_PRICE, local_path=LOCAL_PATH)['Index Value'].rename(Assets.HF.value).sort_index() 62 | cta = qis.load_df_from_excel(file_name=CTA_PRICE, local_path=LOCAL_PATH).iloc[:, 0].rename(Assets.CTA.value) 63 | macro = qis.load_df_from_excel(file_name=MACRO_PRICE, local_path=LOCAL_PATH).iloc[:, 0].rename(Assets.MACRO.value) 64 | 65 | # etfs 66 | gld = yf.download('GLD', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.GLD.value) 67 | pe = yf.download('PSP', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.PE.value) 68 | 69 | prices = pd.concat([bal, btc, eth, hf, pe, re, macro, cta, coms, gld], axis=1) 70 | # to business day frequency 71 | prices = prices.asfreq('B', method='ffill').ffill() 72 | qis.save_df_to_csv(prices, file_name=PRICE_DATA_FILE, local_path=LOCAL_PATH) 73 | 74 | return prices 75 | 76 | 77 | def update_prices_with_bloomberg() -> pd.DataFrame: 78 | """ 79 | generate price data using yfinance 80 | """ 81 | from bbg_fetch import fetch_field_timeseries_per_tickers 82 | 83 | btc = fetch_field_timeseries_per_tickers(tickers={'XBTUSD Curncy': Assets.BTC.value}).iloc[:, 0] 84 | eth_bbg = fetch_field_timeseries_per_tickers(tickers={'XETUSD Curncy': Assets.ETH.value}).iloc[:, 0] 85 | # bfill eth with bal 86 | eth = qis.bfill_timeseries(df_newer=eth_bbg, df_older=btc, freq='D', is_prices=True) 87 | bal = create_balanced_price() 88 | 89 | # REET starts from Jul 08, 2014 - use IYR for backfill 90 | iyr = yf.download('IYR', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.RE.value) 91 | reet = yf.download('REET', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.RE.value) 92 | re = qis.bfill_timeseries(df_newer=reet, df_older=iyr, is_prices=True) 93 | 94 | # COMT starts from 2014-10-16, use GSG for backfill 95 | coms0 = yf.download('GSG', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.COMS.value) 96 | coms1 = yf.download('COMT', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.COMS.value) 97 | coms = qis.bfill_timeseries(df_newer=coms1, df_older=coms0, is_prices=True) 98 | 99 | # use bbg 100 | hf = fetch_field_timeseries_per_tickers(tickers={'HFRXGL Index': Assets.HF.value}).iloc[:, 0] 101 | cta = fetch_field_timeseries_per_tickers(tickers={'NEIXCTA Index': Assets.CTA.value}).iloc[:, 0] 102 | macro = fetch_field_timeseries_per_tickers(tickers={'HFRIMDT Index': Assets.MACRO.value}).iloc[:, 0] 103 | 104 | # etfs 105 | gld = yf.download('GLD', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.GLD.value) 106 | pe = yf.download('PSP', start=None, end=None, ignore_tz=True)['Close'].rename(Assets.PE.value) 107 | 108 | prices = pd.concat([bal, btc, eth, hf, pe, re, macro, cta, coms, gld], axis=1).sort_index() 109 | # to business day frequency 110 | prices = prices.asfreq('B', method='ffill').ffill() 111 | qis.save_df_to_csv(prices, file_name=PRICE_DATA_FILE_UPDATED, local_path=LOCAL_PATH) 112 | 113 | return prices 114 | 115 | 116 | def create_btc_price() -> pd.Series: 117 | """ 118 | backfill BTC_PRICES_FROM_2010 119 | """ 120 | btc_bbg = qis.load_df_from_csv(file_name=BTC_PRICES_FROM_2010, local_path=LOCAL_PATH).iloc[:, 0].rename(Assets.BTC.value) 121 | new_bbg = fetch_cmc_price(ticker='BTC').rename(Assets.BTC.value) 122 | btc_price = qis.bfill_timeseries(df_newer=new_bbg, df_older=btc_bbg, freq='D', is_prices=True) 123 | return btc_price 124 | 125 | 126 | def create_eth_price(btc_price: pd.Series) -> pd.Series: 127 | btc_price = btc_price.rename(Assets.ETH.value) 128 | mc_price = fetch_cmc_price(ticker='ETH').rename(Assets.ETH.value) 129 | eth_price = qis.bfill_timeseries(df_newer=mc_price, df_older=btc_price, freq='D', is_prices=True) 130 | return eth_price 131 | 132 | 133 | def create_balanced_price() -> pd.Series: 134 | spy = yf.download('SPY', start=None, end=None, ignore_tz=True)['Close'].rename('SPY') 135 | ief = yf.download('IEF', start=None, end=None, ignore_tz=True)['Close'].rename('IEF') 136 | prices = pd.concat([spy, ief], axis=1).dropna() 137 | balanced = qis.backtest_model_portfolio(prices=prices, 138 | weights=[0.6, 0.4], 139 | rebalancing_freq='QE', 140 | is_rebalanced_at_first_date=True, 141 | rebalancing_costs=0.005, 142 | ticker=Assets.BAL.value) 143 | return balanced.nav 144 | 145 | 146 | def fetch_cmc_price(ticker: str = 'ETH') -> pd.Series: 147 | scraper = CmcScraper(coin_code=ticker, all_time=True, order_ascending=True) 148 | data = scraper.get_dataframe() 149 | data['Date'] = pd.to_datetime(data['Date']) 150 | data = data.set_index('Date', drop=True) 151 | data.index = data.index.normalize() 152 | return data['Close'] 153 | 154 | 155 | def load_prices(assets: List[Assets] = None, 156 | crypto_asset: Optional[Union[Literal['BTC', 'ETH'], str]] = 'BTC', 157 | is_updated: bool = False 158 | ) -> pd.DataFrame: 159 | if is_updated: 160 | prices = qis.load_df_from_csv(file_name=PRICE_DATA_FILE_UPDATED, local_path=LOCAL_PATH) 161 | else: 162 | prices = qis.load_df_from_csv(file_name=PRICE_DATA_FILE, local_path=LOCAL_PATH) 163 | if assets is not None: 164 | prices = prices[[x.value for x in assets]] 165 | elif crypto_asset is not None: 166 | if crypto_asset == 'BTC': 167 | prices = prices.copy().drop(Assets.ETH.value, axis=1) 168 | else: 169 | prices = prices.copy().drop(Assets.BTC.value, axis=1) 170 | return prices 171 | 172 | 173 | def load_risk_free_rate() -> pd.Series: 174 | return yf.download('^IRX', start=None, end=None)['Close'].dropna() / 100.0 175 | 176 | 177 | class UnitTests(Enum): 178 | UPDATE_PRICES_WITH_YF = 1 179 | CREATE_ETH = 2 180 | CHECK_PRICES = 3 181 | CREATE_BALANCED_PRICE = 4 182 | CHECK_REAL_ESTATE = 5 183 | UPDATE_PRICES_WITH_BLOOMBERG = 6 184 | 185 | 186 | def run_unit_test(unit_test: UnitTests): 187 | 188 | if unit_test == UnitTests.UPDATE_PRICES_WITH_YF: 189 | update_prices_with_yf() 190 | prices = load_prices() 191 | print(prices) 192 | 193 | elif unit_test == UnitTests.CREATE_ETH: 194 | # btc_price = qis.load_df_from_csv(file_name=BTC_PRICES_FROM_2010, local_path=LOCAL_PATH).iloc[:, 0] 195 | # eth_price = create_eth_price(btc_price=btc_price) 196 | # print(eth_price) 197 | from bbg_fetch import fetch_field_timeseries_per_tickers 198 | bbg_price = fetch_field_timeseries_per_tickers(tickers={'XETUSD Curncy': f"{Assets.ETH.value} - BBG"}).iloc[:, 0] 199 | mc_price = load_prices(assets=[Assets.ETH], is_updated=False).reindex(index=bbg_price.index) 200 | prices = pd.concat([bbg_price, mc_price], axis=1).dropna() 201 | qis.plot_ra_perf_table(prices=prices) 202 | qis.plot_prices_with_dd(prices=prices, start_to_one=False) 203 | 204 | elif unit_test == UnitTests.CHECK_PRICES: 205 | prices = load_prices(crypto_asset=None, is_updated=True) 206 | qis.plot_ra_perf_table(prices=prices) 207 | qis.plot_prices_with_dd(prices=prices) 208 | 209 | elif unit_test == UnitTests.CREATE_BALANCED_PRICE: 210 | price = create_balanced_price() 211 | print(price) 212 | bal = yf.download('AOR', start=None, end=None, ignore_tz=True)['Close'].rename('AOR') 213 | prices = pd.concat([price, bal], axis=1).dropna() 214 | qis.plot_prices_with_dd(prices=prices) 215 | 216 | elif unit_test == UnitTests.CHECK_REAL_ESTATE: 217 | assets = ['IYR', 'REZ', 'REET'] 218 | prices = [] 219 | for asset in assets: 220 | prices.append(yf.download(asset, start=None, end=None, ignore_tz=True)['Close'].rename(asset)) 221 | prices = pd.concat(prices, axis=1).dropna() 222 | qis.plot_prices_with_dd(prices=prices) 223 | 224 | elif unit_test == UnitTests.UPDATE_PRICES_WITH_BLOOMBERG: 225 | prices = update_prices_with_bloomberg() 226 | qis.plot_prices_with_dd(prices=prices) 227 | 228 | plt.show() 229 | 230 | 231 | if __name__ == '__main__': 232 | 233 | unit_test = UnitTests.CREATE_ETH 234 | 235 | is_run_all_tests = False 236 | if is_run_all_tests: 237 | for unit_test in UnitTests: 238 | run_unit_test(unit_test=unit_test) 239 | else: 240 | run_unit_test(unit_test=unit_test) 241 | -------------------------------------------------------------------------------- /optimalportfolios/examples/figures/MinVariance_multi_covar_estimator_backtest.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArturSepp/OptimalPortfolios/368e115bc3c9e8a8b698806b7302e2887210de6b/optimalportfolios/examples/figures/MinVariance_multi_covar_estimator_backtest.PNG -------------------------------------------------------------------------------- /optimalportfolios/examples/figures/example_customised_report.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArturSepp/OptimalPortfolios/368e115bc3c9e8a8b698806b7302e2887210de6b/optimalportfolios/examples/figures/example_customised_report.PNG -------------------------------------------------------------------------------- /optimalportfolios/examples/figures/example_portfolio_factsheet1.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArturSepp/OptimalPortfolios/368e115bc3c9e8a8b698806b7302e2887210de6b/optimalportfolios/examples/figures/example_portfolio_factsheet1.PNG -------------------------------------------------------------------------------- /optimalportfolios/examples/figures/example_portfolio_factsheet2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArturSepp/OptimalPortfolios/368e115bc3c9e8a8b698806b7302e2887210de6b/optimalportfolios/examples/figures/example_portfolio_factsheet2.PNG -------------------------------------------------------------------------------- /optimalportfolios/examples/figures/max_diversification_span.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArturSepp/OptimalPortfolios/368e115bc3c9e8a8b698806b7302e2887210de6b/optimalportfolios/examples/figures/max_diversification_span.PNG -------------------------------------------------------------------------------- /optimalportfolios/examples/figures/multi_optimisers_backtest.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArturSepp/OptimalPortfolios/368e115bc3c9e8a8b698806b7302e2887210de6b/optimalportfolios/examples/figures/multi_optimisers_backtest.PNG -------------------------------------------------------------------------------- /optimalportfolios/examples/lasso_covar_estimation.py: -------------------------------------------------------------------------------- 1 | # packages 2 | import pandas as pd 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | from sklearn.linear_model import MultiTaskLasso 6 | from enum import Enum 7 | 8 | import yfinance as yf 9 | import qis as qis 10 | 11 | from optimalportfolios import (LassoModel, LassoModelType, 12 | estimate_lasso_covar, 13 | estimate_rolling_lasso_covar_different_freq) 14 | 15 | # select multi asset ETFs 16 | instrument_data = dict(IEFA='Equity', 17 | IEMG='Equity', 18 | ITOT='Equity', 19 | DVY='Equity', 20 | AGG='Bonds', 21 | IUSB='Bonds', 22 | GVI='Bonds', 23 | GBF='Bonds', 24 | AOR='Mixed', # growth 25 | AOA='Mixed', # aggressive 26 | AOM='Mixed', # moderate 27 | AOK='Mixed') 28 | group_data = pd.Series(instrument_data) 29 | sampling_freqs = group_data.map({'Equity': 'ME', 'Bonds': 'ME', 'Mixed': 'QE'}) 30 | 31 | asset_tickers = group_data.index.to_list() 32 | benchmark_tickers = ['SPY', 'IEF', 'LQD', 'USO', 'GLD', 'UUP'] 33 | asset_group_loadings = qis.set_group_loadings(group_data=group_data) 34 | print(asset_group_loadings) 35 | 36 | asset_prices = yf.download(asset_tickers, start=None, end=None)['Close'][asset_tickers].asfreq('B', method='ffill') 37 | benchmark_prices = yf.download(benchmark_tickers, start=None, end=None)['Close'][benchmark_tickers].reindex( 38 | index=asset_prices.index, method='ffill') 39 | 40 | 41 | class UnitTests(Enum): 42 | LASSO_BETAS = 1 43 | LASSO_COVAR_DIFFERENT_FREQUENCIES = 3 44 | 45 | 46 | def run_unit_test(unit_test: UnitTests): 47 | 48 | pd.set_option('display.max_rows', 500) 49 | pd.set_option('display.max_columns', 500) 50 | pd.set_option('display.width', 1000) 51 | 52 | # set lasso model, x and y are demeaned 53 | lasso_params = dict(group_data=group_data, reg_lambda=1e-5, span=120, demean=False, solver='ECOS_BB') 54 | 55 | # set x and y 56 | y = qis.to_returns(asset_prices, freq='ME', drop_first=True) 57 | x = qis.to_returns(benchmark_prices, freq='ME', drop_first=True) 58 | # demean 59 | y = y - np.nanmean(y, axis=0) 60 | x = x - np.nanmean(x, axis=0) 61 | 62 | if unit_test == UnitTests.LASSO_BETAS: 63 | # full regression 64 | lasso_model_full = LassoModel(model_type=LassoModelType.LASSO, **qis.update_kwargs(lasso_params, dict(reg_lambda=0.0))) 65 | betas0, total_vars, residual_vars, r2_t = lasso_model_full.fit(x=x, y=y).compute_residual_alpha_r2() 66 | betas0 = betas0.where(np.abs(betas0) > 1e-5, other=np.nan) 67 | 68 | # independent Lasso 69 | lasso_model = LassoModel(model_type=LassoModelType.LASSO, **lasso_params) 70 | betas_lasso, total_vars, residual_vars, r2_t = lasso_model.fit(x=x, y=y).compute_residual_alpha_r2() 71 | betas_lasso = betas_lasso.where(np.abs(betas_lasso) > 1e-5, other=np.nan) 72 | 73 | # group Lasso 74 | group_lasso_model = LassoModel(model_type=LassoModelType.GROUP_LASSO, **lasso_params) 75 | betas_group_lasso, total_vars, residual_vars, r2_t = group_lasso_model.fit(x=x, y=y).compute_residual_alpha_r2() 76 | betas_group_lasso = betas_group_lasso.where(np.abs(betas_group_lasso) > 1e-5, other=np.nan) 77 | 78 | fig, axs = plt.subplots(3, 1, figsize=(12, 10), tight_layout=True) 79 | qis.plot_heatmap(df=betas0, title='(A) Multivariate Regression Betas', var_format='{:.2f}', ax=axs[0]) 80 | qis.plot_heatmap(df=betas_lasso, title='(A) Independent Lasso Betas', var_format='{:.2f}', ax=axs[1]) 81 | qis.plot_heatmap(df=betas_group_lasso, title='(B) Group Lasso Betas', var_format='{:.2f}', ax=axs[2]) 82 | 83 | elif unit_test == UnitTests.LASSO_COVAR_DIFFERENT_FREQUENCIES: 84 | lasso_model = LassoModel(model_type=LassoModelType.GROUP_LASSO, **lasso_params) 85 | y_covars = estimate_rolling_lasso_covar_different_freq(risk_factor_prices=benchmark_prices, 86 | prices=asset_prices, 87 | returns_freqs=sampling_freqs, 88 | time_period=qis.TimePeriod('31Dec2019', '13Dec2024'), 89 | rebalancing_freq='ME', 90 | lasso_model=lasso_model, 91 | is_apply_vol_normalised_returns=False 92 | ).y_covars 93 | for date, covar in y_covars.items(): 94 | print(date) 95 | print(covar) 96 | 97 | plt.show() 98 | 99 | 100 | if __name__ == '__main__': 101 | 102 | unit_test = UnitTests.LASSO_BETAS 103 | 104 | is_run_all_tests = False 105 | if is_run_all_tests: 106 | for unit_test in UnitTests: 107 | run_unit_test(unit_test=unit_test) 108 | else: 109 | run_unit_test(unit_test=unit_test) 110 | -------------------------------------------------------------------------------- /optimalportfolios/examples/lasso_estimation.py: -------------------------------------------------------------------------------- 1 | # packages 2 | import pandas as pd 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | from sklearn.linear_model import MultiTaskLasso 6 | 7 | import yfinance as yf 8 | import qis as qis 9 | 10 | from optimalportfolios.lasso.lasso_model_estimator import (solve_lasso_cvx_problem, solve_group_lasso_cvx_problem) 11 | 12 | # select multi asset ETFs 13 | instrument_data = dict(SPY='SPY', 14 | IEFA='Equity', 15 | IEMG='Equity', 16 | ITOT='Equity', 17 | DVY='Equity', 18 | AGG='Bonds', 19 | IUSB='Bonds', 20 | GVI='Bonds', 21 | GBF='Bonds', 22 | AOR='Mixed', # growth 23 | AOA='Mixed', # aggressive 24 | AOM='Mixed', # moderate 25 | AOK='Mixed', # conservatives 26 | GSG='Commodts', 27 | COMT='Commodts', 28 | PDBC='Commodts', 29 | FTGC='Commodts') 30 | instrument_data = pd.Series(instrument_data) 31 | asset_tickers = instrument_data.index.to_list() 32 | benchmark_tickers = ['SPY', 'IEF', 'LQD', 'USO', 'GLD', 'UUP'] 33 | asset_group_loadings = qis.set_group_loadings(group_data=instrument_data) 34 | print(asset_group_loadings) 35 | 36 | asset_prices = yf.download(asset_tickers, start=None, end=None)['Close'][asset_tickers].asfreq('B', method='ffill') 37 | benchmark_prices = yf.download(benchmark_tickers, start=None, end=None)['Close'][benchmark_tickers].reindex(index=asset_prices.index, method='ffill') 38 | 39 | y = qis.to_returns(asset_prices, freq='ME', drop_first=True) 40 | x = qis.to_returns(benchmark_prices, freq='ME', drop_first=True) 41 | y = y.to_numpy() - np.nanmean(y, axis=0) 42 | x = x.to_numpy() - np.nanmean(x, axis=0) 43 | 44 | params = dict(reg_lambda=1e-5, span=24, nonneg=False) 45 | 46 | 47 | #beta2 = solve_lasso_problem_2d(x=x, y=y, **params, apply_independent_nan_filter=True) 48 | #beta2 = pd.DataFrame(beta2, index=benchmark_tickers, columns=asset_tickers) 49 | #beta2 = beta2.where(np.abs(beta2) > 1e-4, other=np.nan) 50 | 51 | beta3 = solve_lasso_cvx_problem(x=x, y=y, **params, apply_independent_nan_filter=False) 52 | beta3 = pd.DataFrame(beta3, index=benchmark_tickers, columns=asset_tickers) 53 | beta3 = beta3.where(np.abs(beta3) > 1e-4, other=np.nan) 54 | print(beta3) 55 | 56 | beta4 = solve_group_lasso_cvx_problem(x=x, y=y, group_loadings=asset_group_loadings.to_numpy(), **params) 57 | beta4 = pd.DataFrame(beta4, index=benchmark_tickers, columns=asset_tickers) 58 | beta4 = beta4.where(np.abs(beta4) > 1e-4, other=np.nan) 59 | print(beta4) 60 | 61 | model = MultiTaskLasso(alpha=1e-3, fit_intercept=False) 62 | 63 | x, y = qis.select_non_nan_x_y(x=x, y=y) 64 | model.fit(X=x, y=y) 65 | params = pd.DataFrame(model.coef_.T, index=benchmark_tickers, columns=asset_tickers) 66 | params = params.where(np.abs(params) > 1e-4, other=np.nan) 67 | print(params) 68 | 69 | fig, axs = plt.subplots(3, 1, figsize=(12, 8), tight_layout=True) 70 | qis.plot_heatmap(df=beta3, title='independent betas same nonnan basis', var_format='{:.2f}', ax=axs[0]) 71 | qis.plot_heatmap(df=beta4, title='group betas same nonnan basis', var_format='{:.2f}', ax=axs[1]) 72 | qis.plot_heatmap(df=params, title='multi Lasso', var_format='{:.2f}', ax=axs[2]) 73 | 74 | 75 | plt.show() 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /optimalportfolios/examples/long_short_optimisation.py: -------------------------------------------------------------------------------- 1 | """ 2 | example of backtester with long-short weights 3 | """ 4 | import pandas as pd 5 | import matplotlib.pyplot as plt 6 | import seaborn as sns 7 | import yfinance as yf 8 | from typing import Tuple 9 | import qis as qis 10 | 11 | # package 12 | from optimalportfolios import compute_rolling_optimal_weights, PortfolioObjective, Constraints 13 | 14 | 15 | # 1. we define the investment universe and allocation by asset classes 16 | def fetch_universe_data() -> Tuple[pd.DataFrame, pd.DataFrame, pd.Series]: 17 | """ 18 | fetch universe data for the portfolio construction: 19 | 1. dividend and split adjusted end of day prices: price data may start / end at different dates 20 | 2. benchmark prices which is used for portfolio reporting and benchmarking 21 | 3. universe group data for portfolio reporting and risk attribution for large universes 22 | this function is using yfinance to fetch the price data 23 | """ 24 | universe_data = dict(SPY='Equities', 25 | QQQ='Equities', 26 | EEM='Equities', 27 | TLT='Bonds', 28 | IEF='Bonds', 29 | LQD='Credit', 30 | HYG='HighYield', 31 | GLD='Gold') 32 | tickers = list(universe_data.keys()) 33 | group_data = pd.Series(universe_data) 34 | prices = yf.download(tickers, start=None, end=None, ignore_tz=True)['Close'] 35 | prices = prices[tickers] # arrange as given 36 | prices = prices.asfreq('B', method='ffill') # refill at B frequency 37 | benchmark_prices = prices[['SPY', 'TLT']] 38 | return prices, benchmark_prices, group_data 39 | 40 | 41 | # 2. get universe data 42 | prices, benchmark_prices, group_data = fetch_universe_data() 43 | time_period = qis.TimePeriod('31Dec2004', '17Apr2025') # period for computing weights backtest 44 | 45 | # 3.a. define optimisation setup 46 | # currently cannot work for MAX_DIVERSIFICATION and MAXIMUM_SHARPE_RATIO 47 | portfolio_objective = PortfolioObjective.MAXIMUM_SHARPE_RATIO # define portfolio objective 48 | returns_freq = 'W-WED' # use weekly returns 49 | rebalancing_freq = 'QE' # weights rebalancing frequency: rebalancing is quarterly on WED 50 | span = 52 # span of number of returns_freq-returns for covariance estimation = 12y 51 | 52 | # use max_exposure = min_exposure so the sum of portfolio weight = 0 53 | constraints0 = Constraints(is_long_only=False, # negative weights are allowed 54 | max_exposure=1.0, # defines maximum net exposure = sum(weights) 55 | min_exposure=-1.0, # defines minimum net exposure = sum(weights) 56 | min_weights=pd.Series(-0.25, index=prices.columns), # can go negative 57 | max_weights=pd.Series(0.25, index=prices.columns) 58 | ) 59 | 60 | # 3.b. compute solvers portfolio weights rebalanced every quarter 61 | weights = compute_rolling_optimal_weights(prices=prices, 62 | portfolio_objective=portfolio_objective, 63 | constraints0=constraints0, 64 | time_period=time_period, 65 | rebalancing_freq=rebalancing_freq, 66 | span=span) 67 | 68 | # 4. given portfolio weights, construct the performance of the portfolio 69 | funding_rate = None # on positive / negative cash balances 70 | rebalancing_costs = 0.0010 # rebalancing costs per volume = 10bp 71 | weight_implementation_lag = 1 # portfolio is implemented next day after weights are computed 72 | portfolio_data = qis.backtest_model_portfolio(prices=prices.loc[weights.index[0]:, :], 73 | weights=weights, 74 | ticker='LongShortPortfolio', 75 | funding_rate=funding_rate, 76 | weight_implementation_lag=weight_implementation_lag, 77 | rebalancing_costs=rebalancing_costs) 78 | 79 | # 5. using portfolio_data run the reporting with strategy factsheet 80 | # for group-based reporting set_group_data 81 | portfolio_data.set_group_data(group_data=group_data, group_order=list(group_data.unique())) 82 | # set time period for portfolio reporting 83 | figs = qis.generate_strategy_factsheet(portfolio_data=portfolio_data, 84 | benchmark_prices=benchmark_prices, 85 | add_weights_turnover_sheet=True, 86 | time_period=time_period, 87 | **qis.fetch_default_report_kwargs(time_period=time_period)) 88 | # save report to pdf and png 89 | qis.save_figs_to_pdf(figs=figs, 90 | file_name=f"{portfolio_data.nav.name}_portfolio_factsheet", 91 | orientation='landscape', 92 | local_path="C://Users//Artur//OneDrive//analytics//outputs") 93 | 94 | plt.show() 95 | -------------------------------------------------------------------------------- /optimalportfolios/examples/multi_covar_estimation_backtest.py: -------------------------------------------------------------------------------- 1 | """ 2 | backtest multiple covariance estimators given parameter backtest several optimisers 3 | """ 4 | # imports 5 | import pandas as pd 6 | import matplotlib.pyplot as plt 7 | from typing import List, Optional 8 | from enum import Enum 9 | import qis as qis 10 | 11 | # package 12 | from optimalportfolios import (Constraints, PortfolioObjective, 13 | backtest_rolling_optimal_portfolio, 14 | estimate_rolling_ewma_covar, 15 | LassoModelType, LassoModel, 16 | estimate_rolling_lasso_covar) 17 | from optimalportfolios.examples.universe import fetch_benchmark_universe_data 18 | 19 | SUPPORTED_SOLVERS = [PortfolioObjective.EQUAL_RISK_CONTRIBUTION, 20 | PortfolioObjective.MIN_VARIANCE, 21 | PortfolioObjective.MAX_DIVERSIFICATION] 22 | 23 | 24 | def run_multi_covar_estimators_backtest(prices: pd.DataFrame, 25 | benchmark_prices: pd.DataFrame, 26 | ac_benchmark_prices: pd.DataFrame, 27 | group_data: pd.Series, 28 | time_period: qis.TimePeriod, # for weights 29 | perf_time_period: qis.TimePeriod, # for reporting 30 | returns_freq: str = 'W-WED', # covar matrix estimation on weekly returns 31 | rebalancing_freq: str = 'QE', # portfolio rebalancing 32 | span: int = 52, # ewma span for covariance matrix estimation: span = 1y of weekly returns 33 | portfolio_objective: PortfolioObjective = PortfolioObjective.MAX_DIVERSIFICATION, 34 | squeeze_factor: Optional[float] = None 35 | ) -> List[plt.Figure]: 36 | """ 37 | backtest multi covar estimation 38 | test maximum diversification optimiser to span parameter 39 | portfolios are rebalanced at rebalancing_freq 40 | """ 41 | if portfolio_objective not in SUPPORTED_SOLVERS: 42 | raise NotImplementedError(f"not supported {portfolio_objective}") 43 | 44 | # 1. EWMA covar 45 | ewma_covars = estimate_rolling_ewma_covar(prices=prices, time_period=time_period, 46 | rebalancing_freq=rebalancing_freq, 47 | returns_freq=returns_freq, 48 | span=span, 49 | is_apply_vol_normalised_returns=False, 50 | squeeze_factor=squeeze_factor) 51 | # 2. ewma covar with vol norm returns 52 | ewma_covars_vol_norm = estimate_rolling_ewma_covar(prices=prices, time_period=time_period, 53 | rebalancing_freq=rebalancing_freq, 54 | returns_freq=returns_freq, 55 | span=span, 56 | is_apply_vol_normalised_returns=True, 57 | squeeze_factor=squeeze_factor) 58 | # lasso params 59 | lasso_kwargs = dict(risk_factor_prices=ac_benchmark_prices, 60 | prices=prices, 61 | time_period=time_period, 62 | returns_freq=returns_freq, 63 | rebalancing_freq=rebalancing_freq, 64 | span=span, 65 | squeeze_factor=squeeze_factor, 66 | residual_var_weight=1.0) 67 | # 3. Group Lasso model using ac_benchmarks from universe 68 | lasso_model = LassoModel(model_type=LassoModelType.LASSO, 69 | group_data=group_data, reg_lambda=1e-6, span=span, 70 | warm_up_periods=span, solver='ECOS_BB') 71 | lasso_covar_data = estimate_rolling_lasso_covar(lasso_model=lasso_model, 72 | is_apply_vol_normalised_returns=False, 73 | **lasso_kwargs) 74 | lasso_covars = lasso_covar_data.y_covars 75 | lasso_covar_data_norm = estimate_rolling_lasso_covar(lasso_model=lasso_model, 76 | is_apply_vol_normalised_returns=True, 77 | **lasso_kwargs) 78 | lasso_covars_norm = lasso_covar_data_norm.y_covars 79 | 80 | 81 | # 4. Group Lasso model using ac_benchmarks from universe 82 | group_lasso_model = LassoModel(model_type=LassoModelType.GROUP_LASSO, 83 | group_data=group_data, reg_lambda=1e-6, span=span, solver='ECOS_BB') 84 | group_lasso_covars = estimate_rolling_lasso_covar(lasso_model=group_lasso_model, 85 | is_apply_vol_normalised_returns=False, 86 | **lasso_kwargs) 87 | group_lasso_covars = group_lasso_covars.y_covars 88 | group_lasso_covars_norm = estimate_rolling_lasso_covar(lasso_model=group_lasso_model, 89 | is_apply_vol_normalised_returns=True, 90 | **lasso_kwargs) 91 | group_lasso_covars_norm = group_lasso_covars_norm.y_covars 92 | # create dict of estimated covars 93 | covars_dict = {'EWMA': ewma_covars, 'EWMA vol norm': ewma_covars_vol_norm, 94 | 'Lasso': lasso_covars, 'Lasso VolNorn': lasso_covars_norm, 95 | 'Group Lasso': group_lasso_covars, 'Group Lasso VolNorn': group_lasso_covars_norm} 96 | 97 | # set global constaints for portfolios 98 | constraints0 = Constraints(is_long_only=True, 99 | min_weights=pd.Series(0.0, index=prices.columns), 100 | max_weights=pd.Series(0.5, index=prices.columns)) 101 | 102 | # now create a list of portfolios 103 | portfolio_datas = [] 104 | for key, covar_dict in covars_dict.items(): 105 | portfolio_data = backtest_rolling_optimal_portfolio(prices=prices, 106 | portfolio_objective=portfolio_objective, 107 | constraints0=constraints0, 108 | time_period=time_period, 109 | perf_time_period=perf_time_period, 110 | covar_dict=covar_dict, 111 | rebalancing_costs=0.0010, # 10bp for rebalancin 112 | weight_implementation_lag=1, # weights are implemnted next day after comuting 113 | ticker=f"{key}" # portfolio id 114 | ) 115 | portfolio_data.set_group_data(group_data=group_data) 116 | portfolio_datas.append(portfolio_data) 117 | 118 | # run cross portfolio report 119 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas=portfolio_datas, benchmark_prices=benchmark_prices) 120 | figs = qis.generate_multi_portfolio_factsheet(multi_portfolio_data=multi_portfolio_data, 121 | time_period=time_period, 122 | add_strategy_factsheets=False, 123 | **qis.fetch_default_report_kwargs(time_period=time_period)) 124 | return figs 125 | 126 | 127 | class UnitTests(Enum): 128 | MULTI_COVAR_ESTIMATORS_BACKTEST = 1 129 | 130 | 131 | def run_unit_test(unit_test: UnitTests): 132 | 133 | import optimalportfolios.local_path as local_path 134 | 135 | if unit_test == UnitTests.MULTI_COVAR_ESTIMATORS_BACKTEST: 136 | # portfolio_objective = PortfolioObjective.MAX_DIVERSIFICATION 137 | # portfolio_objective = PortfolioObjective.EQUAL_RISK_CONTRIBUTION 138 | portfolio_objective = PortfolioObjective.MIN_VARIANCE 139 | 140 | # params = dict(returns_freq='W-WED', rebalancing_freq='QE', span=52) 141 | params = dict(returns_freq='ME', rebalancing_freq='ME', span=12, squeeze_factor=0.01) 142 | 143 | prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices = fetch_benchmark_universe_data() 144 | time_period = qis.TimePeriod(start='31Dec1998', end=prices.index[-1]) # backtest start: need 6y of data for rolling Sharpe and max mixure portfolios 145 | perf_time_period = qis.TimePeriod(start='31Dec2004', end=prices.index[-1]) # backtest reporting 146 | figs = run_multi_covar_estimators_backtest(prices=prices, 147 | benchmark_prices=benchmark_prices, 148 | ac_benchmark_prices=ac_benchmark_prices, 149 | group_data=group_data, 150 | time_period=time_period, 151 | perf_time_period=perf_time_period, 152 | portfolio_objective=portfolio_objective, 153 | **params) 154 | 155 | # save png and pdf 156 | # qis.save_fig(fig=figs[0], file_name=f"{portfolio_objective.value}_multi_covar_estimator_backtest", local_path=f"figures/") 157 | qis.save_figs_to_pdf(figs=figs, 158 | file_name=f"{portfolio_objective.value} multi_covar_estimator_backtest", 159 | orientation='landscape', 160 | local_path=local_path.get_output_path()) 161 | plt.show() 162 | 163 | 164 | if __name__ == '__main__': 165 | 166 | unit_test = UnitTests.MULTI_COVAR_ESTIMATORS_BACKTEST 167 | 168 | is_run_all_tests = False 169 | if is_run_all_tests: 170 | for unit_test in UnitTests: 171 | run_unit_test(unit_test=unit_test) 172 | else: 173 | run_unit_test(unit_test=unit_test) 174 | -------------------------------------------------------------------------------- /optimalportfolios/examples/multi_optimisers_backtest.py: -------------------------------------------------------------------------------- 1 | """ 2 | backtest several optimisers 3 | """ 4 | # imports 5 | import pandas as pd 6 | import matplotlib.pyplot as plt 7 | from typing import List 8 | from enum import Enum 9 | import qis as qis 10 | 11 | # package 12 | from optimalportfolios import Constraints, backtest_rolling_optimal_portfolio, PortfolioObjective 13 | from optimalportfolios.examples.universe import fetch_benchmark_universe_data 14 | 15 | 16 | def run_multi_optimisers_backtest(prices: pd.DataFrame, 17 | benchmark_prices: pd.DataFrame, 18 | group_data: pd.Series, 19 | time_period: qis.TimePeriod, # for weights 20 | perf_time_period: qis.TimePeriod # for reporting 21 | ) -> List[plt.Figure]: 22 | """ 23 | backtest multi optimisers 24 | test maximum diversification optimiser to span parameter 25 | span is number period for ewm filter 26 | span = 20 for daily data implies last 20 (trading) days contribute 50% of weight for covariance estimation 27 | we test sensitivity from fast (small span) to slow (large span) 28 | """ 29 | portfolio_objectives = {'EqualRisk': PortfolioObjective.EQUAL_RISK_CONTRIBUTION, 30 | 'MinVariance': PortfolioObjective.MIN_VARIANCE, 31 | 'MaxDiversification': PortfolioObjective.MAX_DIVERSIFICATION, 32 | 'MaxSharpe': PortfolioObjective.MAXIMUM_SHARPE_RATIO, 33 | 'MaxCarraMixture': PortfolioObjective.MAX_CARA_MIXTURE} 34 | 35 | # set global constaints for portfolios 36 | constraints0 = Constraints(is_long_only=True, 37 | min_weights=pd.Series(0.0, index=prices.columns), 38 | max_weights=pd.Series(0.5, index=prices.columns)) 39 | 40 | # now create a list of portfolios 41 | portfolio_datas = [] 42 | for ticker, portfolio_objective in portfolio_objectives.items(): 43 | print(ticker) 44 | portfolio_data = backtest_rolling_optimal_portfolio(prices=prices, 45 | portfolio_objective=portfolio_objective, 46 | constraints0=constraints0, 47 | time_period=time_period, 48 | perf_time_period=perf_time_period, 49 | returns_freq='W-WED', # covar matrix estimation on weekly returns 50 | rebalancing_freq='QE', # portfolio rebalancing 51 | span=52, # ewma span for covariance matrix estimation: span = 1y of weekly returns 52 | roll_window=5*52, # linked to returns at rebalancing_freq: 5y of data for max sharpe and mixture carra 53 | carra=0.5, # carra parameter 54 | n_mixures=3, # for mixture carra utility 55 | rebalancing_costs=0.0010, # 10bp for rebalancin 56 | weight_implementation_lag=1, # weights are implemnted next day after comuting 57 | ticker=f"{ticker}" # portfolio id 58 | ) 59 | portfolio_data.set_group_data(group_data=group_data) 60 | portfolio_datas.append(portfolio_data) 61 | 62 | # run cross portfolio report 63 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas=portfolio_datas, benchmark_prices=benchmark_prices) 64 | figs = qis.generate_multi_portfolio_factsheet(multi_portfolio_data=multi_portfolio_data, 65 | time_period=time_period, 66 | add_strategy_factsheets=False, 67 | **qis.fetch_default_report_kwargs(time_period=time_period)) 68 | return figs 69 | 70 | 71 | class UnitTests(Enum): 72 | MULTI_OPTIMISERS_BACKTEST = 1 73 | 74 | 75 | def run_unit_test(unit_test: UnitTests): 76 | 77 | import optimalportfolios.local_path as local_path 78 | 79 | if unit_test == UnitTests.MULTI_OPTIMISERS_BACKTEST: 80 | prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices = fetch_benchmark_universe_data() 81 | time_period = qis.TimePeriod(start='31Dec1998', end=prices.index[-1]) # backtest start: need 6y of data for rolling Sharpe and max mixure portfolios 82 | perf_time_period = qis.TimePeriod(start='31Dec2004', end=prices.index[-1]) # backtest reporting 83 | figs = run_multi_optimisers_backtest(prices=prices, 84 | benchmark_prices=benchmark_prices, 85 | group_data=group_data, 86 | time_period=time_period, 87 | perf_time_period=perf_time_period) 88 | 89 | # save png and pdf 90 | qis.save_fig(fig=figs[0], file_name=f"multi_optimisers_backtest", local_path=f"figures/") 91 | qis.save_figs_to_pdf(figs=figs, 92 | file_name=f"multi_optimisers_backtest", 93 | orientation='landscape', 94 | local_path=local_path.get_output_path()) 95 | plt.show() 96 | 97 | 98 | if __name__ == '__main__': 99 | 100 | unit_test = UnitTests.MULTI_OPTIMISERS_BACKTEST 101 | 102 | is_run_all_tests = False 103 | if is_run_all_tests: 104 | for unit_test in UnitTests: 105 | run_unit_test(unit_test=unit_test) 106 | else: 107 | run_unit_test(unit_test=unit_test) 108 | -------------------------------------------------------------------------------- /optimalportfolios/examples/optimal_portfolio_backtest.py: -------------------------------------------------------------------------------- 1 | """ 2 | minimal example of using the backtester 3 | """ 4 | import pandas as pd 5 | import matplotlib.pyplot as plt 6 | import seaborn as sns 7 | import yfinance as yf 8 | from typing import Tuple 9 | import qis as qis 10 | 11 | # package 12 | from optimalportfolios import compute_rolling_optimal_weights, PortfolioObjective, Constraints 13 | 14 | 15 | # 1. we define the investment universe and allocation by asset classes 16 | def fetch_universe_data() -> Tuple[pd.DataFrame, pd.DataFrame, pd.Series]: 17 | """ 18 | fetch universe data for the portfolio construction: 19 | 1. dividend and split adjusted end of day prices: price data may start / end at different dates 20 | 2. benchmark prices which is used for portfolio reporting and benchmarking 21 | 3. universe group data for portfolio reporting and risk attribution for large universes 22 | this function is using yfinance to fetch the price data 23 | """ 24 | universe_data = dict(SPY='Equities', 25 | QQQ='Equities', 26 | EEM='Equities', 27 | TLT='Bonds', 28 | IEF='Bonds', 29 | LQD='Credit', 30 | HYG='HighYield', 31 | GLD='Gold') 32 | tickers = list(universe_data.keys()) 33 | group_data = pd.Series(universe_data) 34 | prices = yf.download(tickers, start=None, end=None, ignore_tz=True)['Close'] 35 | prices = prices[tickers] # arrange as given 36 | prices = prices.asfreq('B', method='ffill') # refill at B frequency 37 | benchmark_prices = prices[['SPY', 'TLT']] 38 | return prices, benchmark_prices, group_data 39 | 40 | 41 | # 2. get universe data 42 | prices, benchmark_prices, group_data = fetch_universe_data() 43 | time_period = qis.TimePeriod('31Dec2004', '17Apr2025') # period for computing weights backtest 44 | 45 | # 3.a. define optimisation setup 46 | portfolio_objective = PortfolioObjective.EQUAL_RISK_CONTRIBUTION # define portfolio objective 47 | returns_freq = 'W-WED' # use weekly returns 48 | rebalancing_freq = 'QE' # weights rebalancing frequency: rebalancing is quarterly on WED 49 | span = 52 # span of number of returns_freq-returns for covariance estimation = 12y 50 | constraints0 = Constraints(is_long_only=True, 51 | min_weights=pd.Series(0.0, index=prices.columns), 52 | max_weights=pd.Series(0.5, index=prices.columns)) 53 | 54 | # 3.b. compute solvers portfolio weights rebalanced every quarter 55 | weights = compute_rolling_optimal_weights(prices=prices, 56 | portfolio_objective=portfolio_objective, 57 | constraints0=constraints0, 58 | time_period=time_period, 59 | rebalancing_freq=rebalancing_freq, 60 | span=span) 61 | 62 | # 4. given portfolio weights, construct the performance of the portfolio 63 | funding_rate = None # on positive / negative cash balances 64 | rebalancing_costs = 0.0010 # rebalancing costs per volume = 10bp 65 | weight_implementation_lag = 1 # portfolio is implemented next day after weights are computed 66 | portfolio_data = qis.backtest_model_portfolio(prices=prices.loc[weights.index[0]:, :], 67 | weights=weights, 68 | ticker='MaxDiversification', 69 | funding_rate=funding_rate, 70 | weight_implementation_lag=weight_implementation_lag, 71 | rebalancing_costs=rebalancing_costs) 72 | 73 | # 5. using portfolio_data run the reporting with strategy factsheet 74 | # for group-based reporting set_group_data 75 | portfolio_data.set_group_data(group_data=group_data, group_order=list(group_data.unique())) 76 | # set time period for portfolio reporting 77 | figs = qis.generate_strategy_factsheet(portfolio_data=portfolio_data, 78 | benchmark_prices=benchmark_prices, 79 | add_current_position_var_risk_sheet=True, 80 | time_period=time_period, 81 | **qis.fetch_default_report_kwargs(time_period=time_period)) 82 | # save report to pdf and png 83 | qis.save_figs_to_pdf(figs=figs, 84 | file_name=f"{portfolio_data.nav.name}_portfolio_factsheet", 85 | orientation='landscape', 86 | local_path="C://Users//Artur//OneDrive//analytics//outputs") 87 | qis.save_fig(fig=figs[0], file_name=f"example_portfolio_factsheet1", local_path=f"figures/") 88 | qis.save_fig(fig=figs[1], file_name=f"example_portfolio_factsheet2", local_path=f"figures/") 89 | 90 | 91 | # 6. can create customised reporting using portfolio_data custom reporting 92 | def run_customised_reporting(portfolio_data) -> plt.Figure: 93 | with sns.axes_style("darkgrid"): 94 | fig, axs = plt.subplots(3, 1, figsize=(12, 12), tight_layout=True) 95 | perf_params = qis.PerfParams(freq='W-WED', freq_reg='ME') 96 | kwargs = dict(x_date_freq='YE', framealpha=0.8, perf_params=perf_params) 97 | portfolio_data.plot_nav(ax=axs[0], **kwargs) 98 | portfolio_data.plot_weights(ncol=len(prices.columns)//3, 99 | legend_stats=qis.LegendStats.AVG_LAST, 100 | title='Portfolio weights', 101 | freq='QE', 102 | ax=axs[1], 103 | **kwargs) 104 | portfolio_data.plot_returns_scatter(benchmark_price=benchmark_prices.iloc[:, 0], 105 | ax=axs[2], 106 | **kwargs) 107 | return fig 108 | 109 | 110 | # run customised report 111 | fig = run_customised_reporting(portfolio_data) 112 | # save png 113 | qis.save_fig(fig=fig, file_name=f"example_customised_report", local_path=f"figures/") 114 | 115 | plt.show() 116 | -------------------------------------------------------------------------------- /optimalportfolios/examples/parameter_sensitivity_backtest.py: -------------------------------------------------------------------------------- 1 | """ 2 | backtest parameter sensitivity of one method 3 | """ 4 | # imports 5 | import pandas as pd 6 | import matplotlib.pyplot as plt 7 | from typing import List 8 | from enum import Enum 9 | import qis as qis 10 | 11 | # package 12 | from optimalportfolios import (PortfolioObjective, backtest_rolling_optimal_portfolio, 13 | Constraints, GroupLowerUpperConstraints) 14 | from optimalportfolios.examples.universe import fetch_benchmark_universe_data 15 | 16 | 17 | def run_max_diversification_sensitivity_to_span(prices: pd.DataFrame, 18 | benchmark_prices: pd.DataFrame, 19 | group_data: pd.Series, 20 | time_period: qis.TimePeriod, # weight computations 21 | perf_time_period: qis.TimePeriod, # for reporting 22 | constraints0: Constraints 23 | ) -> List[ plt.Figure]: 24 | """ 25 | test maximum diversification optimiser to span parameter 26 | span is number period for ewm filter 27 | span = 20 for daily data implies last 20 (trading) days contribute 50% of weight for covariance estimation 28 | we test sensitivity from fast (small span) to slow (large span) 29 | """ 30 | # use daily returns 31 | returns_freq = 'W-WED' # returns freq 32 | # span defined on number periods using returns_freq 33 | # for weekly returns assume 5 weeeks per month 34 | spans = {'1m': 5, '3m': 13, '6m': 26, '1y': 52, '2y': 104} 35 | 36 | # now create a list of portfolios 37 | portfolio_datas = [] 38 | for ticker, span in spans.items(): 39 | portfolio_data = backtest_rolling_optimal_portfolio(prices=prices, 40 | constraints0=constraints0, 41 | time_period=time_period, 42 | portfolio_objective=PortfolioObjective.MAX_DIVERSIFICATION, 43 | rebalancing_freq='QE', # portfolio rebalancing 44 | returns_freq=returns_freq, 45 | span=span, 46 | ticker=f"span-{ticker}", # portfolio id 47 | rebalancing_costs=0.0010, # 10bp for rebalancin 48 | weight_implementation_lag=1 49 | ) 50 | portfolio_data.set_group_data(group_data=group_data) 51 | portfolio_datas.append(portfolio_data) 52 | 53 | # run cross portfolio report 54 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas=portfolio_datas, benchmark_prices=benchmark_prices) 55 | figs = qis.generate_multi_portfolio_factsheet(multi_portfolio_data=multi_portfolio_data, 56 | time_period=perf_time_period, 57 | add_strategy_factsheets=False, 58 | **qis.fetch_default_report_kwargs(time_period=time_period)) 59 | return figs 60 | 61 | 62 | class UnitTests(Enum): 63 | MAX_DIVERSIFICATION_SPAN = 1 64 | 65 | 66 | def run_unit_test(unit_test: UnitTests): 67 | 68 | import optimalportfolios.local_path as local_path 69 | 70 | prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices = fetch_benchmark_universe_data() 71 | 72 | # add costraints that each asset class is 10% <= sum ac weights <= 30% (benchamrk is 20% each) 73 | group_min_allocation = pd.Series(0.0, index=ac_loadings.columns) 74 | group_max_allocation = pd.Series(0.3, index=ac_loadings.columns) 75 | group_lower_upper_constraints = GroupLowerUpperConstraints(group_loadings=ac_loadings, 76 | group_min_allocation=group_min_allocation, 77 | group_max_allocation=group_max_allocation) 78 | constraints0 = Constraints(is_long_only=True, 79 | min_weights=pd.Series(0.0, index=prices.columns), 80 | max_weights=pd.Series(0.2, index=prices.columns), 81 | group_lower_upper_constraints=group_lower_upper_constraints) 82 | 83 | if unit_test == UnitTests.MAX_DIVERSIFICATION_SPAN: 84 | 85 | time_period = qis.TimePeriod(start='31Dec1998', end=prices.index[-1]) # backtest start for weights computation 86 | perf_time_period = qis.TimePeriod(start='31Dec2004', end=prices.index[-1]) # backtest reporting 87 | figs = run_max_diversification_sensitivity_to_span(prices=prices, 88 | benchmark_prices=benchmark_prices, 89 | constraints0=constraints0, 90 | group_data=group_data, 91 | time_period=time_period, 92 | perf_time_period=perf_time_period) 93 | 94 | # save png and pdf 95 | qis.save_fig(fig=figs[0], file_name=f"max_diversification_span", local_path=f"figures/") 96 | qis.save_figs_to_pdf(figs=figs, 97 | file_name=f"max_diversification_span", 98 | orientation='landscape', 99 | local_path=local_path.get_output_path()) 100 | plt.show() 101 | 102 | 103 | if __name__ == '__main__': 104 | 105 | unit_test = UnitTests.MAX_DIVERSIFICATION_SPAN 106 | 107 | is_run_all_tests = False 108 | if is_run_all_tests: 109 | for unit_test in UnitTests: 110 | run_unit_test(unit_test=unit_test) 111 | else: 112 | run_unit_test(unit_test=unit_test) 113 | -------------------------------------------------------------------------------- /optimalportfolios/examples/risk_contribution_balanced_portfolio.py: -------------------------------------------------------------------------------- 1 | """ 2 | create 60/40 portfolio with static weights 3 | run 90/10 risk budget portfolio 4 | show weights/risk contributions for both 5 | """ 6 | import qis as qis 7 | import matplotlib.pyplot as plt 8 | import yfinance as yf 9 | from optimalportfolios import estimate_rolling_ewma_covar, rolling_risk_budgeting, Constraints 10 | from optimalportfolios import local_path as lp 11 | from qis.portfolio.reports.strategy_benchmark_factsheet import weights_tracking_error_report_by_ac_subac 12 | 13 | # specify rebalancings 14 | rebalancing_freq = 'QE' # for portfolio rebalancing 15 | returns_freq = 'ME' # for covariance computation 16 | span = 104 17 | time_period = qis.TimePeriod('31Dec2004', '07Mar2025') # for portfolio computations 18 | 19 | static_portfolio_weights = {'SPY': 0.6, 'IEF': 0.4} 20 | risk_budgets = {'SPY': 0.98, 'IEF': 0.020} 21 | prices = yf.download(tickers=list(static_portfolio_weights.keys()), start=None, end=None, ignore_tz=True)['Close'] 22 | prices = prices[list(static_portfolio_weights.keys())].asfreq('ME', method='ffill') 23 | 24 | # static portfolio rebalanced quarterly 25 | balanced_60_40 = qis.backtest_model_portfolio(prices=prices, weights=static_portfolio_weights, rebalancing_freq='QE', 26 | ticker='60/40 static portfolio') 27 | 28 | # compute covar matrix using 1y span 29 | covar_dict = estimate_rolling_ewma_covar(prices=prices, time_period=time_period, 30 | rebalancing_freq=rebalancing_freq, 31 | returns_freq=returns_freq, 32 | span=span) 33 | # portfolio with equal risk contribution 34 | risk_budget_weights = rolling_risk_budgeting(prices=prices, 35 | time_period=time_period, 36 | covar_dict=covar_dict, 37 | risk_budget=risk_budgets, 38 | constraints0=Constraints(is_long_only=True)) 39 | risk_budget_portfolio = qis.backtest_model_portfolio(prices=prices, weights=risk_budget_weights, 40 | ticker='Risk-budgeted portfolio') 41 | 42 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas=[risk_budget_portfolio, balanced_60_40], 43 | benchmark_prices=prices.iloc[:, 0], 44 | covar_dict=covar_dict) 45 | 46 | report_kwargs = qis.fetch_default_report_kwargs(time_period=time_period, 47 | reporting_frequency=qis.ReportingFrequency.MONTHLY, 48 | add_rates_data=False) 49 | 50 | figs1 = qis.generate_strategy_benchmark_factsheet_plt(multi_portfolio_data=multi_portfolio_data, 51 | strategy_idx=0, 52 | benchmark_idx=1, 53 | add_benchmarks_to_navs=False, 54 | add_exposures_comp=True, 55 | add_strategy_factsheet=True, 56 | time_period=time_period, 57 | **report_kwargs) 58 | qis.save_figs_to_pdf(figs1, file_name='risk_portfolio', local_path=lp.get_output_path()) 59 | 60 | figs2, dfs = weights_tracking_error_report_by_ac_subac(multi_portfolio_data=multi_portfolio_data, time_period=time_period, 61 | **report_kwargs) 62 | 63 | qis.save_figs_to_pdf(figs2, file_name='risk_portfolio2', local_path=lp.get_output_path()) 64 | 65 | all_navs = multi_portfolio_data.get_navs(add_benchmarks_to_navs=True) 66 | 67 | fig = qis.generate_multi_asset_factsheet(prices=all_navs, benchmark='SPY', time_period=time_period, 68 | **report_kwargs) 69 | qis.save_figs_to_pdf([fig], file_name='risk_portfolio3', local_path=lp.get_output_path()) 70 | 71 | plt.close('all') 72 | 73 | -------------------------------------------------------------------------------- /optimalportfolios/examples/solvers/carra_mixture.py: -------------------------------------------------------------------------------- 1 | """ 2 | example of minimization of tracking error 3 | """ 4 | import pandas as pd 5 | import matplotlib.pyplot as plt 6 | import qis as qis 7 | from enum import Enum 8 | 9 | from optimalportfolios import (Constraints, GroupLowerUpperConstraints, 10 | compute_te_turnover, 11 | rolling_maximize_cara_mixture, 12 | wrapper_maximize_cara_mixture, 13 | fit_gaussian_mixture) 14 | 15 | from optimalportfolios.examples.universe import fetch_benchmark_universe_data 16 | 17 | 18 | class UnitTests(Enum): 19 | ONE_STEP_OPTIMISATION = 1 20 | ROLLING_OPTIMISATION = 2 21 | 22 | 23 | def run_unit_test(unit_test: UnitTests): 24 | 25 | import optimalportfolios.local_path as lp 26 | 27 | prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices = fetch_benchmark_universe_data() 28 | 29 | # add costraints that each asset class is 10% <= sum ac weights <= 30% (benchamrk is 20% each) 30 | group_min_allocation = pd.Series(0.05, index=ac_loadings.columns) 31 | group_max_allocation = pd.Series(0.25, index=ac_loadings.columns) 32 | group_lower_upper_constraints = GroupLowerUpperConstraints(group_loadings=ac_loadings, 33 | group_min_allocation=group_min_allocation, 34 | group_max_allocation=group_max_allocation) 35 | constraints0 = Constraints(is_long_only=True, 36 | group_lower_upper_constraints=group_lower_upper_constraints, 37 | min_weights=pd.Series(0.0, index=prices.columns), 38 | max_weights=pd.Series(0.5, index=prices.columns), 39 | weights_0=benchmark_weights) 40 | 41 | if unit_test == UnitTests.ONE_STEP_OPTIMISATION: 42 | # optimise using last available data as inputs 43 | returns = qis.to_returns(prices, freq='ME', is_log_returns=True).dropna() 44 | params = fit_gaussian_mixture(x=returns.to_numpy(), n_components=3, scaler=52) 45 | 46 | weights = wrapper_maximize_cara_mixture(means=params.means, 47 | covars=params.covars, 48 | probs=params.probs, 49 | constraints0=constraints0, 50 | tickers=returns.columns.to_list(), 51 | carra=0.5) 52 | 53 | df_weight = pd.concat([benchmark_weights.rename('benchmark'), weights.rename('portfolio')], axis=1) 54 | print(f"weights=\n{df_weight}") 55 | qis.plot_bars(df=df_weight) 56 | 57 | pd_covar = pd.DataFrame(12.0 * qis.compute_masked_covar_corr(data=returns, is_covar=True), 58 | index=prices.columns, columns=prices.columns) 59 | te_vol, turnover, alpha, port_vol, benchmark_vol = compute_te_turnover(covar=pd_covar.to_numpy(), 60 | benchmark_weights=benchmark_weights, 61 | weights=weights, 62 | weights_0=benchmark_weights) 63 | print(f"port_vol={port_vol:0.4f}, benchmark_vol={benchmark_vol:0.4f}, te_vol={te_vol:0.4f}, " 64 | f"turnover={turnover:0.4f}, alpha={alpha:0.4f}") 65 | 66 | plt.show() 67 | 68 | elif unit_test == UnitTests.ROLLING_OPTIMISATION: 69 | # optimise using last available data as inputs 70 | time_period = qis.TimePeriod('31Jan2007', '17Apr2025') 71 | rebalancing_costs = 0.0003 72 | 73 | weights = rolling_maximize_cara_mixture(prices=prices, 74 | constraints0=constraints0, 75 | roll_window=12*10, 76 | returns_freq='ME', 77 | time_period=time_period) 78 | print(weights) 79 | portfolio_dict = {'Optimal Portfolio': weights, 80 | 'EqualWeight Portfolio': qis.df_to_equal_weight_allocation(prices, index=weights.index)} 81 | portfolio_datas = [] 82 | for ticker, weights in portfolio_dict.items(): 83 | portfolio_data = qis.backtest_model_portfolio(prices=prices, 84 | weights=weights, 85 | rebalancing_costs=rebalancing_costs, 86 | weight_implementation_lag=1, 87 | ticker=ticker) 88 | portfolio_data.set_group_data(group_data=group_data) 89 | portfolio_datas.append(portfolio_data) 90 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas, benchmark_prices=benchmark_prices) 91 | kwargs = qis.fetch_default_report_kwargs(time_period=time_period, add_rates_data=True) 92 | figs = qis.generate_strategy_benchmark_factsheet_plt(multi_portfolio_data=multi_portfolio_data, 93 | time_period=time_period, 94 | add_strategy_factsheet=True, 95 | add_grouped_exposures=False, 96 | add_grouped_cum_pnl=False, 97 | **kwargs) 98 | qis.save_figs_to_pdf(figs=figs, 99 | file_name=f"carra utility portfolio", orientation='landscape', 100 | local_path=lp.get_output_path()) 101 | 102 | 103 | if __name__ == '__main__': 104 | 105 | unit_test = UnitTests.ROLLING_OPTIMISATION 106 | 107 | is_run_all_tests = False 108 | if is_run_all_tests: 109 | for unit_test in UnitTests: 110 | run_unit_test(unit_test=unit_test) 111 | else: 112 | run_unit_test(unit_test=unit_test) 113 | -------------------------------------------------------------------------------- /optimalportfolios/examples/solvers/max_diversification.py: -------------------------------------------------------------------------------- 1 | """ 2 | example of minimization of tracking error 3 | """ 4 | import pandas as pd 5 | import matplotlib.pyplot as plt 6 | import qis as qis 7 | from enum import Enum 8 | 9 | from optimalportfolios import (Constraints, GroupLowerUpperConstraints, CovarEstimator, 10 | compute_te_turnover, 11 | rolling_maximise_diversification, 12 | wrapper_maximise_diversification) 13 | 14 | from optimalportfolios.examples.universe import fetch_benchmark_universe_data 15 | 16 | 17 | class UnitTests(Enum): 18 | ONE_STEP_OPTIMISATION = 1 19 | TRACKING_ERROR_GRID = 2 20 | ROLLING_OPTIMISATION = 3 21 | 22 | 23 | def run_unit_test(unit_test: UnitTests): 24 | 25 | import optimalportfolios.local_path as lp 26 | 27 | prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices = fetch_benchmark_universe_data() 28 | 29 | # add costraints that each asset class is 10% <= sum ac weights <= 30% (benchamrk is 20% each) 30 | group_min_allocation = pd.Series(0.1, index=ac_loadings.columns) 31 | group_max_allocation = pd.Series(0.3, index=ac_loadings.columns) 32 | group_lower_upper_constraints = GroupLowerUpperConstraints(group_loadings=ac_loadings, 33 | group_min_allocation=group_min_allocation, 34 | group_max_allocation=group_max_allocation) 35 | constraints0 = Constraints(is_long_only=True, 36 | min_weights=0.0 * benchmark_weights, 37 | max_weights=3.0 * benchmark_weights, 38 | weights_0=benchmark_weights, 39 | group_lower_upper_constraints=group_lower_upper_constraints) 40 | 41 | if unit_test == UnitTests.ONE_STEP_OPTIMISATION: 42 | # optimise using last available data as inputs 43 | returns = qis.to_returns(prices, freq='W-WED', is_log_returns=True) 44 | pd_covar = pd.DataFrame(52.0 * qis.compute_masked_covar_corr(data=returns, is_covar=True), 45 | index=prices.columns, columns=prices.columns) 46 | print(f"pd_covar=\n{pd_covar}") 47 | 48 | weights = wrapper_maximise_diversification(pd_covar=pd_covar, 49 | constraints0=constraints0, 50 | weights_0=benchmark_weights) 51 | 52 | df_weight = pd.concat([benchmark_weights.rename('benchmark'), weights.rename('portfolio')], axis=1) 53 | print(f"weights=\n{df_weight}") 54 | qis.plot_bars(df=df_weight, stacked=False) 55 | 56 | te_vol, turnover, alpha, port_vol, benchmark_vol = compute_te_turnover(covar=pd_covar.to_numpy(), 57 | benchmark_weights=benchmark_weights, 58 | weights=weights, 59 | weights_0=benchmark_weights) 60 | print(f"port_vol={port_vol:0.4f}, benchmark_vol={benchmark_vol:0.4f}, te_vol={te_vol:0.4f}, " 61 | f"turnover={turnover:0.4f}, alpha={alpha:0.4f}") 62 | 63 | plt.show() 64 | 65 | elif unit_test == UnitTests.ROLLING_OPTIMISATION: 66 | # optimise using last available data as inputs 67 | time_period = qis.TimePeriod('31Jan2007', '17Apr2025') 68 | rebalancing_costs = 0.0003 69 | covar_estimator = CovarEstimator() 70 | weights = rolling_maximise_diversification(prices=prices, 71 | constraints0=constraints0, 72 | time_period=time_period, 73 | covar_estimator=covar_estimator) 74 | print(weights) 75 | 76 | portfolio_dict = {'Optimal Portfolio': weights, 77 | 'EqualWeight Portfolio': qis.df_to_equal_weight_allocation(prices, index=weights.index)} 78 | portfolio_datas = [] 79 | for ticker, weights in portfolio_dict.items(): 80 | portfolio_data = qis.backtest_model_portfolio(prices=prices, 81 | weights=weights, 82 | rebalancing_costs=rebalancing_costs, 83 | weight_implementation_lag=1, 84 | ticker=ticker) 85 | portfolio_data.set_group_data(group_data=group_data) 86 | portfolio_datas.append(portfolio_data) 87 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas, benchmark_prices=benchmark_prices) 88 | kwargs = qis.fetch_default_report_kwargs(time_period=time_period, add_rates_data=True) 89 | figs = qis.generate_strategy_benchmark_factsheet_plt(multi_portfolio_data=multi_portfolio_data, 90 | time_period=time_period, 91 | add_strategy_factsheet=True, 92 | add_grouped_exposures=False, 93 | add_grouped_cum_pnl=False, 94 | **kwargs) 95 | qis.save_figs_to_pdf(figs=figs, 96 | file_name=f"max diversification portfolio", orientation='landscape', 97 | local_path=lp.get_output_path()) 98 | 99 | 100 | if __name__ == '__main__': 101 | 102 | unit_test = UnitTests.ROLLING_OPTIMISATION 103 | 104 | is_run_all_tests = False 105 | if is_run_all_tests: 106 | for unit_test in UnitTests: 107 | run_unit_test(unit_test=unit_test) 108 | else: 109 | run_unit_test(unit_test=unit_test) 110 | -------------------------------------------------------------------------------- /optimalportfolios/examples/solvers/max_sharpe.py: -------------------------------------------------------------------------------- 1 | """ 2 | example of minimization of tracking error 3 | """ 4 | import numpy as np 5 | import pandas as pd 6 | import matplotlib.pyplot as plt 7 | import qis as qis 8 | from enum import Enum 9 | 10 | from optimalportfolios import (Constraints, GroupLowerUpperConstraints, 11 | compute_te_turnover, 12 | rolling_maximize_portfolio_sharpe, 13 | wrapper_maximize_portfolio_sharpe) 14 | 15 | from optimalportfolios.examples.universe import fetch_benchmark_universe_data 16 | 17 | 18 | class UnitTests(Enum): 19 | ONE_STEP_OPTIMISATION = 1 20 | ROLLING_OPTIMISATION = 2 21 | 22 | 23 | def run_unit_test(unit_test: UnitTests): 24 | 25 | import optimalportfolios.local_path as lp 26 | 27 | prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices = fetch_benchmark_universe_data() 28 | 29 | # add costraints that each asset class is 10% <= sum ac weights <= 30% (benchamrk is 20% each) 30 | group_min_allocation = pd.Series(0.05, index=ac_loadings.columns) 31 | group_max_allocation = pd.Series(0.25, index=ac_loadings.columns) 32 | group_lower_upper_constraints = GroupLowerUpperConstraints(group_loadings=ac_loadings, 33 | group_min_allocation=group_min_allocation, 34 | group_max_allocation=group_max_allocation) 35 | constraints0 = Constraints(is_long_only=True, 36 | group_lower_upper_constraints=group_lower_upper_constraints, 37 | min_weights=pd.Series(0.0, index=prices.columns), 38 | max_weights=pd.Series(1.0, index=prices.columns), 39 | weights_0=benchmark_weights) 40 | 41 | if unit_test == UnitTests.ONE_STEP_OPTIMISATION: 42 | # optimise using last available data as inputs 43 | returns = qis.to_returns(prices, freq='W-WED', is_log_returns=True) 44 | pd_covar = pd.DataFrame(52.0 * qis.compute_masked_covar_corr(data=returns, is_covar=True), 45 | index=prices.columns, columns=prices.columns) 46 | print(f"pd_covar=\n{pd_covar}") 47 | 48 | weights = wrapper_maximize_portfolio_sharpe(pd_covar=pd_covar, 49 | means=52.0*returns.mean(0), 50 | constraints0=constraints0, 51 | weights_0=benchmark_weights) 52 | 53 | df_weight = pd.concat([benchmark_weights.rename('benchmark'), weights.rename('portfolio')], axis=1) 54 | print(f"weights=\n{df_weight}") 55 | qis.plot_bars(df=df_weight) 56 | 57 | te_vol, turnover, alpha, port_vol, benchmark_vol = compute_te_turnover(covar=pd_covar.to_numpy(), 58 | benchmark_weights=benchmark_weights, 59 | weights=weights, 60 | weights_0=benchmark_weights) 61 | print(f"port_vol={port_vol:0.4f}, benchmark_vol={benchmark_vol:0.4f}, te_vol={te_vol:0.4f}, " 62 | f"turnover={turnover:0.4f}, alpha={alpha:0.4f}") 63 | 64 | plt.show() 65 | 66 | elif unit_test == UnitTests.ROLLING_OPTIMISATION: 67 | # optimise using last available data as inputs 68 | time_period = qis.TimePeriod('31Jan2007', '17Apr2025') 69 | rebalancing_costs = 0.0003 70 | 71 | weights = rolling_maximize_portfolio_sharpe(prices=prices, 72 | constraints0=constraints0, 73 | time_period=time_period) 74 | 75 | print(weights) 76 | 77 | portfolio_dict = {'Optimal Portfolio': weights, 78 | 'EqualWeight Portfolio': qis.df_to_equal_weight_allocation(prices, index=weights.index)} 79 | portfolio_datas = [] 80 | for ticker, weights in portfolio_dict.items(): 81 | portfolio_data = qis.backtest_model_portfolio(prices=prices, 82 | weights=weights, 83 | rebalancing_costs=rebalancing_costs, 84 | weight_implementation_lag=1, 85 | ticker=ticker) 86 | portfolio_data.set_group_data(group_data=group_data) 87 | portfolio_datas.append(portfolio_data) 88 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas, benchmark_prices=benchmark_prices) 89 | kwargs = qis.fetch_default_report_kwargs(time_period=time_period, add_rates_data=True) 90 | figs = qis.generate_strategy_benchmark_factsheet_plt(multi_portfolio_data=multi_portfolio_data, 91 | time_period=time_period, 92 | add_strategy_factsheet=True, 93 | add_grouped_exposures=False, 94 | add_grouped_cum_pnl=False, 95 | **kwargs) 96 | qis.save_figs_to_pdf(figs=figs, 97 | file_name=f"max sharpe portfolio", orientation='landscape', 98 | local_path=lp.get_output_path()) 99 | 100 | 101 | if __name__ == '__main__': 102 | 103 | unit_test = UnitTests.ROLLING_OPTIMISATION 104 | 105 | is_run_all_tests = False 106 | if is_run_all_tests: 107 | for unit_test in UnitTests: 108 | run_unit_test(unit_test=unit_test) 109 | else: 110 | run_unit_test(unit_test=unit_test) 111 | -------------------------------------------------------------------------------- /optimalportfolios/examples/solvers/min_variance.py: -------------------------------------------------------------------------------- 1 | """ 2 | example of minimization of tracking error 3 | """ 4 | import numpy as np 5 | import pandas as pd 6 | import matplotlib.pyplot as plt 7 | import qis as qis 8 | from enum import Enum 9 | 10 | from optimalportfolios import (Constraints, GroupLowerUpperConstraints, CovarEstimator, 11 | compute_te_turnover, 12 | wrapper_quadratic_optimisation, 13 | rolling_quadratic_optimisation) 14 | 15 | from optimalportfolios.examples.universe import fetch_benchmark_universe_data 16 | 17 | 18 | class UnitTests(Enum): 19 | ONE_STEP_OPTIMISATION = 1 20 | TRACKING_ERROR_GRID = 2 21 | ROLLING_OPTIMISATION = 3 22 | 23 | 24 | def run_unit_test(unit_test: UnitTests): 25 | 26 | import optimalportfolios.local_path as lp 27 | 28 | prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices = fetch_benchmark_universe_data() 29 | 30 | # add costraints that each asset class is 10% <= sum ac weights <= 30% (benchamrk is 20% each) 31 | group_min_allocation = pd.Series(0.1, index=ac_loadings.columns) 32 | group_max_allocation = pd.Series(0.3, index=ac_loadings.columns) 33 | group_lower_upper_constraints = GroupLowerUpperConstraints(group_loadings=ac_loadings, 34 | group_min_allocation=group_min_allocation, 35 | group_max_allocation=group_max_allocation) 36 | 37 | constraints0 = Constraints(is_long_only=True, 38 | min_weights=pd.Series(0.0, index=prices.columns), 39 | max_weights=pd.Series(0.2, index=prices.columns), 40 | weights_0=benchmark_weights, 41 | group_lower_upper_constraints=group_lower_upper_constraints) 42 | 43 | if unit_test == UnitTests.ONE_STEP_OPTIMISATION: 44 | # optimise using last available data as inputs 45 | returns = qis.to_returns(prices, freq='W-WED', is_log_returns=True) 46 | pd_covar = pd.DataFrame(52.0 * qis.compute_masked_covar_corr(data=returns, is_covar=True), 47 | index=prices.columns, columns=prices.columns) 48 | print(f"pd_covar=\n{pd_covar}") 49 | weights = wrapper_quadratic_optimisation(pd_covar=pd_covar, 50 | constraints0=constraints0, 51 | weights_0=benchmark_weights) 52 | 53 | df_weight = pd.concat([benchmark_weights.rename('benchmark'), weights.rename('portfolio')], axis=1) 54 | print(f"weights=\n{df_weight}") 55 | qis.plot_bars(df=df_weight) 56 | 57 | te_vol, turnover, alpha, port_vol, benchmark_vol = compute_te_turnover(covar=pd_covar.to_numpy(), 58 | benchmark_weights=benchmark_weights, 59 | weights=weights, 60 | weights_0=benchmark_weights) 61 | print(f"port_vol={port_vol:0.4f}, benchmark_vol={benchmark_vol:0.4f}, te_vol={te_vol:0.4f}, " 62 | f"turnover={turnover:0.4f}, alpha={alpha:0.4f}") 63 | 64 | plt.show() 65 | 66 | elif unit_test == UnitTests.ROLLING_OPTIMISATION: 67 | # optimise using last available data as inputs 68 | time_period = qis.TimePeriod('31Jan2007', '17Apr2025') 69 | rebalancing_costs = 0.0003 70 | covar_estimator = CovarEstimator() 71 | weights = rolling_quadratic_optimisation(prices=prices, 72 | constraints0=constraints0, 73 | time_period=time_period, 74 | covar_estimator=covar_estimator) 75 | print(weights) 76 | 77 | portfolio_dict = {'Optimal Portfolio': weights, 78 | 'EqualWeight Portfolio': qis.df_to_equal_weight_allocation(prices, index=weights.index)} 79 | portfolio_datas = [] 80 | for ticker, weights in portfolio_dict.items(): 81 | portfolio_data = qis.backtest_model_portfolio(prices=prices, 82 | weights=weights, 83 | rebalancing_costs=rebalancing_costs, 84 | weight_implementation_lag=1, 85 | ticker=ticker) 86 | portfolio_data.set_group_data(group_data=group_data) 87 | portfolio_datas.append(portfolio_data) 88 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas, benchmark_prices=benchmark_prices) 89 | kwargs = qis.fetch_default_report_kwargs(time_period=time_period, add_rates_data=True) 90 | figs = qis.generate_strategy_benchmark_factsheet_plt(multi_portfolio_data=multi_portfolio_data, 91 | time_period=time_period, 92 | add_strategy_factsheet=True, 93 | add_grouped_exposures=False, 94 | add_grouped_cum_pnl=False, 95 | **kwargs) 96 | qis.save_figs_to_pdf(figs=figs, 97 | file_name=f"min variance portfolio", orientation='landscape', 98 | local_path=lp.get_output_path()) 99 | 100 | 101 | if __name__ == '__main__': 102 | 103 | unit_test = UnitTests.ROLLING_OPTIMISATION 104 | 105 | is_run_all_tests = False 106 | if is_run_all_tests: 107 | for unit_test in UnitTests: 108 | run_unit_test(unit_test=unit_test) 109 | else: 110 | run_unit_test(unit_test=unit_test) 111 | -------------------------------------------------------------------------------- /optimalportfolios/examples/solvers/risk_parity.py: -------------------------------------------------------------------------------- 1 | """ 2 | example of minimization of tracking error 3 | """ 4 | import numpy as np 5 | import pandas as pd 6 | import matplotlib.pyplot as plt 7 | import qis as qis 8 | from enum import Enum 9 | 10 | from optimalportfolios import (Constraints, GroupLowerUpperConstraints, CovarEstimator, 11 | compute_te_turnover, 12 | wrapper_risk_budgeting, 13 | rolling_risk_budgeting) 14 | 15 | from optimalportfolios.examples.universe import fetch_benchmark_universe_data 16 | 17 | 18 | class UnitTests(Enum): 19 | ONE_STEP_OPTIMISATION = 1 20 | TRACKING_ERROR_GRID = 2 21 | ROLLING_OPTIMISATION = 3 22 | 23 | 24 | def run_unit_test(unit_test: UnitTests): 25 | 26 | import optimalportfolios.local_path as lp 27 | 28 | prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices = fetch_benchmark_universe_data() 29 | 30 | # add costraints that each asset class is 10% <= sum ac weights <= 30% (benchamrk is 20% each) 31 | group_min_allocation = pd.Series(0.1, index=ac_loadings.columns) 32 | group_max_allocation = pd.Series(0.3, index=ac_loadings.columns) 33 | group_lower_upper_constraints = GroupLowerUpperConstraints(group_loadings=ac_loadings, 34 | group_min_allocation=group_min_allocation, 35 | group_max_allocation=group_max_allocation) 36 | 37 | constraints0 = Constraints(is_long_only=True, 38 | min_weights=pd.Series(0.0, index=prices.columns), 39 | max_weights=pd.Series(1.0, index=prices.columns), 40 | weights_0=benchmark_weights) 41 | 42 | if unit_test == UnitTests.ONE_STEP_OPTIMISATION: 43 | # optimise using last available data as inputs 44 | returns = qis.to_returns(prices, freq='W-WED', is_log_returns=True) 45 | pd_covar = pd.DataFrame(52.0 * qis.compute_masked_covar_corr(data=returns, is_covar=True), 46 | index=prices.columns, columns=prices.columns) 47 | print(f"pd_covar=\n{pd_covar}") 48 | 49 | weights = wrapper_risk_budgeting(pd_covar=pd_covar, 50 | constraints0=constraints0, 51 | weights_0=benchmark_weights) 52 | 53 | df_weight = pd.concat([benchmark_weights.rename('benchmark'), weights.rename('portfolio')], axis=1) 54 | print(f"weights=\n{df_weight}") 55 | qis.plot_bars(df=df_weight) 56 | 57 | te_vol, turnover, alpha, port_vol, benchmark_vol = compute_te_turnover(covar=pd_covar.to_numpy(), 58 | benchmark_weights=benchmark_weights, 59 | weights=weights, 60 | weights_0=benchmark_weights) 61 | print(f"port_vol={port_vol:0.4f}, benchmark_vol={benchmark_vol:0.4f}, te_vol={te_vol:0.4f}, " 62 | f"turnover={turnover:0.4f}, alpha={alpha:0.4f}") 63 | 64 | plt.show() 65 | 66 | elif unit_test == UnitTests.ROLLING_OPTIMISATION: 67 | # optimise using last available data as inputs 68 | time_period = qis.TimePeriod('31Jan2007', '17Apr2025') 69 | rebalancing_costs = 0.0003 70 | covar_estimator = CovarEstimator(returns_freqs='W-WED', rebalancing_freq='ME', span=52) 71 | weights = rolling_risk_budgeting(prices=prices, 72 | constraints0=constraints0, 73 | time_period=time_period, 74 | covar_estimator=covar_estimator) 75 | print(weights) 76 | 77 | portfolio_dict = {'Optimal Portfolio': weights, 78 | 'EqualWeight Portfolio': qis.df_to_equal_weight_allocation(prices, index=weights.index)} 79 | portfolio_datas = [] 80 | for ticker, weights in portfolio_dict.items(): 81 | portfolio_data = qis.backtest_model_portfolio(prices=prices, 82 | weights=weights, 83 | rebalancing_costs=rebalancing_costs, 84 | weight_implementation_lag=1, 85 | ticker=ticker) 86 | portfolio_data.set_group_data(group_data=group_data) 87 | portfolio_datas.append(portfolio_data) 88 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas, benchmark_prices=benchmark_prices) 89 | kwargs = qis.fetch_default_report_kwargs(time_period=time_period, add_rates_data=True) 90 | figs = qis.generate_strategy_benchmark_factsheet_plt(multi_portfolio_data=multi_portfolio_data, 91 | time_period=time_period, 92 | add_strategy_factsheet=True, 93 | add_grouped_exposures=False, 94 | add_grouped_cum_pnl=False, 95 | **kwargs) 96 | qis.save_figs_to_pdf(figs=figs, 97 | file_name=f"risk parity portfolio", orientation='landscape', 98 | local_path=lp.get_output_path()) 99 | 100 | 101 | if __name__ == '__main__': 102 | 103 | unit_test = UnitTests.ROLLING_OPTIMISATION 104 | 105 | is_run_all_tests = False 106 | if is_run_all_tests: 107 | for unit_test in UnitTests: 108 | run_unit_test(unit_test=unit_test) 109 | else: 110 | run_unit_test(unit_test=unit_test) 111 | -------------------------------------------------------------------------------- /optimalportfolios/examples/solvers/target_return.py: -------------------------------------------------------------------------------- 1 | """ 2 | example of maximization of alpha with target return 3 | """ 4 | import numpy as np 5 | import pandas as pd 6 | import matplotlib.pyplot as plt 7 | import seaborn as sns 8 | import qis as qis 9 | import yfinance as yf 10 | from enum import Enum 11 | from typing import Tuple 12 | 13 | from optimalportfolios import (Constraints, compute_portfolio_vol, 14 | wrapper_maximise_alpha_with_target_return, 15 | rolling_maximise_alpha_with_target_return) 16 | 17 | 18 | def run_bonds_etf_optimal_portfolio(prices: pd.DataFrame, 19 | yields: pd.DataFrame, 20 | target_returns: pd.Series, 21 | time_period: qis.TimePeriod = qis.TimePeriod('31Jan2008', '19Jul2024') 22 | ) -> pd.DataFrame: 23 | """ 24 | run the optimal portfolio 25 | """ 26 | momentum = qis.compute_ewm_long_short_filtered_ra_returns(returns=qis.to_returns(prices, freq='W-WED'), vol_span=13, 27 | long_span=13, short_span=None, weight_lag=0) 28 | # momentum = qis.map_signal_to_weight(signals=momentum, loc=0.0, slope_right=0.5, slope_left=0.5, tail_level=3.0) 29 | alphas = qis.df_to_cross_sectional_score(df=momentum) 30 | 31 | constraints0 = Constraints(is_long_only=True, 32 | min_weights=pd.Series(0.0, index=prices.columns), 33 | max_weights=pd.Series(0.2, index=prices.columns), 34 | max_target_portfolio_vol_an=0.065, 35 | max_exposure=1.0, 36 | min_exposure=0.5, 37 | turnover_constraint=0.30 # 25% per month 38 | ) 39 | 40 | weights = rolling_maximise_alpha_with_target_return(prices=prices, 41 | alphas=alphas, 42 | yields=yields, 43 | target_returns=target_returns, 44 | constraints0=constraints0, 45 | time_period=time_period, 46 | span=52, 47 | rebalancing_freq='ME', 48 | verbose=False) 49 | return weights 50 | 51 | 52 | def compute_dividend_rolling_1y(dividend: pd.Series): 53 | rolling_1y = 4.0 * dividend.asfreq('ME', method='ffill').fillna(0.0).rolling(3).sum() 54 | return rolling_1y 55 | 56 | 57 | def fetch_benchmark_universe_data() -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.Series, pd.Series]: 58 | """ 59 | define custom universe with asset class grouping 60 | """ 61 | universe_data = dict( 62 | TLT='Tresuries', 63 | IEF='Tresuries', 64 | SHY='Tresuries', 65 | TFLO='Tresuries', # floating ust 66 | TIP='TIPS', 67 | STIP='TIPS', # 0-5 y tips 68 | LQD='IG', 69 | IGSB='IG', # 1-5y corporates 70 | FLOT='IG', # corporate float 71 | MUB='Munies/MBS', 72 | MBB='Munies/MBS', 73 | HYG='HighYield', 74 | SHYG='HighYield', # 0-5y HY 75 | FALN='HighYield', # fallen angels 76 | EMB='EM', 77 | EMHY='EM', # em hy 78 | ICVT='Hybrid' # converts 79 | # PFF='Hybrid' # preferds 80 | ) 81 | yield_deflators = dict(TIP=0.75, STIP=0.75) # tips pay income distribution 82 | 83 | tickers = list(universe_data.keys()) 84 | group_data = pd.Series(universe_data) # for portfolio reporting 85 | prices = yf.download(tickers=tickers, start=None, end=None, ignore_tz=True)['Close'][tickers] 86 | prices = prices.asfreq('B', method='ffill') 87 | 88 | dividends = {} 89 | yields = {} # assume that div is paid monthly and extrapolate last 3 m to 1 year, yields are defined on monthly schedule 90 | for ticker in tickers: 91 | dividend = yf.Ticker(ticker).dividends 92 | dividend.index = dividend.index.tz_localize(None) # remove hours and tz 93 | dividends[ticker] = dividend 94 | rolling_1y = compute_dividend_rolling_1y(dividend=dividend) 95 | if ticker in yield_deflators.keys(): 96 | rolling_1y = yield_deflators[ticker] * rolling_1y 97 | yields[ticker] = rolling_1y.divide(prices[ticker].reindex(index=rolling_1y.index, method='ffill')) 98 | dividends = pd.DataFrame.from_dict(dividends, orient='columns') 99 | yields = pd.DataFrame.from_dict(yields, orient='columns') 100 | 101 | benchmarks = ['AGG'] 102 | benchmark_prices = yf.download(tickers=benchmarks, start=None, end=None, ignore_tz=True)['Close'] 103 | print(benchmark_prices) 104 | target_returns = yf.download('^IRX', start=None, end=None)['Close'].dropna() / 100.0 105 | target_returns = target_returns.iloc[:, 0].reindex(index=prices.index).ffill().rename('Target return') 106 | return prices, benchmark_prices, dividends, yields, target_returns, group_data 107 | 108 | 109 | class UnitTests(Enum): 110 | ILLUSTRATE_INPUT_DATA = 1 111 | ONE_STEP_OPTIMISATION = 2 112 | ROLLING_OPTIMISATION = 3 113 | 114 | 115 | def run_unit_test(unit_test: UnitTests): 116 | 117 | import optimalportfolios.local_path as lp 118 | 119 | prices, benchmark_prices, dividends, yields, target_returns, group_data = fetch_benchmark_universe_data() 120 | 121 | if unit_test == UnitTests.ILLUSTRATE_INPUT_DATA: 122 | with sns.axes_style('darkgrid'): 123 | fig, axs = plt.subplots(2, 1, figsize=(14, 12), constrained_layout=True) 124 | qis.plot_prices_with_dd(prices=prices, axs=axs) 125 | 126 | fig, axs = plt.subplots(2, 1, figsize=(14, 12), constrained_layout=True) 127 | qis.plot_time_series(df=dividends, title='Dividends', ax=axs[0]) 128 | yields = pd.concat([target_returns.reindex(index=yields.index, method='ffill'), yields], axis=1) 129 | qis.plot_time_series(df=yields, title='Yields', var_format='{:,.2%}', ax=axs[1]) 130 | plt.show() 131 | 132 | elif unit_test == UnitTests.ONE_STEP_OPTIMISATION: 133 | # optimise using last available data as inputs 134 | returns = qis.to_returns(prices, freq='W-WED', is_log_returns=True) 135 | pd_covar = pd.DataFrame(52.0 * qis.compute_masked_covar_corr(data=returns, is_covar=True), 136 | index=prices.columns, columns=prices.columns) 137 | print(f"pd_covar=\n{pd_covar}") 138 | last_yields = yields.iloc[-1, :] 139 | print(f"last_yields=\n{last_yields}") 140 | target_return = target_returns.iloc[-1] + 0.01 141 | print(f"target_return=\n{target_return}") 142 | momenum_1y = prices.divide(prices.shift(260)) - 1.0 143 | alphas = qis.df_to_cross_sectional_score(df=momenum_1y.iloc[-1, :]) 144 | print(f"alphas=\n{alphas}") 145 | constraints0 = Constraints(is_long_only=True, 146 | min_weights=pd.Series(0.0, index=prices.columns), 147 | max_weights=pd.Series(0.15, index=prices.columns), 148 | max_target_portfolio_vol_an=0.065, 149 | max_exposure=1.0, 150 | min_exposure=0.5 151 | ) 152 | 153 | weights = wrapper_maximise_alpha_with_target_return(pd_covar=pd_covar, alphas=alphas, yields=last_yields, 154 | target_return=target_return, 155 | constraints0=constraints0) 156 | print(f"weights={weights}") 157 | print(f"exposure = {np.sum(weights)}") 158 | print(f"portfolio_vol = {compute_portfolio_vol(covar=pd_covar, weights=weights)}") 159 | print(f"portfolio_yield = {np.nansum(weights.multiply(last_yields))}") 160 | qis.plot_heatmap(df=pd.DataFrame(qis.compute_masked_covar_corr(data=returns, is_covar=False), 161 | index=prices.columns, columns=prices.columns)) 162 | qis.plot_bars(df=weights) 163 | plt.show() 164 | 165 | elif unit_test == UnitTests.ROLLING_OPTIMISATION: 166 | # optimise using last available data as inputs 167 | time_period = qis.TimePeriod('31Dec2012', '17Apr2025') 168 | weights = run_bonds_etf_optimal_portfolio(prices=prices, 169 | yields=yields, 170 | target_returns=target_returns + 0.005, 171 | time_period=time_period) 172 | print(f"weights={weights}") 173 | print(f"exposure={weights.sum(1)}") 174 | portfolio_data = qis.backtest_model_portfolio(prices=prices, 175 | weights=weights, 176 | rebalancing_costs=0.0000, 177 | weight_implementation_lag=1, 178 | ticker=f"Optimal Portfolio") 179 | portfolio_data.set_group_data(group_data=group_data) 180 | kwargs = qis.fetch_default_report_kwargs(time_period=time_period, add_rates_data=True) 181 | figs = qis.generate_strategy_factsheet(portfolio_data=portfolio_data, 182 | benchmark_prices=benchmark_prices, 183 | time_period=time_period, 184 | add_grouped_exposures=False, 185 | add_grouped_cum_pnl=False, 186 | **kwargs) 187 | qis.save_figs_to_pdf(figs=figs, 188 | file_name=f"target return portfolio", orientation='landscape', 189 | local_path=lp.get_output_path()) 190 | 191 | 192 | if __name__ == '__main__': 193 | 194 | unit_test = UnitTests.ROLLING_OPTIMISATION 195 | 196 | is_run_all_tests = False 197 | if is_run_all_tests: 198 | for unit_test in UnitTests: 199 | run_unit_test(unit_test=unit_test) 200 | else: 201 | run_unit_test(unit_test=unit_test) 202 | -------------------------------------------------------------------------------- /optimalportfolios/examples/sp500_minvar.py: -------------------------------------------------------------------------------- 1 | """ 2 | run Minimum Variance portfolio optimiser for S&P 500 universe 3 | S&P 500 universe compositions are obtained from https://github.com/fja05680/sp500 4 | prices are fetched from yfinance 5 | note that some of the companies ever included in the S&P500 are de-listed and yfinance does not have data on them 6 | I run backtest from 31Dec2010 which should be less sensitive to de-listing bias 7 | By optimisation, I account for the index inclusions using dataframe with inclusion_indicators 8 | 9 | The goal is to backtest the sensetivity of squeezing of the covariance matrix using SSRN paper 10 | Squeezing Financial Noise: A Novel Approach to Covariance Matrix Estimation 11 | https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4986939 12 | """ 13 | 14 | # packages 15 | import pandas as pd 16 | import qis as qis 17 | import yfinance as yf 18 | from typing import Tuple, List 19 | from enum import Enum 20 | 21 | # optimalportfolios 22 | from optimalportfolios import (PortfolioObjective, Constraints, rolling_quadratic_optimisation, CovarEstimator) 23 | from optimalportfolios.local_path import get_resource_path 24 | 25 | # path to save universe data 26 | LOCAL_PATH = f"{get_resource_path()}//sp500//" 27 | # download from source: https://github.com/fja05680/sp500 28 | SP500_FILE = "S&P 500 Historical Components & Changes(08-17-2024).csv" 29 | 30 | 31 | def create_sp500_universe(): 32 | """ 33 | use SP500_FILE file to fetch the list of universe 34 | load price and industry data using yfinance 35 | """ 36 | def create_inclusion_indicators(universe: pd.DataFrame) -> pd.DataFrame: 37 | inclusion_indicators = {} 38 | for date in universe.index: 39 | tickers = universe.loc[date, :].apply(lambda x: sorted(x.split(','))).to_list()[0] 40 | inclusion_indicators[date] = pd.Series(1.0, index=tickers) 41 | inclusion_indicators = pd.DataFrame.from_dict(inclusion_indicators, orient='index').sort_index() 42 | return inclusion_indicators 43 | 44 | def fetch_universe_prices(tickers: List[str]) -> pd.DataFrame: 45 | prices = yf.download(tickers=tickers, start=None, end=None, ignore_tz=True)['Close'] 46 | return prices[tickers] 47 | 48 | def fetch_universe_industry(tickers: List[str]) -> pd.Series: 49 | group_data = {} 50 | for ticker in tickers: 51 | this = yf.Ticker(ticker).info 52 | if 'sector' in this: 53 | group_data[ticker] = this['sector'] 54 | else: 55 | group_data[ticker] = 'unclassified' 56 | return pd.Series(group_data) 57 | 58 | universe = pd.read_csv(f"{LOCAL_PATH}{SP500_FILE}", index_col='date') 59 | inclusion_indicators = create_inclusion_indicators(universe) 60 | prices = fetch_universe_prices(tickers=inclusion_indicators.columns.to_list()) 61 | # remove all nans 62 | prices = prices.dropna(axis=1, how='all').asfreq('B', method='ffill') 63 | group_data = fetch_universe_industry(tickers=prices.columns.to_list()) 64 | inclusion_indicators = inclusion_indicators[prices.columns] 65 | qis.save_df_to_csv(df=prices, file_name='sp500_prices', local_path=LOCAL_PATH) 66 | qis.save_df_to_csv(df=inclusion_indicators, file_name='sp500_inclusions', local_path=LOCAL_PATH) 67 | qis.save_df_to_csv(df=group_data.to_frame(), file_name='sp500_groups', local_path=LOCAL_PATH) 68 | 69 | 70 | def load_sp500_universe() -> Tuple[pd.DataFrame, pd.DataFrame, pd.Series]: 71 | prices = qis.load_df_from_csv(file_name='sp500_prices', local_path=LOCAL_PATH) 72 | inclusion_indicators = qis.load_df_from_csv(file_name='sp500_inclusions', local_path=LOCAL_PATH) 73 | inclusion_indicators.index = inclusion_indicators.index.tz_localize(tz=prices.index.tz) # align tz info 74 | group_data = qis.load_df_from_csv(file_name='sp500_groups', parse_dates=False, local_path=LOCAL_PATH).iloc[:, 0] 75 | return prices, inclusion_indicators, group_data 76 | 77 | 78 | def run_cross_backtest(time_period: qis.TimePeriod, 79 | squeeze_factors: List[float] = (0.0, 0.125, 0.250, 0.375, 0.5, 0.7, 0.9) 80 | ): 81 | # run cross-backtest for sensetivity to 82 | prices, inclusion_indicators, group_data = load_sp500_universe() 83 | 84 | constraints0 = Constraints(is_long_only=True, 85 | min_weights=pd.Series(0.0, index=prices.columns), 86 | max_weights=pd.Series(0.05, index=prices.columns)) 87 | 88 | portfolio_datas = [] 89 | for squeeze_factor in squeeze_factors: 90 | covar_estimator = CovarEstimator(squeeze_factor=squeeze_factor, returns_freqs='W-WED', rebalancing_freq='QE') 91 | weights = rolling_quadratic_optimisation(prices=prices, 92 | constraints0=constraints0, 93 | portfolio_objective=PortfolioObjective.MIN_VARIANCE, 94 | time_period=time_period, 95 | inclusion_indicators=inclusion_indicators, 96 | covar_estimator=covar_estimator) 97 | portfolio_data = qis.backtest_model_portfolio(prices=time_period.locate(prices), 98 | weights=weights, 99 | ticker=f"squeeze={squeeze_factor: 0.3f}", 100 | funding_rate=None, 101 | weight_implementation_lag=1, 102 | rebalancing_costs=0.0030) 103 | portfolio_data.set_group_data(group_data=group_data) 104 | portfolio_datas.append(portfolio_data) 105 | return portfolio_datas 106 | 107 | 108 | class UnitTests(Enum): 109 | CREATE_UNIVERSE_DATA = 1 110 | CROSS_BACKTEST = 2 111 | 112 | 113 | def run_unit_test(unit_test: UnitTests): 114 | 115 | import quant_strats.local_path as lp 116 | 117 | if unit_test == UnitTests.CREATE_UNIVERSE_DATA: 118 | create_sp500_universe() 119 | 120 | elif unit_test == UnitTests.CROSS_BACKTEST: 121 | 122 | # time_period = qis.TimePeriod('31Dec2010', '31Jan2024', tz='UTC') 123 | time_period = qis.TimePeriod('31Dec2010', '31Jan2024') 124 | # define squeeze_factors 125 | squeeze_factors = [0.0, 0.25, 0.5] 126 | # squeeze_factors = [0.0, 0.125, 0.250, 0.375, 0.5, 0.7, 0.9] 127 | 128 | portfolio_datas = run_cross_backtest(time_period=time_period, 129 | squeeze_factors=squeeze_factors) 130 | 131 | # run cross portfolio report 132 | benchmark_prices = yf.download('SPY', start=None, end=None, ignore_tz=True)['Close'].asfreq('B').ffill() 133 | multi_portfolio_data = qis.MultiPortfolioData(portfolio_datas=portfolio_datas, 134 | benchmark_prices=benchmark_prices) 135 | 136 | figs = qis.generate_multi_portfolio_factsheet(multi_portfolio_data=multi_portfolio_data, 137 | time_period=time_period, 138 | add_benchmarks_to_navs=True, 139 | add_strategy_factsheets=False, 140 | **qis.fetch_default_report_kwargs(time_period=time_period)) 141 | 142 | # save report to pdf and png 143 | qis.save_figs_to_pdf(figs=figs, 144 | file_name=f"sp500_squeeze_portfolio_factsheet", 145 | orientation='landscape', 146 | local_path=lp.get_output_path()) 147 | 148 | 149 | if __name__ == '__main__': 150 | 151 | unit_test = UnitTests.CROSS_BACKTEST 152 | 153 | is_run_all_tests = False 154 | if is_run_all_tests: 155 | for unit_test in UnitTests: 156 | run_unit_test(unit_test=unit_test) 157 | else: 158 | run_unit_test(unit_test=unit_test) 159 | 160 | -------------------------------------------------------------------------------- /optimalportfolios/examples/universe.py: -------------------------------------------------------------------------------- 1 | """ 2 | fetch an universe of bond etfs for testing optimisations 3 | """ 4 | import numpy as np 5 | import pandas as pd 6 | import matplotlib.pyplot as plt 7 | import seaborn as sns 8 | import qis as qis 9 | import yfinance as yf 10 | from typing import Tuple 11 | from enum import Enum 12 | 13 | 14 | def fetch_benchmark_universe_data() -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.Series, pd.Series, pd.DataFrame]: 15 | """ 16 | fetch a universe of etfs 17 | define custom universe with asset class grouping 18 | 5 asset groups with 3 etfs in each 19 | """ 20 | universe_data = dict(SPY='Equities', 21 | QQQ='Equities', 22 | EEM='Equities', 23 | TLT='Bonds', 24 | IEF='Bonds', 25 | TIP='Bonds', 26 | IGSB='IG', 27 | LQD='IG', 28 | EMB='IG', 29 | HYG='HighYield', 30 | SHYG='HighYield', 31 | FALN='HighYield', 32 | GLD='Commodts', 33 | GSG='Commodts', 34 | COMT='Commodts') 35 | group_data = pd.Series(universe_data) # for portfolio reporting 36 | equal_weight = 1.0 / len(universe_data.keys()) 37 | benchmark_weights = {x: equal_weight for x in universe_data.keys()} 38 | 39 | # asset class loadings 40 | ac_loadings = qis.set_group_loadings(group_data=group_data) 41 | 42 | tickers = list(universe_data.keys()) 43 | benchmark_weights = pd.Series(benchmark_weights) 44 | prices = yf.download(tickers=tickers, start=None, end=None, ignore_tz=True)['Close'][tickers] 45 | prices = prices.asfreq('B').ffill() 46 | # for group lass 47 | ac_benchmark_prices = prices[['SPY', 'TLT', 'LQD', 'HYG', 'GSG']].rename(dict(SPY='Equities', TLT='Bonds', IG='LQD', HYG='HighYield', GLD='Commodts')) 48 | 49 | # select asset class benchmarks from universe 50 | benchmark_prices = prices[['SPY', 'TLT']] 51 | 52 | return prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices 53 | 54 | 55 | class UnitTests(Enum): 56 | ILLUSTRATE_INPUT_DATA = 1 57 | 58 | 59 | def run_unit_test(unit_test: UnitTests): 60 | 61 | prices, benchmark_prices, ac_loadings, benchmark_weights, group_data, ac_benchmark_prices = fetch_benchmark_universe_data() 62 | 63 | if unit_test == UnitTests.ILLUSTRATE_INPUT_DATA: 64 | with sns.axes_style('darkgrid'): 65 | fig, axs = plt.subplots(2, 1, figsize=(14, 12), constrained_layout=True) 66 | qis.plot_prices_with_dd(prices=prices, axs=axs) 67 | 68 | plt.show() 69 | 70 | 71 | if __name__ == '__main__': 72 | 73 | unit_test = UnitTests.ILLUSTRATE_INPUT_DATA 74 | 75 | is_run_all_tests = False 76 | if is_run_all_tests: 77 | for unit_test in UnitTests: 78 | run_unit_test(unit_test=unit_test) 79 | else: 80 | run_unit_test(unit_test=unit_test) 81 | -------------------------------------------------------------------------------- /optimalportfolios/lasso/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from optimalportfolios.lasso. lasso_model_estimator import LassoModelType, LassoModel -------------------------------------------------------------------------------- /optimalportfolios/local_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | get local path using setting.yaml 3 | setting.yaml is untracked file with PC specific paths 4 | """ 5 | import yaml 6 | from pathlib import Path 7 | 8 | 9 | def get_resource_path() -> str: 10 | """ 11 | read path specs in settings.yaml 12 | """ 13 | full_file_path = Path(__file__).parent.joinpath('settings.yaml') 14 | with open(full_file_path) as settings: 15 | settings_data = yaml.load(settings, Loader=yaml.Loader) 16 | return settings_data['RESOURCE_PATH'] 17 | 18 | 19 | def get_output_path() -> str: 20 | """ 21 | read path specs in settings.yaml 22 | """ 23 | full_file_path = Path(__file__).parent.joinpath('settings.yaml') 24 | with open(full_file_path) as settings: 25 | settings_data = yaml.load(settings, Loader=yaml.Loader) 26 | return settings_data['OUTPUT_PATH'] 27 | 28 | -------------------------------------------------------------------------------- /optimalportfolios/optimization/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from optimalportfolios.optimization.constraints import (Constraints, 3 | GroupLowerUpperConstraints, 4 | GroupTrackingErrorConstraint, 5 | GroupTurnoverConstraint, 6 | merge_group_lower_upper_constraints) 7 | 8 | from optimalportfolios.optimization.wrapper_rolling_portfolios import (compute_rolling_optimal_weights, 9 | backtest_rolling_optimal_portfolio) 10 | 11 | from optimalportfolios.optimization.solvers.__init__ import * 12 | -------------------------------------------------------------------------------- /optimalportfolios/optimization/solvers/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from optimalportfolios.optimization.solvers.carra_mixure import (rolling_maximize_cara_mixture, 3 | wrapper_maximize_cara_mixture, 4 | opt_maximize_cara_mixture) 5 | 6 | from optimalportfolios.optimization.solvers.max_diversification import (rolling_maximise_diversification, 7 | wrapper_maximise_diversification, 8 | opt_maximise_diversification) 9 | 10 | from optimalportfolios.optimization.solvers.max_sharpe import (rolling_maximize_portfolio_sharpe, 11 | wrapper_maximize_portfolio_sharpe, 12 | cvx_maximize_portfolio_sharpe) 13 | 14 | from optimalportfolios.optimization.solvers.quadratic import (rolling_quadratic_optimisation, 15 | wrapper_quadratic_optimisation, 16 | cvx_quadratic_optimisation) 17 | 18 | from optimalportfolios.optimization.solvers.risk_budgeting import (rolling_risk_budgeting, 19 | wrapper_risk_budgeting, 20 | opt_risk_budgeting) 21 | 22 | from optimalportfolios.optimization.solvers.target_return import (rolling_maximise_alpha_with_target_return, 23 | wrapper_maximise_alpha_with_target_return, 24 | cvx_maximise_alpha_with_target_return) 25 | 26 | from optimalportfolios.optimization.solvers.tracking_error import (rolling_maximise_alpha_over_tre, 27 | wrapper_maximise_alpha_over_tre, 28 | cvx_maximise_alpha_over_tre) 29 | -------------------------------------------------------------------------------- /optimalportfolios/optimization/solvers/carra_mixure.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of carra utility 3 | """ 4 | 5 | import numpy as np 6 | import pandas as pd 7 | import qis as qis 8 | from scipy.optimize import minimize 9 | from typing import List, Optional 10 | from enum import Enum 11 | 12 | from optimalportfolios.utils.gaussian_mixture import fit_gaussian_mixture 13 | from optimalportfolios.utils.portfolio_funcs import (compute_portfolio_variance, compute_portfolio_risk_contributions) 14 | from optimalportfolios.optimization.constraints import (Constraints, total_weight_constraint, long_only_constraint) 15 | from optimalportfolios.covar_estimation.utils import squeeze_covariance_matrix 16 | 17 | 18 | def rolling_maximize_cara_mixture(prices: pd.DataFrame, 19 | constraints0: Constraints, 20 | time_period: qis.TimePeriod, # when we start building portfolios 21 | rebalancing_freq: str = 'QE', 22 | roll_window: int = 52*6, # number of returns in mixture estimation, default is 6y of weekly returns 23 | returns_freq: str = 'W-WED', # frequency for returns computing mixure distr 24 | carra: float = 0.5, # carra parameters 25 | n_components: int = 3, 26 | squeeze_factor: Optional[float] = None # for squeezing covar matrix 27 | ) -> pd.DataFrame: 28 | """ 29 | solve solvers mixture Carra portfolios 30 | estimation is applied for the whole period of prices 31 | """ 32 | returns = qis.to_returns(prices=prices, is_log_returns=True, drop_first=True, freq=returns_freq) 33 | # generate rebalancing dates on the returns index 34 | rebalancing_schedule = qis.generate_rebalancing_indicators(df=returns, freq=rebalancing_freq) 35 | 36 | _, scaler = qis.get_period_days(freq=returns_freq) 37 | 38 | tickers = prices.columns.to_list() 39 | weights = {} 40 | weights_0 = None 41 | for idx, (date, value) in enumerate(rebalancing_schedule.items()): 42 | if idx >= roll_window-1 and value: 43 | period = qis.TimePeriod(rebalancing_schedule.index[idx - roll_window+1], date) 44 | # drop assets with 45 | rets_ = period.locate(returns).dropna(axis=1, how='any') 46 | params = fit_gaussian_mixture(x=rets_.to_numpy(), n_components=n_components, scaler=scaler) 47 | constraints = constraints0.update_with_valid_tickers(valid_tickers=rets_.columns.to_list(), 48 | total_to_good_ratio=len(tickers)/len(rets_.columns), 49 | weights_0=weights_0) 50 | if squeeze_factor is not None and squeeze_factor > 0.0: 51 | params.covars = [squeeze_covariance_matrix(covars, squeeze_factor=squeeze_factor) for covars in params.covars] 52 | 53 | weights_ = wrapper_maximize_cara_mixture(means=params.means, 54 | covars=params.covars, 55 | probs=params.probs, 56 | constraints0=constraints, 57 | tickers=rets_.columns.to_list(), 58 | carra=carra) 59 | weights_0 = weights_ # update for next rebalancing 60 | weights[date] = weights_.reindex(index=tickers).fillna(0.0) 61 | weights = pd.DataFrame.from_dict(weights, orient='index', columns=prices.columns) 62 | if time_period is not None: 63 | weights = time_period.locate(weights) 64 | 65 | return weights 66 | 67 | 68 | def wrapper_maximize_cara_mixture(means: List[np.ndarray], 69 | covars: List[np.ndarray], 70 | probs: np.ndarray, 71 | constraints0: Constraints, 72 | tickers: List[str], 73 | carra: float = 0.5 74 | ) -> pd.Series: 75 | """ 76 | wrapper assumes means and covars are valid 77 | """ 78 | weights = opt_maximize_cara_mixture(means=means, 79 | covars=covars, 80 | probs=probs, 81 | constraints=constraints0, 82 | carra=carra) 83 | weights = pd.Series(weights, index=tickers) 84 | return weights 85 | 86 | 87 | def opt_maximize_cara_mixture(means: List[np.ndarray], 88 | covars: List[np.ndarray], 89 | probs: np.ndarray, 90 | constraints: Constraints, 91 | carra: float = 0.5, 92 | verbose: bool = False 93 | ) -> np.ndarray: 94 | 95 | # set up problem 96 | n = covars[0].shape[0] 97 | if Constraints.weights_0 is not None: 98 | x0 = Constraints.weights_0.to_numpy() 99 | else: 100 | x0 = np.ones(n) / n 101 | 102 | constraints_ = constraints.set_scipy_constraints() # covar is not used for this method 103 | res = minimize(carra_objective_mixture, x0, args=[means, covars, probs, carra], method='SLSQP', 104 | constraints=constraints_, 105 | options={'disp': verbose, 'ftol': 1e-12}) 106 | optimal_weights = res.x 107 | 108 | if optimal_weights is None: 109 | # raise ValueError(f"not solved") 110 | print(f"not solved") 111 | if constraints.weights_0 is not None: 112 | optimal_weights = constraints.weights_0 113 | print(f"using weights_0") 114 | else: 115 | optimal_weights = np.zeros(n) 116 | print(f"using zeroweights") 117 | 118 | return optimal_weights 119 | 120 | 121 | def opt_maximize_cara(means: np.ndarray, 122 | covar: np.ndarray, 123 | carra: float = 0.5, 124 | min_weights: np.ndarray = None, 125 | max_weights: np.ndarray = None, 126 | disp: bool = False, 127 | is_exp: bool = False, 128 | is_print_log: bool = False 129 | ) -> np.ndarray: 130 | n = covar.shape[0] 131 | x0 = np.ones(n) / n 132 | cons = [{'type': 'ineq', 'fun': long_only_constraint}, 133 | {'type': 'eq', 'fun': total_weight_constraint}] 134 | if min_weights is not None: 135 | cons.append({'type': 'ineq', 'fun': lambda x: x - min_weights}) 136 | if max_weights is not None: 137 | cons.append({'type': 'ineq', 'fun': lambda x: max_weights - x}) 138 | 139 | if is_exp: 140 | func = carra_objective_exp 141 | else: 142 | func = carra_objective 143 | res = minimize(func, x0, args=[means, covar, carra], method='SLSQP', constraints=cons, 144 | options={'disp': disp, 'ftol': 1e-16}) 145 | w_rb = res.x 146 | 147 | if is_print_log: 148 | print(f'return_p = {w_rb@means}, ' 149 | f'sigma_p = {np.sqrt(compute_portfolio_variance(w_rb, covar))}, weights: {w_rb}, ' 150 | f'risk contrib.s: {compute_portfolio_risk_contributions(w_rb, covar).T} ' 151 | f'sum of weights: {sum(w_rb)}') 152 | return w_rb 153 | 154 | 155 | def carra_objective(w: np.ndarray, pars: List[np.ndarray]) -> float: 156 | means, covar, carra = pars[0], pars[1], pars[2] 157 | v = means.T @ w - 0.5*carra*w.T @ covar @ w 158 | return -v 159 | 160 | 161 | def carra_objective_exp(w: np.ndarray, pars: List[np.ndarray]) -> float: 162 | means, covar, carra = pars[0], pars[1], pars[2] 163 | v = np.exp(-carra*means.T @ w + 0.5*carra*carra*w.T @ covar @ w) 164 | return v 165 | 166 | 167 | def carra_objective_mixture(w: np.ndarray, pars: List[np.ndarray]) -> float: 168 | means, covars, probs, carra = pars[0], pars[1], pars[2], pars[3] 169 | v = 0.0 170 | for idx, prob in enumerate(probs): 171 | v = v + prob*np.exp(-carra*means[idx].T @ w + 0.5*carra*carra*w.T @ covars[idx] @ w) 172 | return v 173 | 174 | 175 | class UnitTests(Enum): 176 | CARA = 1 177 | CARA_MIX = 2 178 | 179 | 180 | def run_unit_test(unit_test: UnitTests): 181 | 182 | if unit_test == UnitTests.CARA: 183 | means = np.array([0.3, 0.1]) 184 | covar = np.array([[0.2 ** 2, 0.01], 185 | [0.01, 0.1 ** 2]]) 186 | w_rb = opt_maximize_cara(means=means, covar=covar, carra=10, is_exp=False, disp=True) 187 | w_rb = opt_maximize_cara(means=means, covar=covar, carra=10, is_exp=True, disp=True) 188 | 189 | elif unit_test == UnitTests.CARA_MIX: 190 | means = [np.array([0.05, -0.1]), np.array([0.05, 2.0])] 191 | covars = [np.array([[0.2 ** 2, 0.01], 192 | [0.01, 0.2 ** 2]]), 193 | np.array([[0.2 ** 2, 0.01], 194 | [0.01, 0.2 ** 2]]) 195 | ] 196 | probs = np.array([0.95, 0.05]) 197 | optimal_weights = opt_maximize_cara_mixture(means=means, covars=covars, probs=probs, 198 | constraints=Constraints(), 199 | carra=20.0, verbose=True) 200 | print(optimal_weights) 201 | 202 | 203 | if __name__ == '__main__': 204 | 205 | unit_test = UnitTests.CARA_MIX 206 | 207 | is_run_all_tests = False 208 | if is_run_all_tests: 209 | for unit_test in UnitTests: 210 | run_unit_test(unit_test=unit_test) 211 | else: 212 | run_unit_test(unit_test=unit_test) 213 | 214 | -------------------------------------------------------------------------------- /optimalportfolios/optimization/solvers/max_diversification.py: -------------------------------------------------------------------------------- 1 | """ 2 | implementation of maximum diversification objective 3 | """ 4 | # packages 5 | import numpy as np 6 | import pandas as pd 7 | import qis as qis 8 | from scipy.optimize import minimize 9 | from typing import List, Dict 10 | 11 | # optimalportfolios 12 | from optimalportfolios.utils.portfolio_funcs import calculate_diversification_ratio 13 | from optimalportfolios.utils.filter_nans import filter_covar_and_vectors_for_nans 14 | from optimalportfolios.optimization.constraints import Constraints 15 | from optimalportfolios.covar_estimation.covar_estimator import CovarEstimator 16 | 17 | 18 | def rolling_maximise_diversification(prices: pd.DataFrame, 19 | constraints0: Constraints, 20 | time_period: qis.TimePeriod, # when we start building portfolios 21 | covar_dict: Dict[pd.Timestamp, pd.DataFrame] = None, # can be precomputed 22 | covar_estimator: CovarEstimator = CovarEstimator() # default EWMA estimator 23 | ) -> pd.DataFrame: 24 | """ 25 | compute rolling maximum diversification portfolios 26 | covar_dict: Dict[timestamp, covar matrix] can be precomputed 27 | portolio is rebalances at covar_dict.keys() 28 | """ 29 | 30 | if covar_dict is None: # use default ewm covar with covar_estimator 31 | covars = covar_estimator.fit_rolling_covars(prices=prices, time_period=time_period) 32 | covar_dict = covars.y_covars 33 | 34 | weights = {} 35 | weights_0 = None 36 | for date, pd_covar in covar_dict.items(): 37 | weights_ = wrapper_maximise_diversification(pd_covar=pd_covar, 38 | constraints0=constraints0, 39 | weights_0=weights_0) 40 | weights_0 = weights_ # update for next rebalancing 41 | weights[date] = weights_ 42 | 43 | weights = pd.DataFrame.from_dict(weights, orient='index') 44 | weights = weights.reindex(columns=prices.columns.to_list()) 45 | return weights 46 | 47 | 48 | def wrapper_maximise_diversification(pd_covar: pd.DataFrame, 49 | constraints0: Constraints, 50 | weights_0: pd.Series = None 51 | ) -> pd.Series: 52 | """ 53 | create wrapper accounting for nans or zeros in covar matrix 54 | assets in columns/rows of covar must correspond to alphas.index 55 | """ 56 | # filter out assets with zero variance or nans 57 | vectors = None 58 | clean_covar, good_vectors = filter_covar_and_vectors_for_nans(pd_covar=pd_covar, vectors=vectors) 59 | 60 | constraints = constraints0.update_with_valid_tickers(valid_tickers=clean_covar.columns.to_list(), 61 | total_to_good_ratio=len(pd_covar.columns) / len(clean_covar.columns), 62 | weights_0=weights_0) 63 | 64 | weights = opt_maximise_diversification(covar=clean_covar.to_numpy(), 65 | constraints=constraints) 66 | weights = pd.Series(weights, index=clean_covar.columns) 67 | weights = weights.reindex(index=pd_covar.columns).fillna(0.0) # align with tickers 68 | return weights 69 | 70 | 71 | def opt_maximise_diversification(covar: np.ndarray, 72 | constraints: Constraints, 73 | verbose: bool = False 74 | ) -> np.ndarray: 75 | n = covar.shape[0] 76 | x0 = np.ones(n) / n 77 | 78 | constraints_ = constraints.set_scipy_constraints(covar=covar) 79 | res = minimize(max_diversification_objective, x0, args=[covar], method='SLSQP', 80 | constraints=constraints_, 81 | options={'disp': verbose, 'ftol': 1e-18, 'maxiter': 200}) 82 | optimal_weights = res.x 83 | if optimal_weights is None: 84 | # raise ValueError(f"not solved") 85 | print(f"not solved") 86 | if constraints.weights_0 is not None: 87 | optimal_weights = constraints.weights_0 88 | print(f"using weights_0") 89 | else: 90 | optimal_weights = np.zeros(n) 91 | print(f"using zeroweights") 92 | 93 | else: 94 | if constraints.is_long_only: 95 | optimal_weights = np.where(optimal_weights > 0.0, optimal_weights, 0.0) 96 | 97 | return optimal_weights 98 | 99 | 100 | def max_diversification_objective(w: np.ndarray, pars: List[np.ndarray]) -> float: 101 | covar = pars[0] 102 | return -calculate_diversification_ratio(w=w, covar=covar) 103 | -------------------------------------------------------------------------------- /optimalportfolios/optimization/solvers/max_sharpe.py: -------------------------------------------------------------------------------- 1 | """ 2 | implementation of maximum sharpe ratio portfolios 3 | """ 4 | import numpy as np 5 | import pandas as pd 6 | import cvxpy as cvx 7 | import qis as qis 8 | from typing import Tuple, List, Optional 9 | from enum import Enum 10 | from qis import TimePeriod 11 | 12 | from optimalportfolios.utils.filter_nans import filter_covar_and_vectors_for_nans 13 | from optimalportfolios.optimization.constraints import Constraints 14 | from optimalportfolios.covar_estimation.utils import squeeze_covariance_matrix 15 | 16 | 17 | def rolling_maximize_portfolio_sharpe(prices: pd.DataFrame, 18 | constraints0: Constraints, 19 | time_period: qis.TimePeriod, # when we start building portfolios 20 | returns_freq: str = 'W-WED', 21 | rebalancing_freq: str = 'QE', 22 | span: int = 52, # 1y 23 | roll_window: int = 20, # defined on number of periods in rebalancing_freq 24 | solver: str = 'ECOS_BB', 25 | squeeze_factor: Optional[float] = None, # for squeezing covar matrix 26 | print_inputs: bool = False 27 | ) -> pd.DataFrame: 28 | """ 29 | maximise portfolio alpha subject to constraint on tracking tracking error 30 | """ 31 | means, covars = estimate_rolling_means_covar(prices=prices, 32 | returns_freq=returns_freq, 33 | rebalancing_freq=rebalancing_freq, 34 | roll_window=roll_window, 35 | annualize=True, 36 | span=span) 37 | 38 | tickers = prices.columns.to_list() 39 | weights = {} 40 | weights_0 = None 41 | for date, covar in zip(means.index, covars): 42 | if date >= time_period.start: 43 | pd_covar = pd.DataFrame(covar, index=tickers, columns=tickers) 44 | # call optimiser 45 | if print_inputs: 46 | print(f"date={date}") 47 | print(f"pd_covar=\n{pd_covar}") 48 | 49 | weights_ = wrapper_maximize_portfolio_sharpe(pd_covar=pd_covar, 50 | means=means.loc[date, :], 51 | constraints0=constraints0, 52 | weights_0=weights_0, 53 | squeeze_factor=squeeze_factor, 54 | solver=solver) 55 | 56 | weights_0 = weights_ # update for next rebalancing 57 | weights[date] = weights_ 58 | 59 | weights = pd.DataFrame.from_dict(weights, orient='index') 60 | weights = weights.reindex(columns=tickers).fillna(0.0) # align with tickers 61 | return weights 62 | 63 | 64 | def wrapper_maximize_portfolio_sharpe(pd_covar: pd.DataFrame, 65 | means: pd.Series, 66 | constraints0: Constraints, 67 | weights_0: pd.Series = None, 68 | squeeze_factor: Optional[float] = None, # for squeezing covar matrix 69 | solver: str = 'ECOS_BB' 70 | ) -> pd.Series: 71 | """ 72 | create wrapper accounting for nans or zeros in covar matrix 73 | assets in columns/rows of covar must correspond to alphas.index 74 | """ 75 | # filter out assets with zero variance or nans 76 | vectors = dict(means=means) 77 | clean_covar, good_vectors = filter_covar_and_vectors_for_nans(pd_covar=pd_covar, vectors=vectors) 78 | 79 | if squeeze_factor is not None and squeeze_factor > 0.0: 80 | clean_covar = squeeze_covariance_matrix(clean_covar, squeeze_factor=squeeze_factor) 81 | 82 | constraints = constraints0.update_with_valid_tickers(valid_tickers=clean_covar.columns.to_list(), 83 | total_to_good_ratio=len(pd_covar.columns) / len(clean_covar.columns), 84 | weights_0=weights_0) 85 | 86 | weights = cvx_maximize_portfolio_sharpe(covar=clean_covar.to_numpy(), 87 | means=good_vectors['means'].to_numpy(), 88 | constraints=constraints, 89 | solver=solver) 90 | 91 | weights = pd.Series(weights, index=clean_covar.index) 92 | weights = weights.reindex(index=pd_covar.index).fillna(0.0) # align with tickers 93 | 94 | return weights 95 | 96 | 97 | def cvx_maximize_portfolio_sharpe(covar: np.ndarray, 98 | means: np.ndarray, 99 | constraints: Constraints, 100 | verbose: bool = False, 101 | solver: str = 'ECOS_BB' 102 | ) -> np.ndarray: 103 | """ 104 | max means^t*w / sqrt(w^t @ covar @ w) 105 | subject to 106 | 1. weight_min <= w <= weight_max 107 | """ 108 | # set up problem 109 | n = covar.shape[0] 110 | z = cvx.Variable(n+1) 111 | w = z[:n] 112 | k = z[n] 113 | objective = cvx.Minimize(cvx.quad_form(w, covar)) 114 | 115 | constraints_ = constraints.set_cvx_constraints(w=w, covar=covar, exposure_scaler=k) 116 | 117 | # add scaling constraints 118 | constraints_ += [means.T @ w == constraints.max_exposure] 119 | 120 | problem = cvx.Problem(objective, constraints_) 121 | problem.solve(verbose=verbose, solver=solver) 122 | 123 | optimal_weights = z.value 124 | 125 | if optimal_weights is not None: 126 | optimal_weights = optimal_weights[:n] / optimal_weights[n] # apply rescaling 127 | else: 128 | print(f"not solved") 129 | if constraints.weights_0 is not None: 130 | optimal_weights = constraints.weights_0.to_numpy() 131 | print(f"using weights_0") 132 | else: 133 | optimal_weights = np.zeros(n) 134 | print(f"using zeroweights") 135 | 136 | return optimal_weights 137 | 138 | 139 | def estimate_rolling_means_covar(prices: pd.DataFrame, 140 | returns_freq: str = 'W-WED', 141 | rebalancing_freq: str = 'QE', 142 | roll_window: int = 20, # defined on number of periods in rebalancing_freq 143 | span: int = 52, 144 | annualize: bool = True, 145 | is_regularize: bool = True, 146 | is_ewm_covar: bool = True 147 | ) -> Tuple[pd.DataFrame, List[np.ndarray]]: 148 | 149 | """ 150 | inputs for solvers portfolios 151 | """ 152 | returns = qis.to_returns(prices=prices, is_log_returns=True, drop_first=True, freq=returns_freq) 153 | # generate rebalancing dates on the returns index 154 | rebalancing_schedule = qis.generate_rebalancing_indicators(df=returns, freq=rebalancing_freq) 155 | 156 | if annualize: 157 | _, scaler = qis.get_period_days(freq=returns_freq) 158 | else: 159 | scaler = 1.0 160 | means = {} 161 | covars = [] 162 | covar0 = np.zeros((len(prices.columns), len(prices.columns))) 163 | for idx, (date, value) in enumerate(rebalancing_schedule.items()): 164 | if idx >= roll_window-1 and value: 165 | period = TimePeriod(rebalancing_schedule.index[idx - roll_window + 1], date) 166 | # period.print() 167 | rets_ = period.locate(returns).to_numpy() 168 | means[date] = scaler*pd.Series(np.nanmean(rets_, axis=0), index=prices.columns) 169 | if is_ewm_covar: 170 | covar = qis.compute_ewm_covar(a=rets_, span=span, covar0=covar0) 171 | covar0 = covar 172 | else: 173 | covar = qis.compute_masked_covar_corr(data=rets_, bias=True) 174 | 175 | if is_regularize: 176 | covar = qis.matrix_regularization(covar=covar, cut=1e-5) 177 | 178 | covars.append(scaler * covar) 179 | means = pd.DataFrame.from_dict(means, orient="index") 180 | return means, covars 181 | 182 | 183 | class UnitTests(Enum): 184 | ROLLING_MEANS_COVAR = 1 185 | SHARPE = 2 186 | 187 | 188 | def run_unit_test(unit_test: UnitTests): 189 | 190 | import seaborn as sns 191 | import matplotlib.pyplot as plt 192 | from optimalportfolios.test_data import load_test_data 193 | prices = load_test_data() 194 | prices = prices.loc['2000':, :] # need 5 years for max sharpe and max carra methods 195 | 196 | if unit_test == UnitTests.ROLLING_MEANS_COVAR: 197 | # prices = prices[['SPY', 'TLT']].dropna() 198 | 199 | means, covars = estimate_rolling_means_covar(prices=prices, rebalancing_freq='QE', roll_window=20) 200 | # = estimate_rolling_data(prices=prices, rebalancing_freq='ME', roll_window=60) 201 | 202 | vols = {} 203 | covs = {} 204 | for index, covar in zip(means.index, covars): 205 | vols[index] = pd.Series(np.sqrt(np.diag(covar))) 206 | covs[index] = pd.Series(np.extract(1 - np.eye(2), covar)) 207 | vols = pd.DataFrame.from_dict(vols, orient='index') 208 | covs = pd.DataFrame.from_dict(covs, orient='index') 209 | print(vols) 210 | print(covs) 211 | 212 | with sns.axes_style("darkgrid"): 213 | fig, axs = plt.subplots(3, 1, figsize=(7, 12)) 214 | qis.plot_time_series(df=means, 215 | var_format='{:.0%}', 216 | trend_line=qis.TrendLine.AVERAGE, 217 | legend_stats=qis.LegendStats.FIRST_AVG_LAST, 218 | ax=axs[0]) 219 | qis.plot_time_series(df=vols, 220 | var_format='{:.0%}', 221 | trend_line=qis.TrendLine.AVERAGE, 222 | legend_stats=qis.LegendStats.FIRST_AVG_LAST, 223 | ax=axs[1]) 224 | qis.plot_time_series(df=covs, 225 | var_format='{:.0%}', 226 | trend_line=qis.TrendLine.AVERAGE, 227 | legend_stats=qis.LegendStats.FIRST_AVG_LAST, 228 | ax=axs[2]) 229 | 230 | plt.show() 231 | 232 | 233 | if __name__ == '__main__': 234 | 235 | unit_test = UnitTests.ROLLING_MEANS_COVAR 236 | 237 | is_run_all_tests = False 238 | if is_run_all_tests: 239 | for unit_test in UnitTests: 240 | run_unit_test(unit_test=unit_test) 241 | else: 242 | run_unit_test(unit_test=unit_test) 243 | -------------------------------------------------------------------------------- /optimalportfolios/optimization/solvers/target_return.py: -------------------------------------------------------------------------------- 1 | """ 2 | optimise alpha with targeting return 3 | """ 4 | import numpy as np 5 | import pandas as pd 6 | import cvxpy as cvx 7 | import qis as qis 8 | from typing import Optional, Dict 9 | 10 | from optimalportfolios import filter_covar_and_vectors_for_nans 11 | from optimalportfolios.optimization.constraints import Constraints 12 | from optimalportfolios.covar_estimation.rolling_covar import estimate_rolling_ewma_covar 13 | 14 | 15 | def rolling_maximise_alpha_with_target_return(prices: pd.DataFrame, 16 | alphas: pd.DataFrame, 17 | yields: pd.DataFrame, 18 | target_returns: pd.Series, 19 | constraints0: Constraints, 20 | time_period: qis.TimePeriod, # when we start building portfolios 21 | covar_dict: Dict[pd.Timestamp, pd.DataFrame] = None, # can be precomputed 22 | returns_freq: str = 'W-WED', 23 | rebalancing_freq: str = 'QE', 24 | span: int = 52, # 1y 25 | squeeze_factor: Optional[float] = None, # for squeezing covar matrix 26 | solver: str = 'ECOS_BB', 27 | verbose: bool = False 28 | ) -> pd.DataFrame: 29 | """ 30 | maximise portfolio alpha subject to constraint on tracking tracking error 31 | """ 32 | if covar_dict is None: # use default ewm covar 33 | covar_dict = estimate_rolling_ewma_covar(prices=prices, 34 | time_period=time_period, 35 | returns_freq=returns_freq, 36 | rebalancing_freq=rebalancing_freq, 37 | span=span, 38 | squeeze_factor=squeeze_factor) 39 | 40 | # create rebalancing schedule: it must much idx in covar_tensor_txy using returns.index 41 | rebalancing_schedule = list(covar_dict.keys()) 42 | alphas = alphas.reindex(index=rebalancing_schedule, method='ffill') 43 | yields = yields.reindex(index=rebalancing_schedule, method='ffill') 44 | target_returns = target_returns.reindex(index=rebalancing_schedule, method='ffill') 45 | 46 | weights = {} 47 | weights_0 = None 48 | for date, pd_covar in covar_dict.items(): 49 | 50 | if verbose: 51 | print(f"date={date}") 52 | print(f"pd_covar=\n{pd_covar}") 53 | print(f"alphas=\n{alphas.loc[date, :]}") 54 | print(f"yields=\n{yields.loc[date, :]}") 55 | print(f"target_return=\n{target_returns[date]}") 56 | 57 | # call optimiser 58 | weights_ = wrapper_maximise_alpha_with_target_return(pd_covar=pd_covar, 59 | alphas=alphas.loc[date, :], 60 | yields=yields.loc[date, :], 61 | target_return=target_returns[date], 62 | constraints0=constraints0, 63 | weights_0=weights_0, 64 | solver=solver) 65 | 66 | weights_0 = weights_ # update for next rebalancing 67 | weights[date] = weights_ 68 | 69 | weights = pd.DataFrame.from_dict(weights, orient='index') 70 | weights = weights.reindex(columns=prices.columns).fillna(0.0) # align with tickers 71 | return weights 72 | 73 | 74 | def wrapper_maximise_alpha_with_target_return(pd_covar: pd.DataFrame, 75 | alphas: pd.Series, 76 | yields: pd.Series, 77 | target_return: float, 78 | constraints0: Constraints, 79 | weights_0: pd.Series = None, 80 | solver: str = 'ECOS_BB' 81 | ) -> pd.Series: 82 | """ 83 | create wrapper accounting for nans or zeros in covar matrix 84 | assets in columns/rows of covar must correspond to alphas.index 85 | """ 86 | # filter out assets with zero variance or nans 87 | vectors = dict(alphas=alphas) 88 | clean_covar, good_vectors = filter_covar_and_vectors_for_nans(pd_covar=pd_covar, vectors=vectors) 89 | 90 | constraints = constraints0.update_with_valid_tickers(valid_tickers=clean_covar.columns.to_list(), 91 | total_to_good_ratio=len(pd_covar.columns) / len(clean_covar.columns), 92 | weights_0=weights_0, 93 | asset_returns=yields, 94 | target_return=target_return) 95 | 96 | weights = cvx_maximise_alpha_with_target_return(covar=clean_covar.to_numpy(), 97 | alphas=good_vectors['alphas'].to_numpy(), 98 | constraints=constraints, 99 | solver=solver) 100 | 101 | weights = pd.Series(weights, index=clean_covar.index) 102 | weights = weights.reindex(index=pd_covar.index).fillna(0.0) # align with tickers 103 | 104 | return weights 105 | 106 | 107 | def cvx_maximise_alpha_with_target_return(covar: np.ndarray, 108 | alphas: np.ndarray, 109 | constraints: Constraints, 110 | verbose: bool = False, 111 | solver: str = 'ECOS_BB' 112 | ) -> np.ndarray: 113 | """ 114 | numpy level one step solution of problem 115 | max alpha @ w 116 | such that 117 | yields @ w = target return 118 | sum(w) = 1 # exposure constraint 119 | w >= 0 # long only constraint 120 | w.T @ Sigma @ w <= vol_constraint 121 | """ 122 | # set up problem 123 | n = covar.shape[0] 124 | if constraints.is_long_only: 125 | nonneg = True 126 | else: 127 | nonneg = False 128 | w = cvx.Variable(n, nonneg=nonneg) 129 | # covar = cvx.psd_wrap(covar) 130 | 131 | # set solver 132 | objective_fun = alphas.T @ w 133 | objective = cvx.Maximize(objective_fun) 134 | constraints_ = constraints.set_cvx_constraints(w=w, covar=covar) 135 | problem = cvx.Problem(objective, constraints_) 136 | problem.solve(verbose=verbose, solver=solver) 137 | 138 | optimal_weights = w.value 139 | if optimal_weights is None: 140 | # raise ValueError(f"not solved") 141 | print(f"not solved") 142 | if constraints.weights_0 is not None: 143 | optimal_weights = constraints.weights_0.to_numpy() 144 | print(f"using weights_0") 145 | else: 146 | optimal_weights = np.zeros(n) 147 | print(f"using zeroweights") 148 | 149 | return optimal_weights 150 | -------------------------------------------------------------------------------- /optimalportfolios/optimization/solvers/tracking_error.py: -------------------------------------------------------------------------------- 1 | """ 2 | optimise alpha over tracking error 3 | """ 4 | import numpy as np 5 | import pandas as pd 6 | import cvxpy as cvx 7 | import qis as qis 8 | from typing import Optional, Union, Dict 9 | 10 | from optimalportfolios import filter_covar_and_vectors_for_nans, compute_portfolio_risk_contribution_outputs 11 | from optimalportfolios.optimization.constraints import Constraints 12 | from optimalportfolios.covar_estimation.covar_estimator import CovarEstimator 13 | 14 | 15 | def rolling_maximise_alpha_over_tre(prices: pd.DataFrame, 16 | alphas: pd.DataFrame, 17 | constraints0: Constraints, 18 | benchmark_weights: Union[pd.Series, pd.DataFrame], 19 | time_period: qis.TimePeriod, # when we start building portfolios 20 | covar_estimator: CovarEstimator = CovarEstimator(), # default covar estimator 21 | covar_dict: Dict[pd.Timestamp, pd.DataFrame] = None, 22 | rebalancing_indicators: pd.DataFrame = None, 23 | apply_total_to_good_ratio: bool = True, 24 | solver: str = 'ECOS_BB' 25 | ) -> pd.DataFrame: 26 | """ 27 | maximise portfolio alpha subject to constraint on tracking error 28 | """ 29 | # estimate covar at rebalancing schedule 30 | if covar_dict is None: # use default ewm covar with covar_estimator 31 | covar_dict = covar_estimator.fit_rolling_covars(prices=prices, time_period=time_period).y_covars 32 | 33 | rebalancing_dates = list(covar_dict.keys()) 34 | alphas = alphas.reindex(index=rebalancing_dates, method='ffill').fillna(0.0) 35 | 36 | weights = {} 37 | # extend benchmark weights 38 | if isinstance(benchmark_weights, pd.DataFrame): 39 | benchmark_weights = benchmark_weights.reindex(index=rebalancing_dates, method='ffill').fillna(0.0) 40 | else: # for series do transformation 41 | benchmark_weights = benchmark_weights.to_frame( 42 | name=rebalancing_dates[0]).T.reindex(index=rebalancing_dates, method='ffill').fillna(0.0) 43 | 44 | if rebalancing_indicators is not None: # need to reindex at covar_dict index: by default no rebalancing 45 | rebalancing_indicators = rebalancing_indicators.reindex(index=rebalancing_dates).fillna(0.0) 46 | 47 | weights_0 = None # it will relax turnover constraint for the first rebalancing 48 | for date, pd_covar in covar_dict.items(): 49 | if rebalancing_indicators is not None: 50 | rebalancing_indicators_t = rebalancing_indicators.loc[date, :] 51 | else: 52 | rebalancing_indicators_t = None 53 | weights_ = wrapper_maximise_alpha_over_tre(pd_covar=pd_covar, 54 | alphas=alphas.loc[date, :], 55 | benchmark_weights=benchmark_weights.loc[date, :], 56 | constraints0=constraints0, 57 | rebalancing_indicators=rebalancing_indicators_t, 58 | weights_0=weights_0, 59 | apply_total_to_good_ratio=apply_total_to_good_ratio, 60 | solver=solver) 61 | 62 | weights_0 = weights_ # update for next rebalancing 63 | weights[date] = weights_ 64 | 65 | weights = pd.DataFrame.from_dict(weights, orient='index') 66 | weights = weights.reindex(columns=prices.columns.to_list()) 67 | return weights 68 | 69 | 70 | def wrapper_maximise_alpha_over_tre(pd_covar: pd.DataFrame, 71 | alphas: pd.Series, 72 | benchmark_weights: pd.Series, 73 | constraints0: Constraints, 74 | weights_0: pd.Series = None, 75 | rebalancing_indicators: pd.Series = None, 76 | apply_total_to_good_ratio: bool = True, 77 | solver: str = 'ECOS_BB', 78 | detailed_output: bool = False, 79 | is_apply_tre_utility_objective: bool = False 80 | ) -> Union[pd.Series, pd.DataFrame]: 81 | """ 82 | create wrapper accounting for nans or zeros in covar matrix 83 | assets in columns/rows of covar must correspond to alphas.index 84 | """ 85 | # filter out assets with zero variance or nans 86 | vectors = dict(alphas=alphas) 87 | clean_covar, good_vectors = filter_covar_and_vectors_for_nans(pd_covar=pd_covar, vectors=vectors) 88 | if apply_total_to_good_ratio: 89 | total_to_good_ratio = len(pd_covar.columns) / len(clean_covar.columns) 90 | else: 91 | total_to_good_ratio = 1.0 92 | 93 | constraints = constraints0.update_with_valid_tickers(valid_tickers=clean_covar.columns.to_list(), 94 | total_to_good_ratio=total_to_good_ratio, 95 | weights_0=weights_0, 96 | benchmark_weights=benchmark_weights, 97 | rebalancing_indicators=rebalancing_indicators) 98 | 99 | if is_apply_tre_utility_objective: 100 | weights = cvx_maximise_alpha_with_tre_utility(covar=clean_covar.to_numpy(), 101 | alphas=good_vectors['alphas'].to_numpy(), 102 | constraints=constraints, 103 | solver=solver) 104 | else: 105 | weights = cvx_maximise_alpha_over_tre(covar=clean_covar.to_numpy(), 106 | alphas=good_vectors['alphas'].to_numpy(), 107 | constraints=constraints, 108 | solver=solver) 109 | 110 | weights = pd.Series(weights, index=clean_covar.index) 111 | weights = weights.reindex(index=pd_covar.index).fillna(0.0) # align with tickers 112 | 113 | if detailed_output: 114 | out = compute_portfolio_risk_contribution_outputs(weights=weights, clean_covar=clean_covar) 115 | else: 116 | out = weights 117 | return out 118 | 119 | 120 | def cvx_maximise_alpha_over_tre(covar: np.ndarray, 121 | alphas: np.ndarray, 122 | constraints: Constraints, 123 | solver: str = 'ECOS_BB', 124 | verbose: bool = False 125 | ) -> np.ndarray: 126 | """ 127 | numpy level solution of quadratic problem: 128 | max alpha@w 129 | such that 130 | (w-benchmark_weights) @ Sigma @ (w-benchmark_weights).t <= tracking_err_vol_constraint 131 | sum(abs(w-w_0)) <= turnover_constraint 132 | subject to linear constraints 133 | 1. weight_min <= w <= weight_max 134 | 2. sum(w) = 1 135 | 3. exposure_budget_eq[0]^t*w = exposure_budget_eq[1] 136 | here we assume that all assets are valid: Sigma is invertible 137 | """ 138 | n = covar.shape[0] 139 | if constraints.is_long_only: 140 | nonneg = True 141 | else: 142 | nonneg = False 143 | w = cvx.Variable(n, nonneg=nonneg) 144 | covar = cvx.psd_wrap(covar) 145 | 146 | # set solver 147 | benchmark_weights = constraints.benchmark_weights.to_numpy() 148 | objective_fun = alphas.T @ (w - benchmark_weights) 149 | objective = cvx.Maximize(objective_fun) 150 | constraints_ = constraints.set_cvx_constraints(w=w, covar=covar) 151 | 152 | problem = cvx.Problem(objective, constraints_) 153 | problem.solve(verbose=verbose, solver=solver) 154 | 155 | optimal_weights = w.value 156 | if optimal_weights is None: 157 | # raise ValueError(f"not solved") 158 | print(f"not solved") 159 | if constraints.weights_0 is not None: 160 | optimal_weights = constraints.weights_0.to_numpy() 161 | print(f"using weights_0") 162 | else: 163 | optimal_weights = np.zeros(n) 164 | print(f"using zeroweights") 165 | 166 | return optimal_weights 167 | 168 | 169 | def cvx_maximise_alpha_with_tre_utility(covar: np.ndarray, 170 | constraints: Constraints, 171 | alphas: Optional[np.ndarray] = None, 172 | tre_weight: Optional[float] = 0.00001, 173 | turnover_weight: Optional[float] = 0.001, 174 | solver: str = 'ECOS_BB', 175 | verbose: bool = False 176 | ) -> np.ndarray: 177 | """ 178 | numpy level solution of quadratic problem with utility weights: 179 | max { alpha@w - tre_weight * (w-benchmark_weights)@Sigma@(w-benchmark_weights).t - turnover_weight*sum(abs(w-w_0))} 180 | subject to linear constraints 181 | 1. weight_min <= w <= weight_max 182 | 2. sum(w) = 1 183 | 3. exposure_budget_eq[0]^t*w = exposure_budget_eq[1] 184 | here we assume that all assets are valid: Sigma is invertible 185 | """ 186 | n = covar.shape[0] 187 | if constraints.is_long_only: 188 | nonneg = True 189 | else: 190 | nonneg = False 191 | w = cvx.Variable(n, nonneg=nonneg) 192 | covar = cvx.psd_wrap(covar) 193 | 194 | constraints1 = constraints.copy() 195 | # set solver 196 | benchmark_weights = constraints.benchmark_weights.to_numpy() 197 | if alphas is not None: 198 | objective_fun = alphas.T @ (w - benchmark_weights) 199 | 200 | if tre_weight is not None: 201 | constraints1.tracking_err_vol_constraint = None # disable from constraints 202 | tracking_error_var = cvx.quad_form(w - benchmark_weights, covar) 203 | objective_fun += -1.0*tre_weight*tracking_error_var 204 | 205 | else: 206 | if tre_weight is None: 207 | raise ValueError(f"tre_weight must be given for tre without alphas") 208 | 209 | constraints1.tracking_err_vol_constraint = None # disable from constraints 210 | tracking_error_var = cvx.quad_form(w - benchmark_weights, covar) 211 | objective_fun = -1.0*tre_weight*tracking_error_var 212 | 213 | # add turover 214 | if turnover_weight is not None: 215 | constraints1.turnover_constraint = None # disable from constraints 216 | if constraints1.weights_0 is None: 217 | print(f"weights_0 must be given for turnover_constraint") 218 | else: 219 | objective_fun += -1.0*turnover_weight*cvx.norm(w - constraints1.weights_0, 1) 220 | 221 | objective = cvx.Maximize(objective_fun) 222 | constraints_ = constraints1.set_cvx_constraints(w=w, covar=covar) 223 | 224 | problem = cvx.Problem(objective, constraints_) 225 | problem.solve(verbose=verbose, solver=solver) 226 | 227 | optimal_weights = w.value 228 | if optimal_weights is None: 229 | # raise ValueError(f"not solved") 230 | print(f"not solved") 231 | if constraints.weights_0 is not None: 232 | optimal_weights = constraints.weights_0.to_numpy() 233 | print(f"using weights_0") 234 | else: 235 | optimal_weights = np.zeros(n) 236 | print(f"using zeroweights") 237 | 238 | return optimal_weights 239 | -------------------------------------------------------------------------------- /optimalportfolios/optimization/wrapper_rolling_portfolios.py: -------------------------------------------------------------------------------- 1 | """ 2 | linking engine to different optimisation routines 3 | """ 4 | # packages 5 | import pandas as pd 6 | import qis as qis 7 | from typing import Optional, Dict 8 | # optimalportfolios 9 | import optimalportfolios as opt 10 | from optimalportfolios.covar_estimation.covar_estimator import CovarEstimator, CovarEstimatorType 11 | from optimalportfolios.optimization.constraints import Constraints 12 | from optimalportfolios.config import PortfolioObjective 13 | 14 | 15 | def compute_rolling_optimal_weights(prices: pd.DataFrame, 16 | constraints0: Constraints, 17 | time_period: qis.TimePeriod, 18 | portfolio_objective: PortfolioObjective = PortfolioObjective.MAX_DIVERSIFICATION, 19 | covar_dict: Dict[pd.Timestamp, pd.DataFrame] = None, # can be precomputed 20 | covar_estimator: CovarEstimator = None, 21 | risk_budget: pd.Series = None, 22 | returns_freq: Optional[str] = 'W-WED', # returns freq 23 | rebalancing_freq: str = 'QE', # portfolio rebalancing 24 | span: int = 52, # ewma span for covariance matrix estimation 25 | roll_window: int = 20, # linked to returns at rebalancing_freq 26 | carra: float = 0.5, # carra parameters 27 | n_mixures: int = 3 28 | ) -> pd.DataFrame: 29 | """ 30 | wrapper function that links implemented optimisation solvers optimisation methods 31 | for portfolio_objective in config.PortfolioObjective 32 | covar_dict: Dict[timestamp, covar matrix] can be precomputed 33 | portolio is rebalances at covar_dict.keys() 34 | """ 35 | if covar_estimator is None: 36 | covar_estimator = CovarEstimator(returns_freqs=returns_freq, rebalancing_freq=rebalancing_freq, span=span, 37 | covar_estimator_type=CovarEstimatorType.EWMA) 38 | if portfolio_objective == PortfolioObjective.EQUAL_RISK_CONTRIBUTION: 39 | weights = opt.rolling_risk_budgeting(prices=prices, 40 | constraints0=constraints0, 41 | time_period=time_period, 42 | covar_dict=covar_dict, 43 | risk_budget=risk_budget, 44 | covar_estimator=covar_estimator) 45 | 46 | elif portfolio_objective == PortfolioObjective.MAX_DIVERSIFICATION: 47 | weights = opt.rolling_maximise_diversification(prices=prices, 48 | constraints0=constraints0, 49 | time_period=time_period, 50 | covar_dict=covar_dict, 51 | covar_estimator=covar_estimator) 52 | 53 | elif portfolio_objective in [PortfolioObjective.MIN_VARIANCE, PortfolioObjective.QUADRATIC_UTILITY]: 54 | weights = opt.rolling_quadratic_optimisation(prices=prices, 55 | constraints0=constraints0, 56 | portfolio_objective=portfolio_objective, 57 | time_period=time_period, 58 | covar_dict=covar_dict, 59 | covar_estimator=covar_estimator, 60 | carra=carra) 61 | 62 | elif portfolio_objective == PortfolioObjective.MAXIMUM_SHARPE_RATIO: 63 | weights = opt.rolling_maximize_portfolio_sharpe(prices=prices, 64 | constraints0=constraints0, 65 | time_period=time_period, 66 | returns_freq=returns_freq, 67 | rebalancing_freq=rebalancing_freq, 68 | span=span, 69 | roll_window=roll_window) 70 | 71 | elif portfolio_objective == PortfolioObjective.MAX_CARA_MIXTURE: 72 | weights = opt.rolling_maximize_cara_mixture(prices=prices, 73 | constraints0=constraints0, 74 | time_period=time_period, 75 | returns_freq=returns_freq, 76 | rebalancing_freq=rebalancing_freq, 77 | carra=carra, 78 | n_components=n_mixures, 79 | roll_window=roll_window) 80 | 81 | else: 82 | raise NotImplementedError(f"{portfolio_objective}") 83 | 84 | return weights 85 | 86 | 87 | def backtest_rolling_optimal_portfolio(prices: pd.DataFrame, 88 | constraints0: Constraints, 89 | time_period: qis.TimePeriod, # for computing weights 90 | covar_dict: Dict[pd.Timestamp, pd.DataFrame] = None, # can be precomputed 91 | perf_time_period: qis.TimePeriod = None, # for computing performance 92 | portfolio_objective: PortfolioObjective = PortfolioObjective.MAX_DIVERSIFICATION, 93 | returns_freq: Optional[str] = 'W-WED', # returns freq 94 | rebalancing_freq: str = 'QE', # portfolio rebalancing 95 | span: int = 52, # ewma span for covariance matrix estimation 96 | roll_window: int = 6*52, # linked to returns at rebalancing_freq: 6y of weekly returns 97 | carra: float = 0.5, # carra parameter 98 | n_mixures: int = 3, # for mixture carra utility 99 | ticker: str = None, 100 | rebalancing_costs: float = 0.0010, # 10 bp 101 | weight_implementation_lag: Optional[int] = None # = 1 for daily data 102 | ) -> qis.PortfolioData: 103 | """ 104 | compute solvers portfolio weights and return portfolio data 105 | weight_implementation_lag: Optional[int] = None # = 1 for daily data otherwise skip 106 | covar_dict: Dict[timestamp, covar matrix] can be precomputed 107 | portolio is rebalances at covar_dict.keys() 108 | """ 109 | weights = compute_rolling_optimal_weights(prices=prices, 110 | time_period=time_period, 111 | constraints0=constraints0, 112 | covar_dict=covar_dict, 113 | portfolio_objective=portfolio_objective, 114 | returns_freq=returns_freq, 115 | rebalancing_freq=rebalancing_freq, 116 | span=span, 117 | carra=carra, 118 | roll_window=roll_window, 119 | n_mixures=n_mixures) 120 | 121 | # make sure price exists for the first weight date: can happen when the first weight date falls on weekend 122 | if perf_time_period is not None: 123 | weights = perf_time_period.locate(weights) 124 | prices_ = qis.truncate_prior_to_start(df=prices, start=weights.index[0]) 125 | portfolio_out = qis.backtest_model_portfolio(prices=prices_, 126 | weights=weights, 127 | rebalancing_costs=rebalancing_costs, 128 | weight_implementation_lag=weight_implementation_lag, 129 | ticker=ticker) 130 | return portfolio_out 131 | -------------------------------------------------------------------------------- /optimalportfolios/reports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArturSepp/OptimalPortfolios/368e115bc3c9e8a8b698806b7302e2887210de6b/optimalportfolios/reports/__init__.py -------------------------------------------------------------------------------- /optimalportfolios/reports/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | set common configuration for different reports 3 | """ 4 | 5 | from qis import PerfStat, PerfParams, BenchmarkReturnsQuantileRegimeSpecs 6 | 7 | BENCHMARK_TABLE_COLUMNS2 = (PerfStat.PA_RETURN, 8 | PerfStat.VOL, 9 | PerfStat.SHARPE_EXCESS, 10 | PerfStat.MAX_DD, 11 | # PerfStat.MAX_DD_VOL, 12 | PerfStat.BEST, 13 | PerfStat.WORST, 14 | PerfStat.SKEWNESS, 15 | PerfStat.ALPHA_AN, 16 | PerfStat.BETA, 17 | PerfStat.R2) 18 | 19 | DATE_FORMAT = '%d%b%Y' 20 | FIG_SIZE1 = (14, 3) # one figure for whole page 21 | FIG_SIZE11 = (4.65, 2.35) # one figure for half page 22 | FIG_SIZE11_2 = (4.70, 0.95) 23 | FIG_SIZE11_2a = (4.70, 0.6) 24 | 25 | 26 | PERF_PARAMS = PerfParams(freq_vol='ME', freq_reg='ME', freq_drawdown='ME', alpha_an_factor=12) 27 | 28 | REGIME_PARAMS = BenchmarkReturnsQuantileRegimeSpecs(freq='ME') 29 | 30 | KWARGS = dict(fontsize=7, 31 | linewidth=0.5, 32 | digits_to_show=1, sharpe_digits=2, 33 | weight='normal', 34 | markersize=2, 35 | framealpha=0.8, 36 | date_format='%b-%y', 37 | trend_line_colors=['darkred'], 38 | trend_linewidth=2.0, 39 | x_date_freq='QE', 40 | short=True) 41 | 42 | # for py blocks 43 | margin_top = 0.0 44 | margin_bottom = 0.0 45 | line_height = 1.0 46 | font_family = 'Calibri' 47 | 48 | KWARGS_SUPTITLE = {'title_wrap': True, 'text_align': 'center', 'color': 'blue', 'font_size': "12px", 'font-weight': 'normal', 49 | 'title_level': 1, 'line_height': 0.7, 'inherit_cfg': False, 50 | 'margin_top': 0, 'margin_bottom': 0, 51 | 'font-family': 'sans-serif'} 52 | KWARGS_TITLE = {'title_wrap': True, 'text_align': 'left', 'color': 'blue', 'font_size': "12px", 53 | 'title_level': 2, 'line_height': line_height, 'inherit_cfg': False, 54 | 'margin_top': margin_top, 'margin_bottom': margin_bottom, 55 | 'font-family': font_family} 56 | KWARGS_DESC = {'title_wrap': True, 'text_align': 'left', 'font_size': "12px", 'font-weight': 'normal', 57 | 'title_level': 3, 'line_height': line_height, 'inherit_cfg': False, 58 | 'margin_top': margin_top, 'margin_bottom': margin_bottom, 59 | 'font-family': font_family} 60 | KWARGS_TEXT = {'title_wrap': True, 'text_align': 'left', 'font_size': "12px", 'font-weight': 'normal', 61 | 'title_level': 3, 'line_height': line_height, 'inherit_cfg': False, 62 | 'margin_top': margin_top, 'margin_bottom': margin_bottom, 63 | 'font-family': font_family} 64 | KWARGS_FIG = {'title_wrap': True, 'text_align': 'left', 'font_size': "12px", 65 | 'title_level': 3, 'line_height': line_height, 'inherit_cfg': False, 66 | 'margin_top': margin_top, 'margin_bottom': margin_bottom, 67 | 'font-family': font_family} 68 | KWARGS_FOOTNOTE = {'title_wrap': True, 'text_align': 'left', 'font_size': "12px", 'font-weight': 'normal', 69 | 'title_level': 8, 'line_height': line_height, 'inherit_cfg': False, 70 | 'margin_top': 0, 'margin_bottom': 0, 71 | 'font-family': font_family} 72 | 73 | RA_TABLE_FOOTNOTE = (u"\u002A" + f"Vol (annualized volatility) and Skew (Skeweness) are computed using daily returns, " 74 | f"Sharpe is computed assuming zero risk-free rate, " 75 | f"Max DD is maximum drawdown, " 76 | f"Best and Worst are the highest and lowest daily returns, " 77 | f"Alpha (annualized daily alpha), Beta, R2 (R squared) are estimated using regression " 78 | f"of daily returns explained by underlying coin") 79 | -------------------------------------------------------------------------------- /optimalportfolios/test_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | implement test data for optimisations 3 | use update and save data for speed-up of test cases 4 | """ 5 | 6 | # imports 7 | import pandas as pd 8 | import yfinance as yf 9 | import qis 10 | import optimalportfolios.local_path as local_path 11 | from enum import Enum 12 | 13 | FILE_NAME = 'test_prices' 14 | 15 | UNIVERSE_DATA = dict(SPY='Equities', 16 | QQQ='Equities', 17 | EEM='Equities', 18 | TLT='Bonds', 19 | IEF='Bonds', 20 | LQD='Credit', 21 | HYG='HighYield', 22 | GLD='Gold') 23 | 24 | 25 | def update_test_prices() -> pd.DataFrame: 26 | tickers = list(UNIVERSE_DATA.keys()) 27 | prices = yf.download(tickers=tickers, 28 | start=None, end=None, 29 | ignore_tz=True, auto_adjust=True) 30 | prices = prices['Close'] 31 | prices = prices.asfreq('B', method='ffill') # rescale to business days 32 | prices = prices[tickers] # align order 33 | qis.save_df_to_csv(df=prices, file_name=FILE_NAME, local_path=local_path.get_resource_path()) 34 | return prices 35 | 36 | 37 | def load_test_data() -> pd.DataFrame: 38 | prices = qis.load_df_from_csv(file_name=FILE_NAME, local_path=local_path.get_resource_path()) 39 | return prices 40 | 41 | 42 | class UnitTests(Enum): 43 | UPDATE_TEST_PRICES = 1 44 | LOAD_TEST_PRICES = 2 45 | 46 | 47 | def run_unit_test(unit_test: UnitTests): 48 | 49 | if unit_test == UnitTests.UPDATE_TEST_PRICES: 50 | prices = update_test_prices() 51 | print(prices) 52 | 53 | elif unit_test == UnitTests.LOAD_TEST_PRICES: 54 | prices = load_test_data() 55 | print(prices) 56 | 57 | 58 | if __name__ == '__main__': 59 | 60 | unit_test = UnitTests.UPDATE_TEST_PRICES 61 | 62 | is_run_all_tests = False 63 | if is_run_all_tests: 64 | for unit_test in UnitTests: 65 | run_unit_test(unit_test=unit_test) 66 | else: 67 | run_unit_test(unit_test=unit_test) 68 | -------------------------------------------------------------------------------- /optimalportfolios/utils/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from optimalportfolios.utils.filter_nans import (filter_covar_and_vectors, 3 | filter_covar_and_vectors_for_nans) 4 | 5 | from optimalportfolios.utils.portfolio_funcs import (compute_portfolio_vol, 6 | compute_te_turnover) 7 | 8 | from optimalportfolios.utils.portfolio_funcs import (compute_portfolio_variance, 9 | calculate_diversification_ratio, 10 | compute_portfolio_risk_contribution_outputs) 11 | 12 | from optimalportfolios.utils.gaussian_mixture import fit_gaussian_mixture 13 | 14 | from optimalportfolios.utils.factor_alphas import (compute_low_beta_alphas, 15 | compute_low_beta_alphas_different_freqs, 16 | compute_momentum_alphas, 17 | compute_momentum_alphas_different_freqs, 18 | compute_ra_carry_alphas, 19 | estimate_lasso_regression_alphas, 20 | wrapper_compute_low_beta_alphas, 21 | wrapper_estimate_regression_alphas) 22 | 23 | from optimalportfolios.utils.manager_alphas import (AlphasData, 24 | compute_joint_alphas) 25 | -------------------------------------------------------------------------------- /optimalportfolios/utils/filter_nans.py: -------------------------------------------------------------------------------- 1 | """ 2 | when we roll optimisation in time, we need to filter our data with nans 3 | add some utils to deal to provide solution 4 | """ 5 | import pandas as pd 6 | import numpy as np 7 | from typing import Dict, Tuple, Optional 8 | 9 | 10 | def filter_covar_and_vectors(covar: np.ndarray, 11 | tickers: pd.Index, 12 | vectors: Dict[str, pd.Series] = None 13 | ) -> Tuple[pd.DataFrame, Optional[Dict[str, pd.Series]]]: 14 | """ 15 | filter out assets with zero variance or nans 16 | filter corresponding vectors (can be means, win_max_weights, etc 17 | """ 18 | covar_pd = pd.DataFrame(covar, index=tickers, columns=tickers) 19 | variances = np.diag(covar) 20 | is_good_asset = np.where(np.logical_and(np.greater(variances, 0.0), np.isnan(variances) == False)) 21 | good_tickers = tickers[is_good_asset] 22 | covar_pd = covar_pd.loc[good_tickers, good_tickers] 23 | if vectors is not None: 24 | good_vectors = {key: vector[good_tickers] for key, vector in vectors.items()} 25 | else: 26 | good_vectors = None 27 | return covar_pd, good_vectors 28 | 29 | 30 | def filter_covar_and_vectors_for_nans(pd_covar: pd.DataFrame, 31 | vectors: Dict[str, pd.Series] = None, 32 | inclusion_indicators: pd.Series = None 33 | ) -> Tuple[pd.DataFrame, Optional[Dict[str, pd.Series]]]: 34 | """ 35 | filter out assets with zero variance or nans 36 | filter corresponding vectors (can be means, win_max_weights, etc 37 | inclusion_indicators are ones if asset is included for the allocation 38 | """ 39 | variances = np.diag(pd_covar.to_numpy()) 40 | is_good_asset = np.logical_and(np.greater(variances, 1e-8), np.isnan(variances) == False) 41 | if inclusion_indicators is not None: 42 | is_included = inclusion_indicators.loc[pd_covar.columns].to_numpy() 43 | is_good_asset = np.where(np.isclose(is_included, 1.0), is_good_asset, False) 44 | 45 | good_tickers = pd_covar.index[is_good_asset] 46 | pd_covar = pd_covar.loc[good_tickers, good_tickers] 47 | if vectors is not None: 48 | good_vectors = {} 49 | for key, vector in vectors.items(): 50 | if vector is not None: 51 | if isinstance(vector, pd.Series): 52 | good_vectors[key] = vector[good_tickers].fillna(0.0) 53 | else: 54 | raise TypeError(f"vector muts be pd.Series not type={type(vector)}") 55 | else: 56 | good_vectors = None 57 | return pd_covar, good_vectors 58 | -------------------------------------------------------------------------------- /optimalportfolios/utils/manager_alphas.py: -------------------------------------------------------------------------------- 1 | """ 2 | for multi-asset portfolios we compute managers alpha with is regression alpha 3 | for other asset classes we compute grouped alpha 4 | """ 5 | 6 | import numpy as np 7 | import pandas as pd 8 | import qis as qis 9 | from typing import Union, Dict, Optional 10 | from dataclasses import dataclass, asdict 11 | 12 | from optimalportfolios.utils.factor_alphas import (wrapper_compute_low_beta_alphas, 13 | wrapper_estimate_regression_alphas) 14 | 15 | 16 | @dataclass 17 | class AlphasData: 18 | alpha_scores: pd.DataFrame 19 | beta: Optional[pd.DataFrame] 20 | momentum: Optional[pd.DataFrame] 21 | managers_alphas: Optional[pd.DataFrame] 22 | momentum_score: Optional[pd.DataFrame] 23 | beta_score: Optional[pd.DataFrame] 24 | managers_scores: Optional[pd.DataFrame] 25 | 26 | def get_alphas_snapshot(self, date: pd.Timestamp) -> pd.DataFrame: 27 | if date not in self.alpha_scores.index: 28 | raise KeyError(f"{date} is not in {self.alpha_scores.index}") 29 | snapshot = self.alpha_scores.loc[date, :].to_frame('Alpha Scores') 30 | 31 | if self.momentum_score is not None: 32 | snapshot = pd.concat([snapshot, self.momentum_score.loc[date, :].to_frame('Momentum Score')], axis=1) 33 | if self.beta_score is not None: 34 | snapshot = pd.concat([snapshot, self.beta_score.loc[date, :].to_frame('Beta Score')], axis=1) 35 | if self.managers_scores is not None: 36 | if date in self.managers_scores.index: 37 | snapshot = pd.concat([snapshot, self.managers_scores.loc[date, :].to_frame('Managers Score')], axis=1) 38 | else: 39 | snapshot = pd.concat([snapshot, self.managers_scores.iloc[-1, :].to_frame('Managers Score')], axis=1) 40 | if self.momentum is not None: 41 | snapshot = pd.concat([snapshot, self.momentum.loc[date, :].to_frame('Momentum')], axis=1) 42 | if self.beta is not None: 43 | snapshot = pd.concat([snapshot, self.beta.loc[date, :].to_frame('Beta')], axis=1) 44 | if self.managers_alphas is not None: 45 | if date in self.managers_alphas.index: 46 | snapshot = pd.concat([snapshot, self.managers_alphas.loc[date, :].to_frame('Managers Alpha')], axis=1) 47 | else: 48 | snapshot = pd.concat([snapshot, self.managers_alphas.iloc[-1, :].to_frame('Managers Alpha')], axis=1) 49 | return snapshot 50 | 51 | def to_dict(self) -> Dict[str, pd.DataFrame]: 52 | return asdict(self) 53 | 54 | 55 | def compute_joint_alphas(prices: pd.DataFrame, 56 | benchmark_price: pd.Series, 57 | risk_factors_prices: pd.DataFrame, 58 | alpha_beta_type: pd.Series, 59 | rebalancing_freq: Union[str, pd.Series], 60 | estimated_betas: Dict[pd.Timestamp, pd.DataFrame], 61 | group_data_alphas: pd.Series, 62 | beta_span: int = 12, 63 | momentum_long_span: int = 12, 64 | managers_alpha_span: int = 12, 65 | return_annualisation_freq_dict: Optional[Dict[str, float]] = {'ME': 12.0, 'QE': 4.0} 66 | ) -> AlphasData: 67 | """ 68 | for multi-asset portfolios we compute alpha based on the type: 69 | 1) Beta 70 | 71 | managers alpha with is regression alpha 72 | for other asset classes we compute grouped alpha 73 | beta_span = 12 for monthly rebalancing_freq 74 | """ 75 | # 1. compute momentum and low betas for selected universe 76 | beta_assets = alpha_beta_type.loc[alpha_beta_type == 'Beta'].index.to_list() 77 | if len(beta_assets) == 0: 78 | raise NotImplementedError 79 | alpha_scores, momentum, beta, momentum_score, beta_score = wrapper_compute_low_beta_alphas(prices=prices[beta_assets], 80 | benchmark_price=benchmark_price, 81 | rebalancing_freq=rebalancing_freq, 82 | group_data_alphas=group_data_alphas.loc[beta_assets], 83 | beta_span=beta_span, 84 | momentum_long_span=momentum_long_span) 85 | # 2. compute alphas for managers 86 | alpha_assets = alpha_beta_type.loc[alpha_beta_type == 'Alpha'].index.to_list() 87 | if len(alpha_assets) == 0: 88 | raise NotImplementedError 89 | 90 | excess_returns = wrapper_estimate_regression_alphas(prices=prices[alpha_assets], 91 | risk_factors_prices=risk_factors_prices, 92 | estimated_betas=estimated_betas, 93 | rebalancing_freq=rebalancing_freq, 94 | return_annualisation_freq_dict=return_annualisation_freq_dict) 95 | # alphas_ = excess_returns.rolling(managers_alpha_span).sum() 96 | managers_alphas = qis.compute_ewm(data=excess_returns, span=managers_alpha_span) 97 | # managers_scores = qis.df_to_cross_sectional_score(df=managers_alphas) 98 | managers_scores = managers_alphas / np.nanstd(managers_alphas, axis=1, keepdims=True) 99 | 100 | # merge 101 | managers_scores = managers_scores.reindex(index=alpha_scores.index).ffill() 102 | alpha_scores = pd.concat([alpha_scores, managers_scores], axis=1) 103 | alpha_scores = alpha_scores[prices.columns].ffill() 104 | alphas = AlphasData(alpha_scores=alpha_scores, 105 | beta=beta, 106 | momentum=momentum, 107 | managers_alphas=managers_alphas, 108 | momentum_score=momentum_score, 109 | beta_score=beta_score, 110 | managers_scores=managers_scores) 111 | return alphas 112 | -------------------------------------------------------------------------------- /optimalportfolios/utils/portfolio_funcs.py: -------------------------------------------------------------------------------- 1 | """ 2 | examples of 3 | """ 4 | from __future__ import division 5 | 6 | import numpy as np 7 | import pandas as pd 8 | from typing import Tuple, Union, Optional 9 | from numba import njit 10 | 11 | 12 | @njit 13 | def compute_portfolio_variance(w: np.ndarray, covar: np.ndarray) -> float: 14 | return w.T @ covar @ w 15 | 16 | 17 | @njit 18 | def compute_portfolio_risk_contributions(w: np.ndarray, covar: np.ndarray) -> np.ndarray: 19 | portfolio_vol = np.sqrt(w.T @ covar @ w) 20 | marginal_risk_contribution = covar @ w.T 21 | rc = np.multiply(marginal_risk_contribution, w) / portfolio_vol 22 | return rc 23 | 24 | 25 | def compute_portfolio_vol(covar: Union[np.ndarray, pd.DataFrame], 26 | weights: Union[np.ndarray, pd.Series] 27 | ): 28 | if isinstance(covar, pd.DataFrame): 29 | covar = covar.to_numpy() 30 | if isinstance(weights, pd.Series): 31 | weights = weights.to_numpy() 32 | return np.sqrt(compute_portfolio_variance(w=weights, covar=covar)) 33 | 34 | 35 | def compute_te_turnover(covar: np.ndarray, 36 | benchmark_weights: pd.Series, 37 | weights: pd.Series, 38 | weights_0: pd.Series, 39 | alphas: pd.Series = None 40 | ) -> Tuple[float, float, float, float, float]: 41 | weight_diff = weights.subtract(benchmark_weights) 42 | benchmark_vol = np.sqrt(benchmark_weights @ covar @ benchmark_weights.T) 43 | port_vol = np.sqrt(weights @ covar @ weights.T) 44 | te_vol = np.sqrt(weight_diff @ covar @ weight_diff.T) 45 | turnover = np.nansum(np.abs(weights.subtract(weights_0))) 46 | if alphas is not None: 47 | port_alpha = alphas @ weights 48 | else: 49 | port_alpha = 0.0 50 | return te_vol, turnover, port_alpha, port_vol, benchmark_vol 51 | 52 | 53 | def calculate_diversification_ratio(w: np.ndarray, covar: np.ndarray) -> float: 54 | avg_weighted_vol = np.sqrt(np.diag(covar)) @ w.T 55 | portfolio_vol = np.sqrt(compute_portfolio_variance(w, covar)) 56 | diversification_ratio = avg_weighted_vol/portfolio_vol 57 | return diversification_ratio 58 | 59 | 60 | def compute_portfolio_risk_contribution_outputs(weights: pd.Series, 61 | clean_covar: pd.DataFrame, 62 | risk_budget: Optional[pd.Series] = None 63 | ) -> pd.DataFrame: 64 | weights = weights.loc[clean_covar.columns] 65 | asset_rc = compute_portfolio_risk_contributions(weights.to_numpy(), clean_covar.to_numpy()) 66 | asset_rc_ratio = asset_rc / np.nansum(asset_rc) 67 | if risk_budget is None: 68 | risk_budget = pd.Series(0.0, index=clean_covar.columns) 69 | df = pd.concat([pd.Series(weights, index=clean_covar.columns, name='weights'), 70 | pd.Series(asset_rc, index=clean_covar.columns, name='risk contribution'), 71 | risk_budget.rename('Risk Budget'), 72 | pd.Series(asset_rc_ratio, index=clean_covar.columns, name='asset_rc_ratio') 73 | ], axis=1) 74 | return df 75 | 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "optimalportfolios" 3 | version = "3.3.7" 4 | description = "Simulation and backtesting of optimal portfolios" 5 | license = "LICENSE.txt" 6 | authors = ["Artur Sepp "] 7 | maintainers = ["Artur Sepp "] 8 | readme = "README.md" 9 | repository = "https://github.com/ArturSepp/OptimalPortfolios" 10 | documentation = "https://github.com/ArturSepp/OptimalPortfolios" 11 | keywords= ["quantitative", "investing", "portfolio optimization", "systematic strategies", "volatility"] 12 | classifiers=[ 13 | "Development Status :: 4 - Beta", 14 | "Environment :: Console", 15 | "Intended Audience :: Financial and Insurance Industry", 16 | "Intended Audience :: Science/Research", 17 | "License :: OSI Approved :: MIT License", 18 | "Natural Language :: English", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Topic :: Office/Business :: Financial :: Investment", 25 | ] 26 | packages = [ {include = "optimalportfolios"}, 27 | {include = "pyrb"}] 28 | exclude=["optimalportfolios/examples/figures", 29 | "optimalportfolios/examples/crypto_allocation/data"] 30 | 31 | [tool.poetry.urls] 32 | "Issues" = "https://github.com/ArturSepp/OptimalPortfolios/issues" 33 | "Personal website" = "https://artursepp.com" 34 | 35 | [tool.poetry.dependencies] 36 | python = ">=3.8" 37 | numpy = ">=1.26.4" 38 | numba = ">=0.59.0" 39 | scipy = ">=1.12.0" 40 | pandas = ">=2.2.0" 41 | matplotlib = ">=3.8.3" 42 | seaborn = ">=0.13.2" 43 | scikit_learn = ">=1.3.0" 44 | cvxpy = ">=1.3.2" 45 | qis = ">=2.1.1" 46 | pybloqs = ">=1.2.13" 47 | setuptools = ">=69.1.1" 48 | yfinance = ">=0.2.37" 49 | psycopg2 = ">=2.9.5" 50 | quadprog = ">=0.1.13" 51 | 52 | [build-system] 53 | requires = ["poetry-core>=1.0.0"] 54 | build-backend = "poetry.core.masonry.api" 55 | -------------------------------------------------------------------------------- /pyrb/README.md: -------------------------------------------------------------------------------- 1 | 2 | This package is forked from pyrb package https://github.com/jcrichard/pyrb 3 | 4 | for the distribution of optimalportfolios package, because Pyrb is not available through pip install 5 | 6 | jcrichard/pyrb is licensed under the MIT License 7 | 8 | for -------------------------------------------------------------------------------- /pyrb/__init__.py: -------------------------------------------------------------------------------- 1 | # https://github.com/jcrichard/pyrb 2 | 3 | from .allocation import ( 4 | EqualRiskContribution, 5 | RiskBudgeting, 6 | RiskBudgetAllocation, 7 | RiskBudgetingWithER, 8 | ConstrainedRiskBudgeting, 9 | ) -------------------------------------------------------------------------------- /pyrb/settings.py: -------------------------------------------------------------------------------- 1 | # algorithm tolerance 2 | CCD_COVERGENCE_TOL = 1e-10 3 | BISECTION_TOL = 1e-5 4 | ADMM_TOL = 1e-10 5 | MAX_ITER = 5000 6 | BISECTION_UPPER_BOUND = 10 7 | MAXITER_BISECTION = 5000 8 | 9 | # bounds 10 | MIN_WEIGHT = 0 11 | MAX_WEIGHT = 1e3 12 | RISK_BUDGET_TOL = 0.00001 -------------------------------------------------------------------------------- /pyrb/solvers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numba 4 | import numpy as np 5 | 6 | from . import tools 7 | from .settings import CCD_COVERGENCE_TOL, MAX_ITER, MAX_WEIGHT, ADMM_TOL 8 | 9 | 10 | @numba.njit 11 | def accelarate(_varphi, r, s, u, alpha=10, tau=2): 12 | """ 13 | Update varphy and dual error for accelerating convergence after ADMM steps. 14 | 15 | Parameters 16 | ---------- 17 | _varphi 18 | r: primal_error. 19 | s: dual error. 20 | u: primal_error. 21 | alpha: error treshld. 22 | tau: scaling parameter. 23 | 24 | Returns 25 | ------- 26 | updated varphy and primal_error. 27 | """ 28 | 29 | primal_error = np.sum(r ** 2) 30 | dual_error = np.sum(s * s) 31 | if primal_error > alpha * dual_error: 32 | _varphi = _varphi * tau 33 | u = u / tau 34 | elif dual_error > alpha * primal_error: 35 | _varphi = _varphi / tau 36 | u = u * tau 37 | return _varphi, u 38 | 39 | 40 | @numba.jit('Tuple((float64[:], float64[:], float64))(float64[:], float64, float64[:], float64, float64, float64[:], float64[:], float64[:], float64[:,:], float64, float64[:,:])', 41 | nopython=True) 42 | def _cycle(x, c, var, _varphi, sigma_x, Sx, budgets, pi, bounds, lambda_log, cov): 43 | """ 44 | Internal numba function for computing one cycle of the CCD algorithm. 45 | 46 | """ 47 | n = len(x) 48 | for i in range(n): 49 | alpha = c * var[i] + _varphi * sigma_x 50 | beta = c * (Sx[i] - x[i] * var[i]) - pi[i] * sigma_x 51 | gamma_ = -lambda_log * budgets[i] * sigma_x 52 | x_tilde = (-beta + np.sqrt(beta ** 2 - 4 * alpha * gamma_)) / (2 * alpha) 53 | 54 | x_tilde = np.maximum(np.minimum(x_tilde, bounds[i, 1]), bounds[i, 0]) 55 | 56 | x[i] = x_tilde 57 | x = np.ascontiguousarray(x) # to remove numpy warning 58 | cov = np.ascontiguousarray(cov) # to remove numpy warning 59 | Sx = np.dot(cov, x) 60 | Sx = np.ascontiguousarray(Sx) # to remove numpy warning 61 | sigma_x = np.sqrt(np.dot(Sx, x)) 62 | return x, Sx, sigma_x 63 | 64 | 65 | def solve_rb_ccd( 66 | cov, budgets=None, pi=None, c=1.0, bounds=None, lambda_log=1.0, _varphi=0.0 67 | ): 68 | """ 69 | Solve the risk budgeting problem for standard deviation risk-based measure with bounds constraints using cyclical 70 | coordinate descent (CCD). It is corresponding to solve equation (17) in the paper. 71 | 72 | By default the function solve the ERC portfolio or the RB portfolio if budgets are given. 73 | 74 | Parameters 75 | ---------- 76 | cov : array, shape (n, n) 77 | Covariance matrix of the returns. 78 | 79 | budgets : array, shape (n,) 80 | Risk budgets for each asset (the default is None which implies equal risk budget). 81 | 82 | pi : array, shape (n,) 83 | Expected excess return for each asset (the default is None which implies 0 for each asset). 84 | 85 | c : float 86 | Risk aversion parameter equals to one by default. 87 | 88 | bounds : array, shape (n, 2) 89 | Array of minimum and maximum bounds. If None the default bounds are [0,1]. 90 | 91 | lambda_log : float 92 | Log penalty parameter. 93 | 94 | _varphi : float 95 | This parameters is only useful for solving ADMM-CCD algorithm should be zeros otherwise. 96 | 97 | Returns 98 | ------- 99 | x : aray shape(n,) 100 | The array of optimal solution. 101 | 102 | """ 103 | 104 | n = cov.shape[0] 105 | 106 | if bounds is None: 107 | bounds = np.zeros((n, 2)) 108 | bounds[:, 1] = MAX_WEIGHT 109 | else: 110 | bounds = np.array(bounds * 1.0) 111 | 112 | if budgets is None: 113 | budgets = np.array([1.0] * n) / n 114 | else: 115 | budgets = np.array(budgets) 116 | budgets = budgets / np.sum(budgets) 117 | 118 | if (c is None) | (pi is None): 119 | c = 1.0 120 | pi = np.array([0.0] * n) 121 | else: 122 | c = float(c) 123 | pi = np.array(pi).astype(float) 124 | 125 | # initial value equals to 1/vol portfolio 126 | x = 1 / np.diag(cov) ** 0.5 / (np.sum(1 / np.diag(cov) ** 0.5)) 127 | x0 = x / 100 128 | 129 | budgets = tools.to_array(budgets) 130 | pi = tools.to_array(pi) 131 | var = np.array(np.diag(cov)) 132 | Sx = tools.to_array(np.dot(cov, x)) 133 | sigma_x = np.sqrt(np.dot(Sx, x)) 134 | 135 | cvg = False 136 | iters = 0 137 | 138 | while not cvg: 139 | x, Sx, sigma_x = _cycle( 140 | x, c, var, _varphi, sigma_x, Sx, budgets, pi, bounds, lambda_log, cov 141 | ) 142 | cvg = np.sum(np.array(x - x0) ** 2) <= CCD_COVERGENCE_TOL 143 | x0 = x.copy() 144 | iters = iters + 1 145 | if iters >= MAX_ITER: 146 | logging.info( 147 | "Maximum iteration reached during the CCD descent: {}".format(MAX_ITER) 148 | ) 149 | break 150 | 151 | return tools.to_array(x) 152 | 153 | 154 | def solve_rb_admm_qp( 155 | cov, 156 | budgets=None, 157 | pi=None, 158 | c=None, 159 | C=None, 160 | d=None, 161 | bounds=None, 162 | lambda_log=1, 163 | _varphi=1, 164 | ): 165 | """ 166 | Solve the constrained risk budgeting constraint for the Mean Variance risk measure: 167 | The risk measure is given by R(x) = x^T cov x - c * pi^T x 168 | 169 | Parameters 170 | ---------- 171 | cov : array, shape (n, n) 172 | Covariance matrix of the returns. 173 | 174 | budgets : array, shape (n,) 175 | Risk budgets for each asset (the default is None which implies equal risk budget). 176 | 177 | pi : array, shape (n,) 178 | Expected excess return for each asset (the default is None which implies 0 for each asset). 179 | 180 | c : float 181 | Risk aversion parameter equals to one by default. 182 | 183 | C : array, shape (p, n) 184 | Array of p inequality constraints. If None the problem is unconstrained and solved using CCD 185 | (algorithm 3) and it solves equation (17). 186 | 187 | d : array, shape (p,) 188 | Array of p constraints that matches the inequalities. 189 | 190 | bounds : array, shape (n, 2) 191 | Array of minimum and maximum bounds. If None the default bounds are [0,1]. 192 | 193 | lambda_log : float 194 | Log penalty parameter. 195 | 196 | _varphi : float 197 | This parameters is only useful for solving ADMM-CCD algorithm should be zeros otherwise. 198 | 199 | Returns 200 | ------- 201 | x : aray shape(n,) 202 | The array of optimal solution. 203 | 204 | """ 205 | 206 | def proximal_log(a, b, c, budgets): 207 | delta = b * b - 4 * a * c * budgets 208 | x = (b + np.sqrt(delta)) / (2 * a) 209 | return x 210 | 211 | cov = np.array(cov) 212 | n = np.shape(cov)[0] 213 | 214 | if bounds is None: 215 | bounds = np.zeros((n, 2)) 216 | bounds[:, 1] = MAX_WEIGHT 217 | else: 218 | bounds = np.array(bounds * 1.0) 219 | 220 | if budgets is None: 221 | budgets = np.array([1.0 / n] * n) 222 | 223 | x0 = 1 / np.diag(cov) / (np.sum(1 / np.diag(cov))) 224 | 225 | x = x0 / 100 226 | z = x.copy() 227 | zprev = z 228 | u = np.zeros(len(x)) 229 | cvg = False 230 | iters = 0 231 | pi_vec = tools.to_array(pi) 232 | identity_matrix = np.identity(n) 233 | 234 | while not cvg: 235 | 236 | # x-update 237 | x = tools.quadprog_solve_qp( 238 | cov + _varphi * identity_matrix, 239 | c * pi_vec + _varphi * (z - u), 240 | G=C, 241 | h=d, 242 | bounds=bounds, 243 | ) 244 | 245 | # z-update 246 | z = proximal_log(_varphi, (x + u) * _varphi, -lambda_log, budgets) 247 | 248 | # u-update 249 | r = x - z 250 | s = _varphi * (z - zprev) 251 | u += x - z 252 | 253 | # convergence check 254 | cvg1 = sum((x - x0) ** 2) 255 | cvg2 = sum((x - z) ** 2) 256 | cvg3 = sum((z - zprev) ** 2) 257 | cvg = np.max([cvg1, cvg2, cvg3]) <= ADMM_TOL 258 | x0 = x.copy() 259 | zprev = z 260 | 261 | iters = iters + 1 262 | if iters >= MAX_ITER: 263 | logging.info("Maximum iteration reached: {}".format(MAX_ITER)) 264 | break 265 | 266 | # parameters update 267 | _varphi, u = accelarate(_varphi, r, s, u) 268 | 269 | return tools.to_array(x) 270 | 271 | 272 | def solve_rb_admm_ccd( 273 | cov, 274 | budgets=None, 275 | pi=None, 276 | c=None, 277 | C=None, 278 | d=None, 279 | bounds=None, 280 | lambda_log=1, 281 | _varphi=1, 282 | ): 283 | """ 284 | Solve the constrained risk budgeting constraint for the standard deviation risk measure: 285 | The risk measure is given by R(x) = c * sqrt(x^T cov x) - pi^T x 286 | 287 | Parameters 288 | ---------- 289 | Parameters 290 | ---------- 291 | cov : array, shape (n, n) 292 | Covariance matrix of the returns. 293 | 294 | budgets : array, shape (n,) 295 | Risk budgets for each asset (the default is None which implies equal risk budget). 296 | 297 | pi : array, shape (n,) 298 | Expected excess return for each asset (the default is None which implies 0 for each asset). 299 | 300 | c : float 301 | Risk aversion parameter equals to one by default. 302 | 303 | C : array, shape (p, n) 304 | Array of p inequality constraints. If None the problem is unconstrained and solved using CCD 305 | (algorithm 3) and it solves equation (17). 306 | 307 | d : array, shape (p,) 308 | Array of p constraints that matches the inequalities. 309 | 310 | bounds : array, shape (n, 2) 311 | Array of minimum and maximum bounds. If None the default bounds are [0,1]. 312 | 313 | lambda_log : float 314 | Log penalty parameter. 315 | 316 | _varphi : float 317 | This parameters is only useful for solving ADMM-CCD algorithm should be zeros otherwise. 318 | 319 | Returns 320 | ------- 321 | x : aray shape(n,) 322 | The array of optimal solution. 323 | 324 | 325 | """ 326 | 327 | cov = np.array(cov) 328 | 329 | x0 = 1 / np.diag(cov) / (np.sum(1 / np.diag(cov))) 330 | 331 | x = x0 / 100 332 | z = x.copy() 333 | zprev = z 334 | u = np.zeros(len(x)) 335 | cvg = False 336 | iters = 0 337 | pi_vec = tools.to_array(pi) 338 | while not cvg: 339 | 340 | # x-update 341 | x = solve_rb_ccd( 342 | cov, 343 | budgets=budgets, 344 | pi=pi_vec + (_varphi * (z - u)), 345 | bounds=bounds, 346 | lambda_log=lambda_log, 347 | c=c, 348 | _varphi=_varphi, 349 | ) 350 | 351 | # z-update 352 | z = tools.proximal_polyhedra(x + u, C, d, A=None, b=None, bound=bounds) 353 | 354 | # u-update 355 | r = x - z 356 | s = _varphi * (z - zprev) 357 | u += x - z 358 | 359 | # convergence check 360 | cvg1 = sum((x - x0) ** 2) 361 | cvg2 = sum((x - z) ** 2) 362 | cvg3 = sum((z - zprev) ** 2) 363 | cvg = np.max([cvg1, cvg2, cvg3]) <= ADMM_TOL 364 | x0 = x.copy() 365 | zprev = z 366 | 367 | iters = iters + 1 368 | if iters >= MAX_ITER: 369 | logging.info("Maximum iteration reached: {}".format(MAX_ITER)) 370 | break 371 | 372 | # parameters update 373 | _varphi, u = accelarate(_varphi, r, s, u) 374 | 375 | return tools.to_array(x) -------------------------------------------------------------------------------- /pyrb/tools.py: -------------------------------------------------------------------------------- 1 | import quadprog 2 | 3 | import numpy as np 4 | 5 | 6 | def to_column_matrix(x): 7 | """Return x as a matrix columns.""" 8 | x = np.matrix(x) 9 | if x.shape[1] != 1: 10 | x = x.T 11 | if x.shape[1] == 1: 12 | return x 13 | else: 14 | raise ValueError("x is not a vector") 15 | 16 | 17 | def to_array(x): 18 | """Turn a columns or row matrix to an array.""" 19 | if x is None: 20 | return None 21 | elif (len(x.shape)) == 1: 22 | return x 23 | 24 | if x.shape[1] != 1: 25 | x = x.T 26 | return np.squeeze(np.asarray(x)) 27 | 28 | 29 | def quadprog_solve_qp(P, q, G=None, h=None, A=None, b=None, bounds=None): 30 | """Quadprog helper.""" 31 | n = P.shape[0] 32 | if bounds is not None: 33 | I = np.eye(n) 34 | LB = -I 35 | UB = I 36 | if G is None: 37 | G = np.vstack([LB, UB]) 38 | h = np.array(np.hstack([-to_array(bounds[:, 0]), to_array(bounds[:, 1])])) 39 | else: 40 | G = np.vstack([G, LB, UB]) 41 | h = np.array( 42 | np.hstack([h, -to_array(bounds[:, 0]), to_array(bounds[:, 1])]) 43 | ) 44 | 45 | qp_a = q # because 1/2 x^T G x - a^T x 46 | qp_G = P 47 | if A is not None: 48 | qp_C = -np.vstack([A, G]).T 49 | qp_b = -np.hstack([b, h]) 50 | meq = A.shape[0] 51 | else: # no equality constraints 52 | qp_C = -G.T 53 | qp_b = -h 54 | meq = 0 55 | return quadprog.solve_qp(qp_G, qp_a, qp_C, qp_b, meq)[0] 56 | 57 | 58 | def proximal_polyhedra(y, C, d, bound, A=None, b=None): 59 | """Wrapper for projecting a vector on the constrained set.""" 60 | n = len(y) 61 | return quadprog_solve_qp( 62 | np.eye(n), np.array(y), np.array(C), np.array(d), A=A, b=b, bounds=bound 63 | ) -------------------------------------------------------------------------------- /pyrb/validation.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from .settings import RISK_BUDGET_TOL 4 | 5 | 6 | def check_covariance(cov): 7 | if cov.shape[0] != cov.shape[1]: 8 | raise ValueError("The covariance matrix is not squared") 9 | if np.isnan(cov).sum().sum() > 0: 10 | raise ValueError("The covariance matrix contains missing values") 11 | 12 | 13 | def check_expected_return(mu, n): 14 | if mu is None: 15 | return 16 | if n != len(mu): 17 | raise ValueError( 18 | "Expected returns vector size is not equal to the number of asset." 19 | ) 20 | if np.isnan(mu).sum() > 0: 21 | raise ValueError("The expected returns vector contains missing values") 22 | 23 | 24 | def check_constraints(C, d, n): 25 | if C is None: 26 | return 27 | if n != C.shape[1]: 28 | raise ValueError("Number of columns of C is not equal to the number of asset.") 29 | if len(d) != C.shape[0]: 30 | raise ValueError("Number of rows of C is not equal to the length of d.") 31 | 32 | 33 | def check_bounds(bounds, n): 34 | if bounds is None: 35 | return 36 | if n != bounds.shape[0]: 37 | raise ValueError( 38 | "The number of rows of the bounds array is not equal to the number of asset." 39 | ) 40 | if 2 != bounds.shape[1]: 41 | raise ValueError( 42 | "The number of columns the bounds array should be equal to two (min and max bounds)." 43 | ) 44 | 45 | 46 | def check_risk_budget(riskbudgets, n): 47 | if riskbudgets is None: 48 | return 49 | if np.isnan(riskbudgets).sum() > 0: 50 | raise ValueError("Risk budget contains missing values") 51 | if (np.array(riskbudgets) < 0).sum() > 0: 52 | raise ValueError("Risk budget contains negative values") 53 | if n != len(riskbudgets): 54 | raise ValueError("Risk budget size is not equal to the number of asset.") 55 | if all(v < RISK_BUDGET_TOL for v in riskbudgets): 56 | raise ValueError( 57 | "One of the budget is smaller than {}. If you want a risk budget of 0 please remove the asset.".format( 58 | RISK_BUDGET_TOL 59 | ) 60 | ) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pandas>=2.2.0 2 | numpy>=1.26.4 3 | numba>=0.59.0 4 | scipy>=1.12.0 5 | matplotlib>=3.8.3 6 | seaborn>=0.13.2 7 | scikit_learn>=1.3.0 8 | cvxpy>=1.6.4 9 | pybloqs>=1.2.13 10 | yfinance>=0.2.52 11 | qis>=3.2.2 12 | psycopg2>=2.9.5 13 | quadprog>=0.1.13 14 | ecos>=2.0.12 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | def read_requirements(file): 5 | with open(file) as f: 6 | return f.read().splitlines() 7 | 8 | 9 | def read_file(file): 10 | with open(file) as f: 11 | return f.read() 12 | 13 | 14 | long_description = read_file("README.md") 15 | requirements = read_requirements("requirements.txt") 16 | 17 | setup( 18 | name='optimalportfolios', 19 | version='3.3.7', 20 | author='Artur Sepp', 21 | author_email='artursepp@gmail.com', 22 | url='https://github.com/ArturSepp/OptimalPortfolios', 23 | description='Implementation of optimisation analytics for constructing and backtesting optimal portfolios', 24 | long_description_content_type="text/x-rst", # If this causes a warning, upgrade your setuptools package 25 | long_description=long_description, 26 | license="MIT license", 27 | packages=find_packages(exclude=["optimalportfolios/examples/figures", "optimalportfolios/examples/crypto_allocation/data"]), 28 | install_requires=requirements, 29 | classifiers=[ 30 | "Programming Language :: Python :: 3", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: OS Independent", 33 | ] 34 | ) --------------------------------------------------------------------------------