├── 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 | [](https://www.python.org/)
2 | [](https://pypi.python.org/pypi/ansicolortags/)
3 | [](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 | 
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 | 
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 |
--------------------------------------------------------------------------------