├── black_litterman ├── __init__.py ├── ui │ ├── __init__.py │ ├── fonts.py │ ├── view_button.py │ ├── view_manager.py │ ├── allocation_controls.py │ ├── chart_settings_control.py │ ├── portfolio_chart.py │ └── view_designer_control.py ├── domain │ ├── __init__.py │ ├── __pycache__ │ │ ├── views.cpython-37.pyc │ │ └── __init__.cpython-37.pyc │ ├── config_handling.py │ ├── views.py │ └── engine.py ├── market_data │ ├── __init__.py │ ├── engine.py │ └── data_readers.py ├── __pycache__ │ └── __init__.cpython-37.pyc ├── settings.json ├── constants.py └── main.py ├── .gitignore ├── resources ├── app_example.png └── view_config_example.png ├── requirements.txt ├── README.md └── tests ├── test_market_data └── test_engine.py └── test_domain ├── test_views.py └── test_engine.py /black_litterman/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /black_litterman/ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /black_litterman/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /black_litterman/market_data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/* 2 | .idea/* 3 | __pycache__/ 4 | black_litterman/credentials.json -------------------------------------------------------------------------------- /resources/app_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeLove100/black-litterman/HEAD/resources/app_example.png -------------------------------------------------------------------------------- /resources/view_config_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeLove100/black-litterman/HEAD/resources/view_config_example.png -------------------------------------------------------------------------------- /black_litterman/__pycache__/__init__.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeLove100/black-litterman/HEAD/black_litterman/__pycache__/__init__.cpython-37.pyc -------------------------------------------------------------------------------- /black_litterman/domain/__pycache__/views.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeLove100/black-litterman/HEAD/black_litterman/domain/__pycache__/views.cpython-37.pyc -------------------------------------------------------------------------------- /black_litterman/domain/__pycache__/__init__.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeLove100/black-litterman/HEAD/black_litterman/domain/__pycache__/__init__.cpython-37.pyc -------------------------------------------------------------------------------- /black_litterman/ui/fonts.py: -------------------------------------------------------------------------------- 1 | from PySide2 import QtGui 2 | 3 | 4 | class FontHelper: 5 | 6 | @staticmethod 7 | def get_title_font(): 8 | 9 | font = QtGui.QFont("Calibri", 14, QtGui.QFont.Bold) 10 | return font 11 | 12 | @staticmethod 13 | def get_text_font(): 14 | font = QtGui.QFont("Calibri", 8) 15 | return font 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cardano-sdk-market-data==0.0.4 2 | certifi==2020.4.5.1 3 | chardet==3.0.4 4 | dataclasses==0.7 5 | et-xmlfile==1.0.1 6 | idna==2.9 7 | jdcal==1.4.1 8 | numpy==1.18.2 9 | openpyxl==3.0.3 10 | pandas==1.0.3 11 | PySide2==5.14.2 12 | python-dateutil==2.8.1 13 | pytz==2020.1 14 | requests==2.23.0 15 | scipy==1.4.1 16 | shiboken2==5.14.2 17 | six==1.15.0 18 | urllib3==1.25.9 19 | xlrd==1.2.0 -------------------------------------------------------------------------------- /black_litterman/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "market_data" : 3 | { 4 | "source": "reuters", 5 | "file_path": "C:\\Users\\joelo\\Code\\black_litterman_data.xlsx", 6 | "first_date": "2017-01-02", 7 | "last_date": "", 8 | "asset_universe": 9 | { 10 | "UK equities": ["FTSE100", 1000], 11 | "US equities": ["S&PCOMP", 1000], 12 | "Europe equities": ["DJES50I", 1000], 13 | "Japan equities": ["TOKYOSE", 1000], 14 | "UK gov bonds": ["AUKGVAL", 1], 15 | "US gov bonds": ["AUSGVAL", 1], 16 | "German gov bonds": ["TBDGVAL", 1], 17 | "Japan gov bonds": ["AJPGVAL", 1] 18 | } 19 | }, 20 | "parameters": 21 | { 22 | "tau": 0.05, 23 | "risk_aversion": 3 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /black_litterman/constants.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | 4 | class Configuration: 5 | 6 | MARKET_DATA = "market_data" 7 | MARKET_DATA_SOURCE = "source" 8 | MARKET_DATA_FILE_PATH = "file_path" 9 | FIRST_DATE = "first_date" 10 | LAST_DATE = "last_date" 11 | ASSET_UNIVERSE = "asset_universe" 12 | CREDENTIALS = "credentials" 13 | 14 | PARAMETERS = "parameters" 15 | TAU = "tau" 16 | RISK_AVERSION = "risk_aversion" 17 | 18 | 19 | class MarketData: 20 | 21 | PRICE_DATA = "price_data" 22 | MARKET_CAP_DATA = "market_cap_data" 23 | 24 | @classmethod 25 | def get_data_types(cls) -> List[str]: 26 | 27 | return [cls.PRICE_DATA, cls.MARKET_CAP_DATA] 28 | 29 | 30 | class Weights: 31 | 32 | MARKET = "Market Weights" 33 | BLACK_LITTERMAN = "Black-Litterman Weights" 34 | -------------------------------------------------------------------------------- /black_litterman/domain/config_handling.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import pandas as pd 4 | from datetime import datetime 5 | from typing import Any, Dict 6 | from black_litterman.constants import Configuration 7 | from black_litterman.domain.engine import BLEngine, CalculationSettings 8 | from black_litterman.market_data.data_readers import DataReaderFactory 9 | 10 | 11 | class ConfigHandler: 12 | 13 | def __init__(self, 14 | config_path): 15 | 16 | self._config_path = config_path 17 | 18 | def _read_config(self) -> Dict[str, Any]: 19 | 20 | main_path = os.path.join(self._config_path, "settings.json") 21 | credentials_path = os.path.join(self._config_path, "credentials.json") 22 | with open(main_path) as config_file: 23 | main_configuration = json.load(config_file) 24 | 25 | with open(credentials_path) as credentials_file: 26 | credentials = json.load(credentials_file) 27 | 28 | main_configuration[Configuration.CREDENTIALS] = credentials 29 | if not main_configuration[Configuration.MARKET_DATA][Configuration.LAST_DATE]: 30 | prev_date = (datetime.now() + pd.offsets.BusinessDay(-1)).strftime("%Y-%m-%d") 31 | main_configuration[Configuration.MARKET_DATA][Configuration.LAST_DATE] = prev_date 32 | return main_configuration 33 | 34 | def _build_engine(self, 35 | config: Dict[str, Any]) -> BLEngine: 36 | 37 | data_reader = DataReaderFactory.get_data_reader(config) 38 | calc_settings = CalculationSettings.parse_from_config(config) 39 | engine = BLEngine(data_reader, calc_settings) 40 | return engine 41 | 42 | def build_engine_from_config(self) -> BLEngine: 43 | 44 | config = self._read_config() 45 | engine = self._build_engine(config) 46 | return engine 47 | -------------------------------------------------------------------------------- /black_litterman/market_data/engine.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | 4 | class MarketDataEngine: 5 | 6 | def __init__(self, 7 | price_data: pd.DataFrame, 8 | market_cap_data: pd.DataFrame) -> None: 9 | 10 | self._returns_data = price_data.pct_change(1) 11 | self._market_cap_data = market_cap_data 12 | 13 | def get_annualised_cov_matrix(self, 14 | start_date: str, 15 | end_date: str): 16 | """ 17 | get cov matrix based on returns for the 18 | given dates (inclusive) 19 | """ 20 | 21 | date_mask = (self._returns_data.index >= start_date) & (self._returns_data.index <= end_date) 22 | returns_for_dates = self._returns_data[date_mask] 23 | covariance_for_dates = returns_for_dates.cov() * 250 24 | return covariance_for_dates 25 | 26 | def get_market_weights(self, 27 | selected_date: str) -> pd.Series: 28 | """ 29 | get market-cap weights for instruments based 30 | on index market caps 31 | """ 32 | 33 | market_cap_for_date = self._market_cap_data[self._market_cap_data.index <= selected_date].iloc[-1, :] 34 | market_weights = market_cap_for_date / market_cap_for_date.sum() 35 | return market_weights 36 | 37 | def get_implied_returns(self, 38 | start_date: str, 39 | end_date: str, 40 | risk_aversion: float) -> pd.Series: 41 | """ 42 | get the market clearing returns for the given 43 | level of risk aversion 44 | """ 45 | 46 | cov_matrix = self.get_annualised_cov_matrix(start_date, end_date) 47 | market_weights = self.get_market_weights(end_date) 48 | market_returns = cov_matrix.dot(market_weights).mul(risk_aversion) 49 | 50 | return market_returns 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![made-with-python](https://img.shields.io/badge/Made%20with-Python-1f425f.svg)](https://www.python.org/) 2 | [![MIT](https://img.shields.io/pypi/l/ansicolortags.svg)](https://pypi.python.org/pypi/ansicolortags/) 3 | [![GitHub issues](https://img.shields.io/github/issues/Naereen/StrapDown.js.svg)](https://GitHub.com/Naereen/StrapDown.js/issues/) 4 | 5 | # Black-Litterman asset allocation tool 6 | 7 | This tool provides a simple python-based GUI application for constructing 8 | multi-asset portfolios based on the **Black-Litterman** framework. 9 | 10 | ## Theoretical basis 11 | 12 | The Black-Litterman asset allocation model allows an investor to construct a portfolio 13 | based around the "market portfolio", but accounting for their own views about future 14 | market developments. These views can be specified either as directional views on the 15 | out/under performance of a single asset class, or can be on the relative performance 16 | of one basket of assets against another. 17 | 18 | The methodology employed in this tool is taken from 19 | "*A Step By Step Guide to the Black-Litterman Model*" by Thomas Idzorek. A more 20 | theoretical (although still very accessible) justification for the model is 21 | provided by Charlotta Mankert and Michael J Selier in their 2011 paper 22 | "*Mathematical Derivation and Practical Implications for the use of the 23 | Black-Litterman Model*". 24 | 25 | ## Configuring the app 26 | 27 | The app can either run using local data or data from the Refinitiv DataStream API. If you are using 28 | the latter then a credentials file **black_litterman/credentials.json** must be added with a valid 29 | username and password for the API. 30 | 31 | The settings.json file allows you to configure various other parameters of the model, including 32 | the available universe of assets, the start date for the market data, the risk aversion and the 33 | Black-Litterman tau parameter. Detailed descriptions of these last two parameters are provided in the 34 | aforementioned academic resources. 35 | 36 | ## Using the app 37 | 38 | ![App image](resources/app_example.png) 39 | 40 | 1) The main chart shows the market portfolio allocation against the Black-Litterman 41 | allocation (if one or more view is defined) 42 | 43 | 2) The views panel on the left can be used to add new market views up to a maximum 44 | of 4. Currently, the app only supports single-asset relative views. 45 | 46 | 3) The chart can be changed to show the implied expected returns based on the current 47 | market weights and covariances 48 | 49 | 4) The current calculation date can be changed - by default, this will be the prior business 50 | day. You can also change the start date, which sets the window over which the covariance matrix 51 | is calculated. 52 | 53 | ## Configuring views 54 | 55 | ![View configuration](resources/view_config_example.png) 56 | 57 | * The confidence describes how sure you are of a view - this is converted 58 | into a covariance for the purposes of the model, as set out in the Idzorek 59 | paper 60 | 61 | * A view can be an absolute directional view on the performance of an asset, or 62 | a view on the performance of one asset vs another. Although the Black-Litterman model 63 | allows for views on *baskets* of assets, I have not had time to implement this here 64 | (chiefly due to challenges on the UI side) 65 | 66 | * You can select which asset(s) the view applies to - it is perfectly possible to have 67 | multiple views involving the same asset -------------------------------------------------------------------------------- /black_litterman/domain/views.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from dataclasses import dataclass 4 | from typing import List, Optional 5 | from uuid import uuid4 6 | 7 | 8 | class ViewAllocation: 9 | 10 | def __init__(self, 11 | long_asset: str, 12 | short_asset: Optional[str] = None): 13 | 14 | self.long_asset = long_asset 15 | self.short_asset = short_asset 16 | 17 | ABSOLUTE = "Absolute" 18 | RELATIVE = "Relative" 19 | 20 | @classmethod 21 | def get_all_view_types(cls): 22 | return [cls.ABSOLUTE, cls.RELATIVE] 23 | 24 | @property 25 | def view_type(self): 26 | if self.short_asset: 27 | return self.RELATIVE 28 | else: 29 | return self.ABSOLUTE 30 | 31 | 32 | @dataclass(frozen=True) 33 | class View: 34 | 35 | id: str 36 | name: str 37 | out_performance: float 38 | confidence: float 39 | allocation: ViewAllocation 40 | 41 | @ staticmethod 42 | def get_new_view_with_defaults(asset: str): 43 | view_id = uuid4().hex 44 | name = "New view" 45 | out_performance = 0 46 | confidence = 0 47 | allocation = ViewAllocation(asset) 48 | 49 | return View(view_id, name, out_performance, confidence, allocation) 50 | 51 | def get_view_data_frame(self, 52 | asset_universe: List[str]) -> pd.DataFrame: 53 | view_series = pd.Series([0] * len(asset_universe), index=asset_universe, name=self.id) 54 | 55 | view_series[self.allocation.long_asset] = 1 56 | if self.allocation.short_asset is not None: 57 | view_series[self.allocation.short_asset] = -1 58 | 59 | view_data_frame = view_series.to_frame().T 60 | return view_data_frame 61 | 62 | 63 | class ViewCollection: 64 | 65 | def __init__(self): 66 | 67 | self._all_views = dict() 68 | 69 | def add_view(self, 70 | view: View) -> None: 71 | 72 | self._all_views.update({view.id: view}) 73 | 74 | def get_view(self, 75 | view_id: int) -> View: 76 | 77 | view = self._all_views[view_id] 78 | return view 79 | 80 | def get_all_views(self) -> List[View]: 81 | 82 | return list(self._all_views.values()) 83 | 84 | def get_view_matrix(self, 85 | asset_universe: List[str]) -> pd.DataFrame: 86 | 87 | all_view_data_frames = [] 88 | for view in self._all_views.values(): 89 | view_data_frame = view.get_view_data_frame(asset_universe) 90 | all_view_data_frames.append(view_data_frame) 91 | 92 | if not all_view_data_frames: 93 | return pd.DataFrame() 94 | else: 95 | view_matrix = pd.concat(all_view_data_frames, axis=0) 96 | return view_matrix 97 | 98 | def get_view_out_performances(self) -> pd.Series: 99 | 100 | out_performances = pd.Series({view_id: view.out_performance for view_id, view in self._all_views.items()}) 101 | return out_performances 102 | 103 | def get_view_confidence_matrix(self) -> pd.DataFrame: 104 | 105 | uncertainties = pd.Series({view_id: view.confidence for view_id, view in self._all_views.items()}) 106 | cov_matrix = pd.DataFrame(np.diag(uncertainties), index=uncertainties.index, columns=uncertainties.index) 107 | return cov_matrix 108 | 109 | def is_empty(self): 110 | return len(self._all_views) == 0 111 | -------------------------------------------------------------------------------- /black_litterman/ui/view_button.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from PySide2 import QtWidgets, QtCore 3 | from black_litterman.domain.views import View 4 | from black_litterman.ui.view_designer_control import ViewDesignerDialog 5 | 6 | 7 | class ViewButton(QtWidgets.QFrame): 8 | 9 | delete_clicked = QtCore.Signal(QtWidgets.QWidget) 10 | view_changed = QtCore.Signal() 11 | 12 | def __init__(self, 13 | view: View, 14 | asset_universe: List[str]): 15 | 16 | super().__init__() 17 | self._view = view 18 | self._asset_universe = asset_universe 19 | self._create_controls() 20 | self._initialise_controls(view) 21 | self._add_event_handlers() 22 | self._add_controls_to_layout() 23 | self._size_layout() 24 | self._set_control_style() 25 | 26 | def _create_controls(self): 27 | 28 | self._edit_button = QtWidgets.QPushButton("Edit") 29 | self._edit_button.setMinimumHeight(30) 30 | self._edit_button.setMinimumWidth(100) 31 | 32 | self._delete_button = QtWidgets.QPushButton("Delete") 33 | self._delete_button.setMinimumHeight(30) 34 | self._delete_button.setMinimumWidth(100) 35 | 36 | self._name_label = QtWidgets.QLabel() 37 | self._name_label.setMinimumHeight(30) 38 | 39 | def _initialise_controls(self, 40 | view: View): 41 | 42 | self._name_label.setText(view.name) 43 | 44 | def _add_event_handlers(self): 45 | 46 | self._edit_button.clicked.connect(self._show_designer) 47 | self._delete_button.clicked.connect(self._clicked_delete) 48 | 49 | def _add_controls_to_layout(self): 50 | 51 | self.layout = QtWidgets.QGridLayout() 52 | self.setLayout(self.layout) 53 | 54 | self.layout.addWidget(self._name_label, 0, 0, 1, 2) 55 | self.layout.addWidget(self._edit_button, 1, 0) 56 | self.layout.addWidget(self._delete_button, 1, 1) 57 | 58 | def _size_layout(self): 59 | 60 | self.layout.setColumnStretch(0, 5) 61 | self.layout.setColumnStretch(1, 1) 62 | self.layout.setColumnStretch(2, 1) 63 | 64 | def _set_control_style(self): 65 | 66 | self.setFrameStyle(QtWidgets.QFrame.StyledPanel) 67 | 68 | def _show_designer(self): 69 | designer = ViewDesignerDialog(self._view, self._asset_universe) 70 | result = designer.exec_() 71 | if result: 72 | updated_view = designer.get_view() 73 | if self._view != updated_view: 74 | self._view = updated_view 75 | self._name_label.setText(self._view.name) 76 | self.view_changed.emit() 77 | 78 | designer.deleteLater() 79 | 80 | def _clicked_delete(self): 81 | self.delete_clicked.emit(self) 82 | 83 | def get_view(self) -> View: 84 | 85 | return self._view 86 | 87 | 88 | if __name__ == "__main__": 89 | 90 | import sys 91 | from PySide2 import QtGui 92 | from black_litterman.domain.views import ViewAllocation 93 | 94 | app = QtWidgets.QApplication([]) 95 | app.setFont(QtGui.QFont("Arial", 10)) 96 | 97 | v = View("1", "Bonds outperform equity", 0.5, 2, ViewAllocation("test_1")) 98 | widget = ViewButton(v, ["asset_1", "asset_2", "asset_3", "asset_4"]) 99 | widget.setWindowTitle("View button") 100 | widget.resize(30, 100) 101 | widget.show() 102 | 103 | sys.exit(app.exec_()) 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /black_litterman/ui/view_manager.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | from PySide2 import QtWidgets, QtCore 3 | from black_litterman.ui.view_button import ViewButton 4 | from black_litterman.ui.fonts import FontHelper 5 | from black_litterman.domain.views import ViewCollection, View 6 | 7 | 8 | class ViewManager(QtWidgets.QFrame): 9 | 10 | view_changed = QtCore.Signal() 11 | 12 | def __init__(self, all_views: Dict[str, View], asset_universe: List[str]) -> None: 13 | 14 | super().__init__() 15 | self._all_views = all_views 16 | self._asset_universe = asset_universe 17 | self._create_controls() 18 | self._add_event_handlers() 19 | self._add_controls_to_layout() 20 | self._size_layout() 21 | self._set_control_style() 22 | self._view_count = 0 23 | 24 | def _create_controls(self): 25 | 26 | self._views_panel = QtWidgets.QWidget() 27 | layout = QtWidgets.QVBoxLayout() 28 | layout.setAlignment(QtCore.Qt.AlignTop) 29 | self._views_panel.setLayout(layout) 30 | self._views_panel.setMinimumHeight(300) 31 | 32 | self._add_view_button = QtWidgets.QPushButton("Add new view") 33 | self._add_view_button.setMinimumHeight(30) 34 | 35 | self._title_label = QtWidgets.QLabel() 36 | self._title_label.setText("Add market views (max 4)") 37 | self._title_label.setFont(FontHelper.get_title_font()) 38 | 39 | def _add_event_handlers(self): 40 | 41 | self._add_view_button.clicked.connect(self._add_new_view_button) 42 | 43 | def _add_controls_to_layout(self): 44 | 45 | self.layout = QtWidgets.QGridLayout() 46 | self.setLayout(self.layout) 47 | 48 | self.layout.addWidget(self._title_label, 0, 0) 49 | self.layout.addWidget(self._views_panel, 1, 0) 50 | self.layout.addWidget(self._add_view_button, 2, 0) 51 | 52 | def _size_layout(self): 53 | 54 | self.layout.setRowStretch(0, 1) 55 | self.layout.setRowStretch(1, 9) 56 | self.layout.setRowStretch(2, 1) 57 | 58 | def _set_control_style(self): 59 | 60 | self.setFrameStyle(QtWidgets.QFrame.StyledPanel | QtWidgets.QFrame.Raised) 61 | self.setLineWidth(3) 62 | 63 | def _add_new_view_button(self) -> None: 64 | 65 | if self._view_count == 4: 66 | error_msg = QtWidgets.QMessageBox() 67 | error_msg.setIcon(QtWidgets.QMessageBox.Critical) 68 | error_msg.setText("Error") 69 | error_msg.setInformativeText('Max views reached') 70 | error_msg.setWindowTitle("Error") 71 | error_msg.exec_() 72 | else: 73 | new_view = View.get_new_view_with_defaults(self._asset_universe[0]) 74 | button = ViewButton(view=new_view, asset_universe=self._asset_universe) 75 | button.setFixedHeight(75) 76 | self._views_panel.layout().addWidget(button) 77 | button.delete_clicked.connect(self._delete_button) 78 | button.view_changed.connect(self._raise_view_changed) 79 | self._view_count += 1 80 | self.view_changed.emit() 81 | 82 | def _delete_button(self, 83 | button): 84 | button.setParent(None) 85 | self.view_changed.emit() 86 | self._view_count -= 1 87 | 88 | def _raise_view_changed(self): 89 | self.view_changed.emit() 90 | 91 | def get_all_views(self) -> ViewCollection: 92 | 93 | all_views = ViewCollection() 94 | 95 | for child_control in self._views_panel.children(): 96 | if isinstance(child_control, ViewButton): 97 | view = child_control.get_view() 98 | all_views.add_view(view) 99 | 100 | return all_views 101 | -------------------------------------------------------------------------------- /tests/test_market_data/test_engine.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pandas as pd 3 | from datetime import datetime 4 | from black_litterman.market_data.engine import MarketDataEngine 5 | 6 | 7 | class TestMarketDataEngine(unittest.TestCase): 8 | 9 | @staticmethod 10 | def _get_market_data_engine() -> MarketDataEngine: 11 | 12 | dates = pd.date_range(start=datetime(2020, 3, 1), end=datetime(2020, 3, 10), freq="B") 13 | price_data = pd.DataFrame({"asset_1": [100, 101, 102, 100, 98, 99, 100], 14 | "asset_2": [95, 94, 97, 93, 95, 97, 99], 15 | "asset_3": [20, 20.5, 20.5, 20.5, 19.5, 19, 18]}, 16 | index=dates) 17 | 18 | market_cap_data = pd.DataFrame({"asset_1": [1000000, 1000000, 1000000, 1000000, 1020000, 1020000, 1020000], 19 | "asset_2": [500000, 500000, 500000, 400000, 400000, 250000, 250000], 20 | "asset_3": [500000, 500000, 400000, 200000, 400000, 500000, 500000]}, 21 | index=dates) 22 | 23 | engine = MarketDataEngine(price_data, market_cap_data) 24 | return engine 25 | 26 | def test_get_covariance_all_dates(self): 27 | # arrange 28 | engine = self._get_market_data_engine() 29 | start_date = "2020-03-01" 30 | end_date = "2020-03-10" 31 | 32 | # act 33 | result = engine.get_annualised_cov_matrix(start_date, end_date) 34 | 35 | # assert 36 | expected_result = {"asset_1": [0.05943, 0.05040, 0.02213], 37 | "asset_2": [0.05040, 0.19239, -0.11001], 38 | "asset_3": [0.02213, -0.11001, 0.23481]} 39 | expected_result = pd.DataFrame(expected_result, index=["asset_1", "asset_2", "asset_3"]) 40 | pd.testing.assert_frame_equal(expected_result, result, check_less_precise=True) 41 | 42 | def test_get_covariance_different_end_date(self): 43 | # arrange 44 | engine = self._get_market_data_engine() 45 | start_date = "2020-03-01" 46 | end_date = "2020-03-05" 47 | 48 | # act 49 | result = engine.get_annualised_cov_matrix(start_date, end_date) 50 | 51 | # assert 52 | expected_result = {"asset_1": [0.07281, 0.12765, 0.03094], 53 | "asset_2": [0.12765, 0.33732, -0.01222], 54 | "asset_3": [0.03094, -0.01222, 0.05208]} 55 | expected_result = pd.DataFrame(expected_result, index=["asset_1", "asset_2", "asset_3"]) 56 | pd.testing.assert_frame_equal(expected_result, result, check_less_precise=True) 57 | 58 | def test_get_covariance_different_start_date(self): 59 | # arrange 60 | engine = self._get_market_data_engine() 61 | start_date = "2020-03-05" 62 | end_date = "2020-03-10" 63 | 64 | # act 65 | result = engine.get_annualised_cov_matrix(start_date, end_date) 66 | 67 | # assert 68 | expected_result = pd.DataFrame({"asset_1": [0.07479, 0.07562, -0.03590], 69 | "asset_2": [0.07562, 0.24258, -0.16476], 70 | "asset_3": [-0.03590, -0.16476, 0.14762]}, 71 | index=["asset_1", "asset_2", "asset_3"]) 72 | pd.testing.assert_frame_equal(expected_result, result, check_less_precise=True) 73 | 74 | def test_get_market_weights(self): 75 | # arrange 76 | engine = self._get_market_data_engine() 77 | selected_date = "2020-03-05" 78 | 79 | # act 80 | result = engine.get_market_weights(selected_date) 81 | 82 | # assert 83 | expected_result = pd.Series([0.625, 0.25, 0.125], index=["asset_1", "asset_2", "asset_3"]) 84 | pd.testing.assert_series_equal(expected_result, result, check_names=False) 85 | -------------------------------------------------------------------------------- /black_litterman/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from PySide2 import QtWidgets 4 | from black_litterman.ui.view_manager import ViewManager 5 | from black_litterman.ui.portfolio_chart import PortfolioChart 6 | from black_litterman.ui.chart_settings_control import ChartSettingsControl 7 | from black_litterman.domain.config_handling import ConfigHandler 8 | from black_litterman.ui.fonts import FontHelper 9 | 10 | 11 | class BlackLittermanApp(QtWidgets.QWidget): 12 | 13 | def __init__(self): 14 | super().__init__() 15 | self._set_engine_from_config() 16 | self._create_controls() 17 | self._initialise_controls() 18 | self._add_event_handlers() 19 | self._add_controls_to_layout() 20 | self._size_layout() 21 | 22 | def _set_engine_from_config(self) -> None: 23 | 24 | config_path = os.path.abspath(os.path.dirname(__file__)) 25 | config_handler = ConfigHandler(config_path) 26 | self._engine = config_handler.build_engine_from_config() 27 | 28 | def _create_controls(self): 29 | self._main_chart = PortfolioChart() 30 | self._view_manager = ViewManager({}, self._engine.get_asset_universe()) 31 | self._chart_settings_control = ChartSettingsControl(*self._engine.get_dates()) 32 | self._view_manager.setMaximumWidth(300) 33 | self._view_manager.setMinimumWidth(300) 34 | 35 | def _initialise_controls(self): 36 | self._plot_chart() 37 | 38 | def _add_event_handlers(self): 39 | 40 | self._view_manager.view_changed.connect(self._plot_chart) 41 | self._chart_settings_control.dates_changed.connect(self._plot_chart) 42 | self._chart_settings_control.chart_type_changed.connect(self._change_chart_type) 43 | 44 | def _add_controls_to_layout(self): 45 | layout = QtWidgets.QGridLayout() 46 | title_label = QtWidgets.QLabel("Black Litterman Asset Allocation Tool") 47 | title_label.setFont(FontHelper.get_title_font()) 48 | 49 | layout.addWidget(title_label, 0, 0, 1, 1) 50 | layout.addWidget(self._main_chart, 1, 0, 1, 1) 51 | layout.addWidget(self._view_manager, 1, 1, 1, 1) 52 | layout.addWidget(self._chart_settings_control, 2, 0, 2, 1) 53 | 54 | self.layout = layout 55 | self.setLayout(self.layout) 56 | 57 | def _size_layout(self): 58 | self.layout.setColumnStretch(0, 1) 59 | self.layout.setColumnStretch(1, 9) 60 | self.layout.setColumnStretch(1, 1) 61 | 62 | def _plot_chart(self): 63 | 64 | start_date, end_date, chart_type = self._chart_settings_control.get_settings() 65 | asset_universe = self._engine.get_asset_universe() 66 | market_weights = self._engine.get_market_weights(end_date) 67 | implied_returns = self._engine.get_market_returns(start_date, end_date) 68 | all_views = self._view_manager.get_all_views() 69 | if all_views.is_empty(): 70 | self._main_chart.draw_charts(asset_universe, implied_returns, chart_type, 71 | market_weights) 72 | else: 73 | black_litterman_weights = self._engine.get_black_litterman_weights(all_views, start_date, end_date) 74 | self._main_chart.draw_charts(asset_universe, implied_returns, chart_type, 75 | market_weights, black_litterman_weights) 76 | 77 | def _change_chart_type(self): 78 | _, _, chart_type = self._chart_settings_control.get_settings() 79 | self._main_chart.select_chart(chart_type) 80 | 81 | @staticmethod 82 | def _read_config(): 83 | config_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "settings.json") 84 | with open(config_path) as config_file: 85 | configuration = json.load(config_file) 86 | 87 | return configuration 88 | 89 | 90 | if __name__ == "__main__": 91 | import sys 92 | 93 | app = QtWidgets.QApplication([]) 94 | app.setStyle("fusion") 95 | blw = BlackLittermanApp() 96 | blw.setWindowTitle("Black Litterman") 97 | blw.resize(1000, 500) 98 | blw.show() 99 | sys.exit(app.exec_()) 100 | -------------------------------------------------------------------------------- /tests/test_domain/test_views.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pandas as pd 3 | from black_litterman.domain.views import ViewAllocation, ViewCollection, View 4 | 5 | 6 | class TestViews(unittest.TestCase): 7 | 8 | @staticmethod 9 | def _get_view_collection(collection_type: str): 10 | 11 | view_allocation_1 = ViewAllocation("asset_2") 12 | view_1 = View("1", "view_1", 0.06, 0.5, view_allocation_1) 13 | view_allocation_2 = ViewAllocation("asset_1", "asset_3") 14 | view_2 = View("2", "view_2", 0.02, 0.9, view_allocation_2) 15 | view_allocation_3 = ViewAllocation("asset_3", "asset_2") 16 | view_3 = View("3", "view_3", 0.08, 0.2, view_allocation_3) 17 | 18 | view_collection = ViewCollection() 19 | if collection_type == "none": 20 | return view_collection 21 | elif collection_type == "absolute": 22 | view_collection.add_view(view_1) 23 | return view_collection 24 | elif collection_type == "relative": 25 | view_collection.add_view(view_2) 26 | view_collection.add_view(view_3) 27 | return view_collection 28 | else: 29 | view_collection.add_view(view_1) 30 | view_collection.add_view(view_2) 31 | view_collection.add_view(view_3) 32 | return view_collection 33 | 34 | def test_get_view_matrix_no_views(self): 35 | # arrange 36 | view_collection = self._get_view_collection("none") 37 | asset_universe = ["asset_1", "asset_2", "asset_3", "asset_4"] 38 | 39 | # act 40 | result = view_collection.get_view_matrix(asset_universe) 41 | 42 | # assert 43 | expected_result = pd.DataFrame() 44 | pd.testing.assert_frame_equal(expected_result, result) 45 | 46 | def test_get_view_matrix_absolute_view(self): 47 | # arrange 48 | view_collection = self._get_view_collection("absolute") 49 | asset_universe = ["asset_1", "asset_2", "asset_3", "asset_4"] 50 | 51 | # act 52 | result = view_collection.get_view_matrix(asset_universe) 53 | 54 | # assert 55 | expected_result = pd.Series([0, 1, 0, 0], index=asset_universe, name="1").to_frame().T 56 | pd.testing.assert_frame_equal(expected_result, result) 57 | 58 | def test_get_view_matrix_relative(self): 59 | # arrange 60 | view_collection = self._get_view_collection("relative") 61 | asset_universe = ["asset_1", "asset_2", "asset_3", "asset_4"] 62 | 63 | # act 64 | result = view_collection.get_view_matrix(asset_universe) 65 | 66 | # assert 67 | expected_result = pd.DataFrame([[1, 0, -1, 0], [0, -1, 1, 0]], index=["2", "3"], 68 | columns=asset_universe) 69 | pd.testing.assert_frame_equal(expected_result, result) 70 | 71 | def test_get_view_matrix_all_view_types(self): 72 | # arrange 73 | view_collection = self._get_view_collection("") 74 | asset_universe = ["asset_1", "asset_2", "asset_3", "asset_4"] 75 | 76 | # act 77 | result = view_collection.get_view_matrix(asset_universe) 78 | 79 | # assert 80 | expected_result = pd.DataFrame([[0, 1, 0, 0], [1, 0, -1, 0], [0, -1, 1, 0]], index=["1", "2", "3"], 81 | columns=asset_universe) 82 | pd.testing.assert_frame_equal(expected_result, result) 83 | 84 | def test_get_out_performance(self): 85 | # arrange 86 | view_collection = self._get_view_collection("") 87 | 88 | # act 89 | result = view_collection.get_view_out_performances() 90 | 91 | # assert 92 | expected_result = pd.Series({"1": 0.06, "2": 0.02, "3": 0.08}) 93 | pd.testing.assert_series_equal(expected_result, result) 94 | 95 | def test_get_view_cov_matrix(self): 96 | # arrange 97 | view_collection = self._get_view_collection("") 98 | 99 | # act 100 | result = view_collection.get_view_confidence_matrix() 101 | 102 | # assert 103 | expected_result = pd.DataFrame([[0.5, 0, 0], [0, 0.9, 0], [0, 0, 0.2]], 104 | index=["1", "2", "3"], columns=["1", "2", "3"]) 105 | pd.testing.assert_frame_equal(expected_result, result, check_dtype=False) 106 | 107 | 108 | -------------------------------------------------------------------------------- /black_litterman/ui/allocation_controls.py: -------------------------------------------------------------------------------- 1 | from PySide2 import QtWidgets 2 | from typing import List 3 | from black_litterman.domain.views import ViewAllocation 4 | 5 | 6 | class AllocationControlAbsolute(QtWidgets.QWidget): 7 | 8 | def __init__(self, 9 | allocation: ViewAllocation, 10 | asset_universe: List[str]): 11 | 12 | super().__init__() 13 | self._create_controls() 14 | self._initialise_controls(allocation, asset_universe) 15 | self._add_controls_to_layout() 16 | self._size_layout() 17 | 18 | def _create_controls(self) -> None: 19 | 20 | self._long_asset_combo = QtWidgets.QComboBox() 21 | self._long_asset_combo.setMinimumHeight(30) 22 | 23 | def _initialise_controls(self, 24 | allocation: ViewAllocation, 25 | asset_universe: List[str]) -> None: 26 | 27 | self._long_asset_combo.addItems(asset_universe) 28 | self._long_asset_combo.setCurrentText(allocation.long_asset) 29 | 30 | def _add_controls_to_layout(self): 31 | 32 | self.layout = QtWidgets.QGridLayout() 33 | self.setLayout(self.layout) 34 | 35 | # add main controls 36 | self.layout.addWidget(self._long_asset_combo, 0, 1) 37 | 38 | # add label controls 39 | self.layout.addWidget(QtWidgets.QLabel("Long asset:"), 0, 0) 40 | self.layout.addWidget(QtWidgets.QLabel(""), 1, 0) 41 | 42 | def _size_layout(self): 43 | 44 | self.layout.setColumnStretch(0, 2) 45 | self.layout.setColumnStretch(1, 10) 46 | self.layout.setRowStretch(0, 1) 47 | self.layout.setRowStretch(1, 10) 48 | 49 | def get_allocation(self): 50 | return ViewAllocation(self._long_asset_combo.currentText(), None) 51 | 52 | 53 | class AllocationControlRelative(QtWidgets.QWidget): 54 | 55 | def __init__(self, 56 | allocation: ViewAllocation, 57 | asset_universe: List[str]): 58 | 59 | super().__init__() 60 | self._create_controls() 61 | self._initialise_controls(allocation, asset_universe) 62 | self._add_controls_to_layout() 63 | self._size_layout() 64 | 65 | def _create_controls(self) -> None: 66 | 67 | self._long_asset_combo = QtWidgets.QComboBox() 68 | self._long_asset_combo.setMinimumHeight(30) 69 | 70 | self._short_asset_combo = QtWidgets.QComboBox() 71 | self._short_asset_combo.setMinimumHeight(30) 72 | 73 | def _initialise_controls(self, 74 | allocation: ViewAllocation, 75 | asset_universe: List[str]) -> None: 76 | 77 | self._long_asset_combo.addItems(asset_universe) 78 | self._long_asset_combo.setCurrentText(allocation.long_asset) 79 | 80 | self._short_asset_combo.addItems(asset_universe) 81 | self._short_asset_combo.setCurrentIndex(0) 82 | 83 | def _add_controls_to_layout(self): 84 | 85 | self.layout = QtWidgets.QGridLayout() 86 | self.setLayout(self.layout) 87 | 88 | # add main controls 89 | self.layout.addWidget(self._long_asset_combo, 0, 1) 90 | self.layout.addWidget(self._short_asset_combo, 1, 1) 91 | 92 | # add label controls 93 | self.layout.addWidget(QtWidgets.QLabel("Long asset:"), 0, 0) 94 | self.layout.addWidget(QtWidgets.QLabel("Short asset:"), 1, 0) 95 | self.layout.addWidget(QtWidgets.QLabel(""), 2, 0) 96 | 97 | def _size_layout(self): 98 | 99 | self.layout.setColumnStretch(0, 2) 100 | self.layout.setColumnStretch(1, 10) 101 | self.layout.setRowStretch(0, 1) 102 | self.layout.setRowStretch(1, 1) 103 | self.layout.setRowStretch(2, 10) 104 | 105 | def get_allocation(self): 106 | return ViewAllocation(self._long_asset_combo.currentText(), 107 | self._short_asset_combo.currentText()) 108 | 109 | 110 | if __name__ == "__main__": 111 | 112 | import sys 113 | from PySide2 import QtGui 114 | 115 | app = QtWidgets.QApplication([]) 116 | app.setFont(QtGui.QFont("Arial", 10)) 117 | 118 | v = ViewAllocation("asset_1", "asset_2") 119 | au = ["asset_1", "asset_2", "asset_3", "asset_4"] 120 | widget = AllocationControlRelative(v, au) 121 | widget.setWindowTitle("Add new view") 122 | widget.resize(350, 200) 123 | widget.show() 124 | 125 | sys.exit(app.exec_()) 126 | -------------------------------------------------------------------------------- /black_litterman/ui/chart_settings_control.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | from datetime import datetime 3 | import pandas as pd 4 | from PySide2 import QtWidgets, QtCore 5 | from black_litterman.ui.fonts import FontHelper 6 | 7 | 8 | class ChartTypes: 9 | 10 | WEIGHTS = "weights" 11 | RETURNS = "returns" 12 | 13 | @classmethod 14 | def get_chart_types(cls): 15 | return [cls.WEIGHTS, cls.RETURNS] 16 | 17 | 18 | class ChartSettingsControl(QtWidgets.QWidget): 19 | 20 | dates_changed = QtCore.Signal() 21 | chart_type_changed = QtCore.Signal() 22 | 23 | def __init__(self, 24 | start_date: str, 25 | end_date: str): 26 | 27 | super().__init__() 28 | self._create_controls() 29 | self._initialise_controls(start_date, end_date) 30 | self._add_event_handlers() 31 | self._add_controls_to_layout() 32 | self._size_layout() 33 | self._set_control_style() 34 | 35 | def _create_controls(self): 36 | 37 | self._chart_type_combo = QtWidgets.QComboBox() 38 | self._chart_type_combo.setMaximumWidth(100) 39 | 40 | self._start_date_edit = QtWidgets.QDateEdit() 41 | self._start_date_edit.setMaximumWidth(100) 42 | 43 | self._end_date_edit = QtWidgets.QDateEdit() 44 | self._end_date_edit.setMaximumWidth(100) 45 | 46 | def _initialise_controls(self, 47 | start_date: str, 48 | end_date: str): 49 | 50 | max_start_date = pd.to_datetime(end_date) - pd.offsets.MonthEnd(3) 51 | min_end_date = pd.to_datetime(start_date) + pd.offsets.MonthEnd(3) 52 | 53 | self._start_date_edit.setMinimumDate(QtCore.QDate.fromString(start_date, "yyyy-MM-dd")) 54 | self._start_date_edit.setDate(QtCore.QDate.fromString(start_date, "yyyy-MM-dd")) 55 | self._start_date_edit.setMaximumDate(QtCore.QDate.fromString(max_start_date.strftime("%Y-%m-%d"))) 56 | 57 | self._end_date_edit.setMinimumDate(QtCore.QDate.fromString(min_end_date.strftime("%Y-%m-%d"))) 58 | self._end_date_edit.setDate(QtCore.QDate.fromString(end_date, "yyyy-MM-dd")) 59 | self._end_date_edit.setMaximumDate(QtCore.QDate.fromString(end_date, "yyyy-MM-dd")) 60 | 61 | self._chart_type_combo.addItems(ChartTypes.get_chart_types()) 62 | self._chart_type_combo.setCurrentText(ChartTypes.WEIGHTS) 63 | 64 | def _add_event_handlers(self): 65 | 66 | self._start_date_edit.editingFinished.connect(self._start_date_updated) 67 | self._end_date_edit.editingFinished.connect(self._end_date_updated) 68 | self._chart_type_combo.currentTextChanged.connect(self._chart_type_changed) 69 | 70 | def _add_controls_to_layout(self): 71 | 72 | self._layout = QtWidgets.QGridLayout() 73 | self.setLayout(self._layout) 74 | 75 | combo_label = QtWidgets.QLabel("Show:") 76 | combo_label.setMaximumWidth(35) 77 | self._layout.addWidget(combo_label, 0, 0) 78 | self._layout.addWidget(self._chart_type_combo, 0, 1) 79 | 80 | start_date_label = QtWidgets.QLabel("History start:") 81 | start_date_label.setMaximumWidth(75) 82 | self._layout.addWidget(start_date_label, 0, 2) 83 | self._layout.addWidget(self._start_date_edit, 0, 3) 84 | 85 | end_date_label = QtWidgets.QLabel("Calculation Date:") 86 | end_date_label.setMaximumWidth(105) 87 | self._layout.addWidget(end_date_label, 0, 4) 88 | self._layout.addWidget(self._end_date_edit, 0, 5) 89 | 90 | def _size_layout(self): 91 | 92 | self._layout.setColumnMinimumWidth(0, 50) 93 | self._layout.setColumnMinimumWidth(1, 50) 94 | self._layout.setColumnMinimumWidth(2, 50) 95 | self._layout.setColumnMinimumWidth(3, 50) 96 | self._layout.setColumnMinimumWidth(4, 50) 97 | self._layout.setColumnMinimumWidth(5, 50) 98 | 99 | def _set_control_style(self): 100 | 101 | self._chart_type_combo.setFont(FontHelper.get_text_font()) 102 | self._start_date_edit.setFont(FontHelper.get_text_font()) 103 | self._end_date_edit.setFont(FontHelper.get_text_font()) 104 | 105 | def _start_date_updated(self): 106 | 107 | new_start_date = self._start_date_edit.date().toPython() 108 | new_max_end_date = new_start_date - pd.offsets.MonthEnd(3) 109 | self._end_date_edit.setMaximumDate(QtCore.QDate.fromString(new_max_end_date.strftime("%Y-%m-%d"))) 110 | self.dates_changed.emit() 111 | 112 | def _end_date_updated(self): 113 | 114 | new_end_date = self._end_date_edit.date().toPython() 115 | new_min_start_date = new_end_date + pd.offsets.MonthEnd(3) 116 | self._start_date_edit.setMinimumDate(QtCore.QDate.fromString(new_min_start_date.strftime("%Y-%m-%d"))) 117 | self.dates_changed.emit() 118 | 119 | def _chart_type_changed(self): 120 | self.chart_type_changed.emit() 121 | 122 | def get_settings(self) -> Tuple[str, str, str]: 123 | 124 | start_date = self._start_date_edit.date().toString("yyyy-MM-dd") 125 | end_date = self._end_date_edit.date().toString("yyyy-MM-dd") 126 | chart_type = self._chart_type_combo.currentText() 127 | 128 | return start_date, end_date, chart_type 129 | -------------------------------------------------------------------------------- /black_litterman/ui/portfolio_chart.py: -------------------------------------------------------------------------------- 1 | import math 2 | import pandas as pd 3 | from typing import List, Optional 4 | from PySide2 import QtWidgets, QtCore, QtGui 5 | from PySide2.QtCharts import QtCharts 6 | from black_litterman.ui.chart_settings_control import ChartTypes 7 | 8 | 9 | class PortfolioChart(QtWidgets.QWidget): 10 | 11 | def __init__(self): 12 | 13 | super().__init__() 14 | self._create_controls() 15 | self._add_controls_to_layout() 16 | 17 | def _create_controls(self) -> None: 18 | 19 | self._weights_chart_view = QtCharts.QChartView() 20 | self._returns_chart_view = QtCharts.QChartView() 21 | 22 | def _add_controls_to_layout(self) -> None: 23 | 24 | self._chart_stack = QtWidgets.QStackedLayout() 25 | self._chart_stack.addWidget(self._weights_chart_view) 26 | self._chart_stack.addWidget(self._returns_chart_view) 27 | 28 | self._layout = QtWidgets.QGridLayout() 29 | self._layout.addLayout(self._chart_stack, 0, 0) 30 | self.setLayout(self._layout) 31 | 32 | def select_chart(self, 33 | selected_chart_type: str) -> None: 34 | 35 | selected_index = self._get_index_for_chart_type(selected_chart_type) 36 | self._chart_stack.setCurrentIndex(selected_index) 37 | 38 | def draw_charts(self, 39 | asset_universe: List[str], 40 | implied_returns: pd.Series, 41 | selected_chart_type: Optional[str] = ChartTypes.WEIGHTS, 42 | *args: pd.Series) -> None: 43 | 44 | # asset_universe = [s.replace(" ", "
") for s in asset_universe] # no word wrap so add line break 45 | self._set_weights_chart(asset_universe, *args) 46 | self._set_returns_chart(asset_universe, implied_returns) 47 | self.select_chart(selected_chart_type) 48 | 49 | def _set_weights_chart(self, 50 | asset_universe: List[str], 51 | *args: pd.Series) -> None: 52 | 53 | bar_series = QtCharts.QBarSeries() 54 | y_min = 0 55 | y_max = 0 56 | 57 | for weights in args: 58 | 59 | weights = weights.reindex(asset_universe) 60 | y_min = min(weights.min() * 100, y_min) 61 | y_max = max(weights.max() * 100, y_max) 62 | 63 | bar_set = QtCharts.QBarSet(str(weights.name)) 64 | bar_set.append(weights.mul(100).values.tolist()) 65 | bar_series.append(bar_set) 66 | 67 | # configure basic chart 68 | chart = QtCharts.QChart() 69 | chart.setTitle("Black-Litterman Asset Allocation") 70 | title_font = QtGui.QFont() 71 | title_font.setBold(True) 72 | chart.setFont(title_font) 73 | chart.addSeries(bar_series) 74 | 75 | # configure the x axis 76 | axis_x = QtCharts.QBarCategoryAxis() 77 | axis_x.append([s.replace(" ", "
") for s in asset_universe]) 78 | chart.createDefaultAxes() 79 | chart.setAxisX(axis_x) 80 | 81 | # configure the y axis 82 | axis_y = QtCharts.QValueAxis() 83 | self._set_y_axis_limits(y_max, y_min, axis_y) 84 | axis_y.setLabelFormat("%.0f") 85 | axis_y.setTitleText("Suggested Allocation (%)") 86 | chart.setAxisY(axis_y) 87 | bar_series.attachAxis(axis_y) 88 | 89 | # configure chart legend 90 | chart.legend().setVisible(True) 91 | chart.legend().setAlignment(QtCore.Qt.AlignBottom) 92 | 93 | self._weights_chart_view.setChart(chart) 94 | 95 | def _set_returns_chart(self, 96 | asset_universe: List[str], 97 | implied_returns: pd.Series) -> None: 98 | 99 | implied_returns = implied_returns.reindex(asset_universe) 100 | bar_series = QtCharts.QBarSeries() 101 | bar_set = QtCharts.QBarSet("Returns") 102 | bar_set.append(implied_returns.mul(100).values.tolist()) 103 | bar_series.append(bar_set) 104 | 105 | # configure basic chart 106 | chart = QtCharts.QChart() 107 | chart.setTitle("Market Implied Expected Returns") 108 | title_font = QtGui.QFont() 109 | title_font.setBold(True) 110 | chart.setFont(title_font) 111 | chart.addSeries(bar_series) 112 | 113 | # configure the x axis 114 | axis_x = QtCharts.QBarCategoryAxis() 115 | axis_x.append([s.replace(" ", "
") for s in asset_universe]) 116 | chart.createDefaultAxes() 117 | chart.setAxisX(axis_x) 118 | 119 | # configure the y axis 120 | y_min = implied_returns.min() * 100 121 | y_max = implied_returns.max() * 100 122 | axis_y = QtCharts.QValueAxis() 123 | self._set_y_axis_limits(y_max, y_min, axis_y, 2) 124 | axis_y.setLabelFormat("%.0f") 125 | axis_y.setTitleText("Expected Return (%pa)") 126 | chart.setAxisY(axis_y) 127 | bar_series.attachAxis(axis_y) 128 | 129 | self._returns_chart_view.setChart(chart) 130 | 131 | def _set_y_axis_limits(self, 132 | y_max: float, 133 | y_min: float, 134 | axis_y: QtCharts.QValueAxis, 135 | round_to: int = 10) -> None: 136 | 137 | y_max_rounded = self._round_axis_limit(y_max, round_to) 138 | y_min_rounded = self._round_axis_limit(y_min, round_to) 139 | intervals = (y_max_rounded - y_min_rounded) // round_to + 1 140 | 141 | axis_y.setMax(y_max_rounded) 142 | axis_y.setMin(y_min_rounded) 143 | axis_y.setTickCount(intervals) 144 | 145 | @staticmethod 146 | def _round_axis_limit(lim: float, 147 | round_to: int = 10): 148 | 149 | if lim == 0: 150 | return 0 151 | 152 | rounded_lim = (math.floor(abs(lim) / round_to) + 1) * round_to 153 | if lim < 0: 154 | return -rounded_lim 155 | else: 156 | return rounded_lim 157 | 158 | @staticmethod 159 | def _get_index_for_chart_type(chart_type: str): 160 | """ 161 | convert the chart type to the index 162 | of the stacked chart 163 | """ 164 | 165 | if chart_type == ChartTypes.WEIGHTS: 166 | return 0 167 | elif chart_type == ChartTypes.RETURNS: 168 | return 1 169 | else: 170 | raise ValueError(f"Unrecognised chart type {chart_type}") 171 | -------------------------------------------------------------------------------- /black_litterman/ui/view_designer_control.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from PySide2 import QtWidgets, QtCore 3 | from black_litterman.ui.allocation_controls import AllocationControlRelative, AllocationControlAbsolute 4 | from black_litterman.domain.views import View, ViewAllocation 5 | 6 | 7 | class ViewDesignerDialog(QtWidgets.QDialog): 8 | 9 | def __init__(self, 10 | view: View, 11 | asset_universe: List[str]): 12 | 13 | super().__init__() 14 | self._view = view 15 | self._asset_universe = asset_universe 16 | self._create_controls() 17 | self._initialise_controls(view, asset_universe) 18 | self._add_event_handlers() 19 | self._add_controls_to_layout() 20 | self._size_layout() 21 | self.setWindowTitle("Edit market view") 22 | 23 | def _create_controls(self) -> None: 24 | 25 | self._name_box = QtWidgets.QLineEdit() 26 | self._name_box.setFixedHeight(30) 27 | 28 | self._view_type_combo = QtWidgets.QComboBox() 29 | self._view_type_combo.setFixedHeight(30) 30 | 31 | self._confidence_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) 32 | self._confidence_slider.setFixedHeight(30) 33 | self._slider_label = QtWidgets.QLabel() 34 | self._slider_label.setFixedHeight(30) 35 | 36 | self._outperf_up_down = QtWidgets.QDoubleSpinBox() 37 | self._outperf_up_down.setFixedHeight(30) 38 | 39 | self._save_button = QtWidgets.QPushButton("Save") 40 | self._save_button.setMinimumHeight(30) 41 | self._save_button.setMaximumWidth(100) 42 | 43 | self._exit_button = QtWidgets.QPushButton("Exit") 44 | self._exit_button.setMinimumHeight(30) 45 | self._exit_button.setMaximumWidth(100) 46 | 47 | self._allocation_group = QtWidgets.QGroupBox() 48 | v_box = QtWidgets.QVBoxLayout() 49 | self._allocation_group.setLayout(v_box) 50 | 51 | def _initialise_controls(self, 52 | view: View, 53 | asset_universe: List[str]) -> None: 54 | 55 | self._name_box.setText(view.name) 56 | 57 | self._view_type_combo.addItems(ViewAllocation.get_all_view_types()) 58 | 59 | self._confidence_slider.setMinimum(0) 60 | self._confidence_slider.setMaximum(10) 61 | self._confidence_slider.setTickInterval(1) 62 | self._confidence_slider.setFixedHeight(30) 63 | self._confidence_slider.setSliderPosition(int(view.confidence * 10)) 64 | 65 | self.slider_label = QtWidgets.QLabel("{:.0%}".format(view.confidence/10)) 66 | 67 | self._outperf_up_down.setMinimum(-10) 68 | self._outperf_up_down.setMaximum(10) 69 | self._outperf_up_down.setDecimals(1) 70 | self._outperf_up_down.setSingleStep(0.1) 71 | self._outperf_up_down.setValue(view.out_performance) 72 | 73 | self._allocation_group.setTitle("View allocation") 74 | 75 | if view.allocation.view_type == ViewAllocation.ABSOLUTE: 76 | self._allocation_control = AllocationControlAbsolute(view.allocation, asset_universe) 77 | else: 78 | self._allocation_control = AllocationControlRelative(view.allocation, asset_universe) 79 | self._allocation_group.layout().addWidget(self._allocation_control) 80 | 81 | def _add_event_handlers(self): 82 | 83 | self._confidence_slider.valueChanged.connect(self._display_confidence) 84 | self._view_type_combo.currentTextChanged.connect(self._set_allocation_control) 85 | self._save_button.clicked.connect(self.on_click_ok) 86 | self._exit_button.clicked.connect(self.reject) 87 | 88 | def _add_controls_to_layout(self): 89 | 90 | self.layout = QtWidgets.QGridLayout() 91 | 92 | # add main controls 93 | self.layout.addWidget(self._name_box, 0, 1) 94 | self.layout.addWidget(self._confidence_slider, 1, 1) 95 | self.layout.addWidget(self.slider_label, 1, 2) 96 | self.layout.addWidget(self._view_type_combo, 3, 1) 97 | self.layout.addWidget(self._outperf_up_down, 2, 1) 98 | self.layout.addWidget(self._allocation_group, 4, 0, 1, 3) 99 | self.layout.addWidget(self._save_button, 5, 0) 100 | self.layout.addWidget(self._exit_button, 5, 1) 101 | 102 | # add control labels 103 | self.layout.addWidget(QtWidgets.QLabel("View name"), 0, 0) 104 | self.layout.addWidget(QtWidgets.QLabel("Confidence"), 1, 0) 105 | self.layout.addWidget(QtWidgets.QLabel("View type"), 3, 0) 106 | self.layout.addWidget(QtWidgets.QLabel("Return (%pa)"), 2, 0) 107 | self.setLayout(self.layout) 108 | 109 | def _size_layout(self): 110 | self.layout.setRowStretch(0, 1) 111 | self.layout.setRowStretch(1, 1) 112 | self.layout.setRowStretch(2, 1) 113 | self.layout.setRowStretch(3, 10) 114 | self.layout.setColumnStretch(0, 1) 115 | self.layout.setColumnStretch(1, 10) 116 | self.layout.setColumnMinimumWidth(1, 200) 117 | self.layout.setColumnStretch(2, 1) 118 | 119 | def _display_confidence(self, 120 | val): 121 | 122 | self.slider_label.setText("{:.0%}".format(val/10)) 123 | 124 | def _set_allocation_control(self, 125 | allocation_type): 126 | 127 | self._allocation_group.layout().removeWidget(self._allocation_control) 128 | self._allocation_control.deleteLater() 129 | self._allocation_control = None 130 | 131 | if allocation_type == ViewAllocation.ABSOLUTE: 132 | self._allocation_control = AllocationControlAbsolute(self._view.allocation, 133 | self._asset_universe) 134 | else: 135 | self._allocation_control = AllocationControlRelative(self._view.allocation, 136 | self._asset_universe) 137 | 138 | self._allocation_group.layout().addWidget(self._allocation_control) 139 | 140 | def on_click_ok(self): 141 | self._view = self._get_view_from_controls() 142 | self.accept() 143 | 144 | def _get_view_from_controls(self) -> View: 145 | 146 | view_id = self._view.id 147 | name = self._name_box.text() 148 | out_performance = self._outperf_up_down.value() / 100 149 | confidence = self._confidence_slider.value() / 10 150 | allocation = self._allocation_control.get_allocation() 151 | 152 | view = View(view_id, name, out_performance, confidence, allocation) 153 | return view 154 | 155 | def get_view(self): 156 | return self._view 157 | 158 | -------------------------------------------------------------------------------- /black_litterman/market_data/data_readers.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from logging import getLogger 3 | from typing import Dict, List 4 | from abc import ABC, abstractmethod 5 | from black_litterman.market_data.engine import MarketDataEngine 6 | from black_litterman.constants import Configuration, MarketData 7 | from cardano.market_data.market_data_client import MarketDataClient 8 | 9 | logger = getLogger() 10 | 11 | 12 | class BaseDataReader(ABC): 13 | 14 | @abstractmethod 15 | def _read_raw_data(self, 16 | start_date: str, 17 | end_date: str) -> Dict[str, pd.DataFrame]: 18 | """ 19 | read in the raw data from 20 | local source 21 | """ 22 | 23 | @abstractmethod 24 | def _validate_data(self, 25 | raw_data: Dict[str, pd.DataFrame]) -> None: 26 | """ 27 | check raw data for incorrect 28 | values 29 | """ 30 | 31 | @abstractmethod 32 | def _get_formatted_data(self, 33 | raw_data: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]: 34 | """ 35 | apply type formatting 36 | to the data 37 | """ 38 | 39 | def get_market_data_engine(self, 40 | start_date: str, 41 | end_date: str) -> MarketDataEngine: 42 | """ 43 | read market data an wrap in engine class 44 | """ 45 | 46 | raw_data = self._read_raw_data(start_date, end_date) 47 | self._validate_data(raw_data) 48 | formatted_data = self._get_formatted_data(raw_data) 49 | data_engine = MarketDataEngine(formatted_data[MarketData.PRICE_DATA], 50 | formatted_data[MarketData.MARKET_CAP_DATA]) 51 | return data_engine 52 | 53 | 54 | class LocalDataReader(BaseDataReader): 55 | """ 56 | read in data from a local spreadsheet 57 | """ 58 | 59 | def __init__(self, 60 | data_file_path): 61 | 62 | self._path = data_file_path 63 | 64 | def _read_raw_data(self, 65 | start_date: str, 66 | end_date: str) -> Dict[str, pd.DataFrame]: 67 | 68 | raw_data = pd.read_excel(self._path, sheet_name=MarketData.get_data_types(), index_col=0) 69 | raw_data = raw_data.loc[start_date: end_date, :] 70 | return raw_data 71 | 72 | def _validate_data(self, raw_data: Dict[str, pd.DataFrame]) -> None: 73 | 74 | pass 75 | # TODO: should add in some validation here 76 | 77 | def _get_formatted_data(self, raw_data: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]: 78 | 79 | for data in raw_data.values(): 80 | data.index = pd.to_datetime(data.index) 81 | 82 | return raw_data 83 | 84 | 85 | class SqlDataReader(BaseDataReader): 86 | """ 87 | read in data from SQL server database 88 | """ 89 | 90 | def _read_raw_data(self, 91 | start_date: str, 92 | end_date: str): 93 | raise NotImplementedError() 94 | 95 | def _validate_data(self, 96 | raw_data: Dict[str, pd.DataFrame]) -> None: 97 | raise NotImplementedError() 98 | 99 | def _get_formatted_data(self, 100 | raw_data: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]: 101 | raise NotImplementedError() 102 | 103 | 104 | class ReutersDataReader(BaseDataReader): 105 | """ 106 | read in data from the Thompson Reuters 107 | market data API 108 | """ 109 | 110 | def __init__(self, 111 | credentials: Dict[str, str], 112 | tickers: Dict[str, str]): 113 | self._mdc = MarketDataClient(credentials=credentials) 114 | self._tickers = tickers 115 | 116 | def _read_raw_data(self, 117 | start_date: str, 118 | end_date: str) -> Dict[str, pd.DataFrame]: 119 | 120 | n = len(self._tickers) 121 | tickers = [v[0] for v in self._tickers.values()] 122 | 123 | price_requests = list(zip(tickers, ["PI"] * n, [start_date] * n, [end_date] * n)) 124 | price_requests = pd.DataFrame(price_requests, columns=self._mdc.get_reuters_input_headers()) 125 | self._mdc.add_reuters_data(price_requests) 126 | 127 | market_cap_requests = list(zip(tickers, ["X(MV)~GBP"] * n, [start_date] * n, [end_date] * n)) 128 | market_cap_requests = pd.DataFrame(market_cap_requests, columns=self._mdc.get_reuters_input_headers()) 129 | self._mdc.add_reuters_data(market_cap_requests) 130 | 131 | raw_data = self._mdc.get_data_as_dataframe() 132 | price_data = raw_data[raw_data["field"] == "PI"] 133 | market_cap_data = raw_data[raw_data["field"] == "X(MV)~GBP"] 134 | return {MarketData.PRICE_DATA: price_data, MarketData.MARKET_CAP_DATA: market_cap_data} 135 | 136 | def _validate_data(self, raw_data: Dict[str, pd.DataFrame]) -> None: 137 | # TODO: need to add some market data validation 138 | pass 139 | 140 | def _get_formatted_data(self, raw_data: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]: 141 | 142 | rename = {v[0]: k for k, v in self._tickers.items()} 143 | cap_scalings = {ticker: value[1] for ticker, value in self._tickers.items()} 144 | 145 | all_formatted_data = {} 146 | for data_type, data in raw_data.items(): 147 | formatted_data = pd.pivot_table(data, columns="ticker", index="date", values="value") 148 | formatted_data.index = pd.to_datetime(formatted_data.index) 149 | formatted_data.rename(columns=rename, inplace=True) 150 | all_formatted_data.update({data_type: formatted_data}) 151 | 152 | all_formatted_data[MarketData.MARKET_CAP_DATA] *= cap_scalings 153 | 154 | return all_formatted_data 155 | 156 | 157 | class DataReaderFactory: 158 | 159 | SOURCE_LOCAL = "local" 160 | SOURCE_SQL = "sql" 161 | SOURCE_REUTERS = "reuters" 162 | 163 | @classmethod 164 | def get_valid_sources(cls) -> List[str]: 165 | 166 | return [cls.SOURCE_LOCAL, cls.SOURCE_SQL] 167 | 168 | @classmethod 169 | def get_data_reader(cls, 170 | config: Dict) -> BaseDataReader: 171 | 172 | config_data = config[Configuration.MARKET_DATA] 173 | data_source = config_data.get(Configuration.MARKET_DATA_SOURCE, "Not Defined") 174 | 175 | if data_source == cls.SOURCE_LOCAL: 176 | return LocalDataReader(config_data[Configuration.MARKET_DATA_FILE_PATH]) 177 | elif data_source == cls.SOURCE_SQL: 178 | return SqlDataReader() 179 | elif data_source == cls.SOURCE_REUTERS: 180 | return ReutersDataReader(config[Configuration.CREDENTIALS], 181 | config_data[Configuration.ASSET_UNIVERSE]) 182 | else: 183 | err_msg = f"Data source '{data_source}' is not recognised - valid sources " \ 184 | f"are {', '.join(cls.get_valid_sources())}" 185 | logger.error(err_msg) 186 | raise ValueError(err_msg) 187 | -------------------------------------------------------------------------------- /tests/test_domain/test_engine.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pandas as pd 3 | from unittest import mock 4 | from black_litterman.domain.engine import BLEngine, CalculationSettings 5 | from black_litterman.domain.views import View, ViewAllocation, ViewCollection 6 | 7 | 8 | class TestEngine(unittest.TestCase): 9 | 10 | @staticmethod 11 | def _get_market_data(): 12 | asset_universe = ["asset_1", "asset_2", "asset_3"] 13 | market_cov = pd.DataFrame([[0.18, -0.04, 0], [-0.04, 0.05, 0.07], [0, 0.07, 0.11]], 14 | index=asset_universe, columns=asset_universe) 15 | market_weights = pd.Series([0.3, 0.5, 0.2], index=asset_universe) 16 | 17 | return market_cov, market_weights 18 | 19 | @staticmethod 20 | def _get_bl_engine(): 21 | calc_settings = CalculationSettings(1, 3, None, None, ["asset_1", "asset_2", "asset_3"]) 22 | mock_data_reader = mock.MagicMock() 23 | engine = BLEngine(mock_data_reader, calc_settings) 24 | return engine 25 | 26 | def test_get_bl_weights_absolute_view(self): 27 | # arrange 28 | market_cov, market_weights = self._get_market_data() 29 | engine = self._get_bl_engine() 30 | view_matrix = pd.Series([1, 0, 0], index=market_cov.index, name="view_1").to_frame().T 31 | view_cov = pd.DataFrame([[0.05]], index=["view_1"], columns=["view_1"]) 32 | view_outperf = pd.Series([0.2], index=["view_1"]) 33 | 34 | # act 35 | result = engine._get_weights(market_weights, market_cov, view_matrix, view_cov, 36 | view_outperf) 37 | 38 | # assert 39 | expected_result = pd.Series([0.442028986, 0.5, 0.2], index=market_cov.index) 40 | pd.testing.assert_series_equal(expected_result, result) 41 | 42 | def test_get_bl_weights_relative_view(self): 43 | # arrange 44 | market_cov, market_weights = self._get_market_data() 45 | engine = self._get_bl_engine() 46 | view_matrix = pd.Series([0, -1, 1], index=market_cov.index, name="view_1").to_frame().T 47 | view_cov = pd.DataFrame([0.1], index=["view_1"], columns=["view_1"]) 48 | view_outperf = pd.Series([0.06], index=["view_1"]) 49 | 50 | # act 51 | result = engine._get_weights(market_weights, market_cov, view_matrix, view_cov, 52 | view_outperf) 53 | 54 | # assert 55 | expected_result = pd.Series([0.3, 0.5833333, 0.1166667], index=market_cov.index) 56 | pd.testing.assert_series_equal(expected_result, result) 57 | 58 | def test_get_bl_weights_multiple_views(self): 59 | # arrange 60 | market_cov, market_weights = self._get_market_data() 61 | engine = self._get_bl_engine() 62 | view_matrix = pd.DataFrame([[0, -1, 1], [1, 0, 0], [-1, 0, 1]], index=["view_1", "view_2", "view_3"], 63 | columns=market_cov.index) 64 | view_cov = pd.DataFrame([[0.1, 0, 0], [0, 0.05, 0], [0, 0, 0.04]], index=["view_1", "view_2", "view_3"], 65 | columns=["view_1", "view_2", "view_3"]) 66 | view_outperf = pd.Series([0.05, 0.09, 0.08], index=["view_1", "view_2", "view_3"]) 67 | 68 | # act 69 | result = engine._get_weights(market_weights, market_cov, view_matrix, view_cov, 70 | view_outperf) 71 | 72 | # assert 73 | expected_result = pd.Series([0.2982666, 0.6179881, 0.1043762], index=market_cov.index) 74 | pd.testing.assert_series_equal(expected_result, result) 75 | 76 | def test_get_sum_squares(self): 77 | # arrange 78 | series_1 = pd.Series([0.5, 0.25, 0.75, 0.3]) 79 | series_2 = pd.Series([0.2, 0.3, 0.75, 0.4]) 80 | 81 | # act 82 | result = BLEngine._get_sum_squares_error(series_1, series_2) 83 | 84 | # assert 85 | self.assertAlmostEqual(0.1025, result) 86 | 87 | def test_get_target_weights_relative_view(self): 88 | # arrange 89 | test_view = View("1", "test_view", 0.08, 0.5, ViewAllocation("asset_3", "asset_2")) 90 | market_cov, market_weights = self._get_market_data() 91 | engine = self._get_bl_engine() 92 | view_matrix = test_view.get_view_data_frame(["asset_1", "asset_2", "asset_3"]) 93 | view_out_performance = pd.Series([0.08], index=["1"]) 94 | 95 | # act 96 | result = engine._get_view_target_weights(test_view, market_weights, market_cov, 97 | view_matrix, view_out_performance) 98 | 99 | # assert 100 | expected_result = pd.Series([0.3, 0.58333333, 0.11666667], index=["asset_1", "asset_2", "asset_3"]) 101 | pd.testing.assert_series_equal(expected_result, result) 102 | 103 | def test_get_target_weights_absolute_view(self): 104 | # arrange 105 | test_view = View("1", "test_view", 0.13, 0.8, ViewAllocation("asset_1", None)) 106 | market_cov, market_weights = self._get_market_data() 107 | engine = self._get_bl_engine() 108 | view_matrix = test_view.get_view_data_frame(["asset_1", "asset_2", "asset_3"]) 109 | view_out_performance = pd.Series([0.13], index=["1"]) 110 | 111 | # act 112 | result = engine._get_view_target_weights(test_view, market_weights, market_cov, 113 | view_matrix, view_out_performance) 114 | 115 | # assert 116 | expected_result = pd.Series([0.3414814815, 0.5, 0.2], index=["asset_1", "asset_2", "asset_3"]) 117 | pd.testing.assert_series_equal(expected_result, result) 118 | 119 | def test_confidence_to_variance_absolute_view(self): 120 | # arrange 121 | view = View("test_view", "test_view", 0.13, 0.5, ViewAllocation("asset_1")) 122 | market_cov, market_weights = self._get_market_data() 123 | bl_engine = self._get_bl_engine() 124 | 125 | # act 126 | result = bl_engine._confidence_to_variance(view, market_weights, market_cov) 127 | 128 | # assert 129 | self.assertAlmostEqual(0.18, result, delta=1e-4) 130 | 131 | def test_confidence_to_variance_relative_view(self): 132 | # arrange 133 | view = View("test_view", "test_view", 0.06, 0.3, ViewAllocation("asset_3", "asset_2")) 134 | market_cov, market_weights = self._get_market_data() 135 | bl_engine = self._get_bl_engine() 136 | 137 | # act 138 | result = bl_engine._confidence_to_variance(view, market_weights, market_cov) 139 | 140 | # assert 141 | self.assertAlmostEqual(0.04667, result, delta=1e-4) 142 | 143 | def test_get_view_covariances_from_confidences(self): 144 | # arrange 145 | view_collection = ViewCollection() 146 | view_collection.add_view(View("view_1", "view_1", 0.14, 0.6, ViewAllocation("asset_1"))) 147 | view_collection.add_view(View("view_2", "view_2", 0.06, 0.3, ViewAllocation("asset_3", "asset_2"))) 148 | 149 | engine = self._get_bl_engine() 150 | mock_conf_to_var = mock.MagicMock() 151 | mock_conf_to_var.side_effect = [0.1, 0.05] 152 | engine._confidence_to_variance = mock_conf_to_var 153 | 154 | market_cov, market_weight = self._get_market_data() 155 | 156 | # act 157 | result = engine.get_view_covariances_from_confidences(market_weight, market_cov, view_collection) 158 | 159 | # assert 160 | expected_result = pd.DataFrame([[0.1, 0], [0, 0.05]], index=["view_1", "view_2"], columns=["view_1", "view_2"]) 161 | pd.testing.assert_frame_equal(expected_result, result) 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | -------------------------------------------------------------------------------- /black_litterman/domain/engine.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from scipy import optimize 4 | from typing import List, Dict, Any, Tuple, Optional 5 | from dataclasses import dataclass 6 | from black_litterman.market_data.data_readers import BaseDataReader 7 | from black_litterman.domain.views import ViewCollection, View 8 | from black_litterman.constants import Configuration, Weights 9 | 10 | 11 | @dataclass(frozen=True) 12 | class CalculationSettings: 13 | tau: float 14 | risk_aversion: float 15 | start_date: str 16 | calculation_date: str 17 | asset_universe: Dict[str, str] 18 | 19 | @staticmethod 20 | def parse_from_config(config: Dict[str, Any]) -> "CalculationSettings": 21 | 22 | config_params = config[Configuration.PARAMETERS] 23 | config_data = config[Configuration.MARKET_DATA] 24 | 25 | calc_settings = CalculationSettings(config_params[Configuration.TAU], 26 | config_params[Configuration.RISK_AVERSION], 27 | config_data[Configuration.FIRST_DATE], 28 | config_data[Configuration.LAST_DATE], 29 | config_data[Configuration.ASSET_UNIVERSE]) 30 | return calc_settings 31 | 32 | 33 | class BLEngine: 34 | 35 | def __init__(self, 36 | data_reader: BaseDataReader, 37 | calc_settings: CalculationSettings): 38 | 39 | self._market_data_engine = data_reader.get_market_data_engine(calc_settings.start_date, 40 | calc_settings.calculation_date) 41 | self._calc_settings = calc_settings 42 | 43 | def get_market_weights(self, 44 | end_date: Optional[str] = None) -> pd.Series: 45 | """ 46 | return the implied market clearing weights 47 | """ 48 | 49 | if end_date is None: 50 | end_date = self._calc_settings.calculation_date 51 | 52 | weights = self._market_data_engine.get_market_weights(end_date) 53 | weights.name = Weights.MARKET 54 | return weights 55 | 56 | def get_market_returns(self, 57 | start_date: str, 58 | end_date: str) -> pd.Series: 59 | """ 60 | return the implied market clearing expected returns 61 | """ 62 | 63 | return self._market_data_engine.get_implied_returns(start_date, end_date, self._calc_settings.risk_aversion) 64 | 65 | def get_asset_universe(self) -> List[str]: 66 | """ 67 | return the names of the current available assets from the 68 | calculation settings 69 | """ 70 | 71 | return list(self._calc_settings.asset_universe.keys()) 72 | 73 | def get_dates(self) -> Tuple[str, str]: 74 | """ 75 | get the start and end date from the calc settings 76 | """ 77 | 78 | return self._calc_settings.start_date, self._calc_settings.calculation_date 79 | 80 | def get_black_litterman_weights(self, 81 | view_collection: ViewCollection, 82 | start_date: str, 83 | end_date: str) -> pd.Series: 84 | """ 85 | derive target portfolio weights based on the Black-Litterman 86 | portfolio optimisation model 87 | """ 88 | 89 | # get the market data 90 | market_weights = self._market_data_engine.get_market_weights(end_date) 91 | market_cov = self._market_data_engine.get_annualised_cov_matrix(start_date, end_date) 92 | 93 | # get the view specific data 94 | view_mat = view_collection.get_view_matrix(list(self._calc_settings.asset_universe)) 95 | view_out_performance = view_collection.get_view_out_performances() 96 | view_cov = self.get_view_covariances_from_confidences(market_weights, market_cov, view_collection) 97 | 98 | # calc BL weights 99 | bl_weights = self._get_weights(market_weights, market_cov, view_mat, view_cov, view_out_performance) 100 | bl_weights.name = Weights.BLACK_LITTERMAN 101 | return bl_weights 102 | 103 | def get_view_covariances_from_confidences(self, 104 | market_weights: pd.Series, 105 | market_covariance: pd.DataFrame, 106 | view_collection: ViewCollection) -> pd.DataFrame: 107 | """ 108 | build a diagonal covariance matrix from the views 109 | based on the confidence in each view 110 | """ 111 | 112 | cov_by_view = dict() 113 | all_views = view_collection.get_all_views() 114 | 115 | for view in all_views: 116 | var = self._confidence_to_variance(view, market_weights, market_covariance) 117 | cov_by_view.update({view.id: var}) 118 | 119 | var_series = pd.Series(cov_by_view) 120 | cov_matrix = pd.DataFrame(np.diag(var_series), index=var_series.index, columns=var_series.index) 121 | return cov_matrix 122 | 123 | def _get_weights(self, 124 | market_weights: pd.Series, 125 | market_cov: pd.DataFrame, 126 | view_matrix: pd.DataFrame, 127 | view_cov: pd.DataFrame, 128 | view_out_performance: pd.Series) -> pd.Series: 129 | """ 130 | Black-Litterman calculation to derive target weights 131 | """ 132 | 133 | try: 134 | mat_1 = (view_cov.divide(self._calc_settings.tau) + 135 | view_matrix.dot(market_cov).dot(view_matrix.T)) 136 | except ValueError: 137 | print("matrix not aligned?") 138 | mat_1_inv = pd.DataFrame(np.linalg.inv(mat_1.values), 139 | index=mat_1.index, columns=mat_1.index) 140 | mat_2 = (view_out_performance.divide(self._calc_settings.risk_aversion) 141 | - view_matrix.dot(market_cov).dot(market_weights)) 142 | 143 | bl_weights = market_weights + view_matrix.T.dot(mat_1_inv).dot(mat_2) 144 | return bl_weights 145 | 146 | def _get_view_target_weights(self, 147 | view: View, 148 | market_weights: pd.Series, 149 | market_covariance: pd.DataFrame, 150 | view_matrix: pd.DataFrame, 151 | view_out_performance: pd.Series) -> pd.Series: 152 | """ 153 | get target weights based on the view allocation and 154 | stated confidence in the view 155 | """ 156 | 157 | zero_view_cov = pd.DataFrame([0], index=[view.id], columns=[view.id]) 158 | full_confidence_weights = self._get_weights(market_weights, market_covariance, view_matrix, zero_view_cov, 159 | view_out_performance) 160 | max_weight_difference = full_confidence_weights - market_weights 161 | target_weights = market_weights.add(view.confidence * max_weight_difference) 162 | 163 | return target_weights 164 | 165 | @staticmethod 166 | def _get_sum_squares_error(series_1: pd.Series, 167 | series_2: pd.Series) -> float: 168 | """ 169 | get the sum squared errors between two 170 | series 171 | """ 172 | 173 | diff = series_1.subtract(series_2) 174 | sum_square = sum([x ** 2 for x in diff]) 175 | return sum_square 176 | 177 | def _confidence_to_variance(self, 178 | view: View, 179 | market_weights: pd.Series, 180 | market_covariance: pd.DataFrame,): 181 | """ 182 | convert a view confidence level to a variance for 183 | that view 184 | """ 185 | 186 | view_matrix = view.get_view_data_frame(list(self._calc_settings.asset_universe)) 187 | view_out_performance = pd.Series([view.out_performance], index=[view.id]) 188 | target_weights = self._get_view_target_weights(view, market_weights, market_covariance, 189 | view_matrix, view_out_performance) 190 | 191 | def _error_vs_target_weights(var) -> float: 192 | view_cov = pd.DataFrame(var, index=[view.id], columns=[view.id]) 193 | weights_for_cov = self._get_weights(market_weights, market_covariance, view_matrix, view_cov, 194 | view_out_performance) 195 | 196 | return self._get_sum_squares_error(weights_for_cov, target_weights) 197 | 198 | variance = optimize.minimize(_error_vs_target_weights, np.array(0.1), method="BFGS") 199 | return variance.x[0] 200 | --------------------------------------------------------------------------------