bbstrader 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of bbstrader might be problematic. Click here for more details.

@@ -0,0 +1,182 @@
1
+ import warnings
2
+
3
+ from pypfopt import expected_returns, risk_models
4
+ from pypfopt.efficient_frontier import EfficientFrontier
5
+ from pypfopt.hierarchical_portfolio import HRPOpt
6
+
7
+ __all__ = [
8
+ "markowitz_weights",
9
+ "hierarchical_risk_parity",
10
+ "equal_weighted",
11
+ "optimized_weights",
12
+ ]
13
+
14
+
15
+ def markowitz_weights(prices=None, rfr=0.0, freq=252):
16
+ """
17
+ Calculates optimal portfolio weights using Markowitz's mean-variance optimization (Max Sharpe Ratio) with multiple solvers.
18
+
19
+ Parameters
20
+ ----------
21
+ prices : pd.DataFrame, optional
22
+ Price data for assets, where rows represent time periods and columns represent assets.
23
+ freq : int, optional
24
+ Frequency of the data, such as 252 for daily returns in a year (default is 252).
25
+
26
+ Returns
27
+ -------
28
+ dict
29
+ Dictionary containing the optimal asset weights for maximizing the Sharpe ratio, normalized to sum to 1.
30
+
31
+ Notes
32
+ -----
33
+ This function attempts to maximize the Sharpe ratio by iterating through various solvers ('SCS', 'ECOS', 'OSQP')
34
+ from the PyPortfolioOpt library. If a solver fails, it proceeds to the next one. If none succeed, an error message
35
+ is printed for each solver that fails.
36
+
37
+ This function is useful for portfolio with a small number of assets, as it may not scale well for large portfolios.
38
+
39
+ Raises
40
+ ------
41
+ Exception
42
+ If all solvers fail, each will print an exception error message during runtime.
43
+ """
44
+ returns = expected_returns.mean_historical_return(prices, frequency=freq)
45
+ cov = risk_models.sample_cov(prices, frequency=freq)
46
+
47
+ # Try different solvers to maximize Sharpe ratio
48
+ for solver in ["SCS", "ECOS", "OSQP"]:
49
+ ef = EfficientFrontier(
50
+ expected_returns=returns,
51
+ cov_matrix=cov,
52
+ weight_bounds=(0, 1),
53
+ solver=solver,
54
+ )
55
+ try:
56
+ ef.max_sharpe(risk_free_rate=rfr)
57
+ return ef.clean_weights()
58
+ except Exception as e:
59
+ print(f"Solver {solver} failed with error: {e}")
60
+
61
+
62
+ def hierarchical_risk_parity(prices=None, returns=None, freq=252):
63
+ """
64
+ Computes asset weights using Hierarchical Risk Parity (HRP) for risk-averse portfolio allocation.
65
+
66
+ Parameters
67
+ ----------
68
+ prices : pd.DataFrame, optional
69
+ Price data for assets; if provided, daily returns will be calculated.
70
+ returns : pd.DataFrame, optional
71
+ Daily returns for assets. One of `prices` or `returns` must be provided.
72
+ freq : int, optional
73
+ Number of days to consider in calculating portfolio weights (default is 252).
74
+
75
+ Returns
76
+ -------
77
+ dict
78
+ Optimized asset weights using the HRP method, with asset weights summing to 1.
79
+
80
+ Raises
81
+ ------
82
+ ValueError
83
+ If neither `prices` nor `returns` are provided.
84
+
85
+ Notes
86
+ -----
87
+ Hierarchical Risk Parity is particularly useful for portfolios with a large number of assets,
88
+ as it mitigates issues of multicollinearity and estimation errors in covariance matrices by
89
+ using hierarchical clustering.
90
+ """
91
+ warnings.filterwarnings("ignore")
92
+ if returns is None and prices is None:
93
+ raise ValueError("Either prices or returns must be provided")
94
+ if returns is None:
95
+ returns = prices.pct_change().dropna(how="all")
96
+ # Remove duplicate columns and index
97
+ returns = returns.loc[:, ~returns.columns.duplicated()]
98
+ returns = returns.loc[~returns.index.duplicated(keep="first")]
99
+ hrp = HRPOpt(returns=returns.iloc[-freq:])
100
+ return hrp.optimize()
101
+
102
+
103
+ def equal_weighted(prices=None, returns=None, round_digits=5):
104
+ """
105
+ Generates an equal-weighted portfolio by assigning an equal proportion to each asset.
106
+
107
+ Parameters
108
+ ----------
109
+ prices : pd.DataFrame, optional
110
+ Price data for assets, where each column represents an asset.
111
+ returns : pd.DataFrame, optional
112
+ Return data for assets. One of `prices` or `returns` must be provided.
113
+ round_digits : int, optional
114
+ Number of decimal places to round each weight to (default is 5).
115
+
116
+ Returns
117
+ -------
118
+ dict
119
+ Dictionary with equal weights assigned to each asset, summing to 1.
120
+
121
+ Raises
122
+ ------
123
+ ValueError
124
+ If neither `prices` nor `returns` are provided.
125
+
126
+ Notes
127
+ -----
128
+ Equal weighting is a simple allocation method that assumes equal importance across all assets,
129
+ useful as a baseline model and when no strong views exist on asset return expectations or risk.
130
+ """
131
+
132
+ if returns is None and prices is None:
133
+ raise ValueError("Either prices or returns must be provided")
134
+ if returns is None:
135
+ n = len(prices.columns)
136
+ columns = prices.columns
137
+ else:
138
+ n = len(returns.columns)
139
+ columns = returns.columns
140
+ return {col: round(1 / n, round_digits) for col in columns}
141
+
142
+
143
+ def optimized_weights(prices=None, returns=None, rfr=0.0, freq=252, method="equal"):
144
+ """
145
+ Selects an optimization method to calculate portfolio weights based on user preference.
146
+
147
+ Parameters
148
+ ----------
149
+ prices : pd.DataFrame, optional
150
+ Price data for assets, required for certain methods.
151
+ returns : pd.DataFrame, optional
152
+ Returns data for assets, an alternative input for certain methods.
153
+ freq : int, optional
154
+ Number of days for calculating portfolio weights, such as 252 for a year's worth of daily returns (default is 252).
155
+ method : str, optional
156
+ Optimization method to use ('markowitz', 'hrp', or 'equal') (default is 'markowitz').
157
+
158
+ Returns
159
+ -------
160
+ dict
161
+ Dictionary containing optimized asset weights based on the chosen method.
162
+
163
+ Raises
164
+ ------
165
+ ValueError
166
+ If an unknown optimization method is specified.
167
+
168
+ Notes
169
+ -----
170
+ This function integrates different optimization methods:
171
+ - 'markowitz': mean-variance optimization with max Sharpe ratio
172
+ - 'hrp': Hierarchical Risk Parity, for risk-based clustering of assets
173
+ - 'equal': Equal weighting across all assets
174
+ """
175
+ if method == "markowitz":
176
+ return markowitz_weights(prices=prices, rfr=rfr, freq=freq)
177
+ elif method == "hrp":
178
+ return hierarchical_risk_parity(prices=prices, returns=returns, freq=freq)
179
+ elif method == "equal":
180
+ return equal_weighted(prices=prices, returns=returns)
181
+ else:
182
+ raise ValueError(f"Unknown method: {method}")
@@ -0,0 +1,223 @@
1
+ import matplotlib.pyplot as plt
2
+ import pandas as pd
3
+ import seaborn as sns
4
+ from sklearn.decomposition import PCA
5
+ from sklearn.preprocessing import scale
6
+
7
+ from bbstrader.models.optimization import (
8
+ equal_weighted,
9
+ hierarchical_risk_parity,
10
+ markowitz_weights,
11
+ )
12
+
13
+ __all__ = ["EigenPortfolios"]
14
+
15
+
16
+ class EigenPortfolios(object):
17
+ """
18
+ The `EigenPortfolios` class applies Principal Component Analysis (PCA) to a covariance matrix of normalized asset returns
19
+ to derive portfolios (eigenportfolios) that capture distinct risk factors in the asset returns. Each eigenportfolio
20
+ represents a principal component of the return covariance matrix, ordered by the magnitude of its eigenvalue. These
21
+ portfolios capture most of the variance in asset returns and are mutually uncorrelated.
22
+
23
+ Notes
24
+ -----
25
+ The implementation is inspired by the book "Machine Learning for Algorithmic Trading" by Stefan Jansen.
26
+
27
+ References
28
+ ----------
29
+ Stefan Jansen (2020). Machine Learning for Algorithmic Trading - Second Edition.
30
+ chapter 13, Data-Driven Risk Factors and Asset Allocation with Unsupervised Learning.
31
+
32
+ """
33
+
34
+ def __init__(self):
35
+ self.returns = None
36
+ self.n_portfolios = None
37
+ self._portfolios = None
38
+ self._fit_called = False
39
+
40
+ def get_portfolios(self) -> pd.DataFrame:
41
+ """
42
+ Returns the computed eigenportfolios (weights of assets in each portfolio).
43
+
44
+ Returns
45
+ -------
46
+ pd.DataFrame
47
+ DataFrame containing eigenportfolio weights for each asset.
48
+
49
+ Raises
50
+ ------
51
+ ValueError
52
+ If `fit()` has not been called before retrieving portfolios.
53
+ """
54
+ if not self._fit_called:
55
+ raise ValueError("fit() must be called first")
56
+ return self._portfolios
57
+
58
+ def fit(self, returns: pd.DataFrame, n_portfolios: int = 4) -> pd.DataFrame:
59
+ """
60
+ Computes the eigenportfolios based on PCA of the asset returns' covariance matrix.
61
+
62
+ Parameters
63
+ ----------
64
+ returns : pd.DataFrame
65
+ Historical returns of assets to be used for PCA.
66
+ n_portfolios : int, optional
67
+ Number of eigenportfolios to compute (default is 4).
68
+
69
+ Returns
70
+ -------
71
+ pd.DataFrame
72
+ DataFrame containing normalized weights for each eigenportfolio.
73
+
74
+ Notes
75
+ -----
76
+ This method performs winsorization and normalization on returns to reduce the impact of outliers
77
+ and achieve zero mean and unit variance. It uses the first `n_portfolios` principal components
78
+ as portfolio weights.
79
+ """
80
+ # Winsorize and normalize the returns
81
+ normed_returns = scale(
82
+ returns.clip(
83
+ lower=returns.quantile(q=0.025), upper=returns.quantile(q=0.975), axis=1
84
+ ).apply(lambda x: x.sub(x.mean()).div(x.std()))
85
+ )
86
+ returns = returns.dropna(thresh=int(normed_returns.shape[0] * 0.95), axis=1)
87
+ returns = returns.dropna(thresh=int(normed_returns.shape[1] * 0.95))
88
+
89
+ cov = returns.cov()
90
+ cov.columns = cov.columns.astype(str)
91
+ pca = PCA()
92
+ pca.fit(cov)
93
+
94
+ top_portfolios = pd.DataFrame(
95
+ pca.components_[:n_portfolios], columns=cov.columns
96
+ )
97
+ eigen_portfolios = top_portfolios.div(top_portfolios.sum(axis=1), axis=0)
98
+ eigen_portfolios.index = [f"Portfolio {i}" for i in range(1, n_portfolios + 1)]
99
+ self._portfolios = eigen_portfolios
100
+ self.returns = returns
101
+ self.n_portfolios = n_portfolios
102
+ self._fit_called = True
103
+
104
+ def plot_weights(self):
105
+ """
106
+ Plots the weights of each asset in each eigenportfolio as bar charts.
107
+
108
+ Notes
109
+ -----
110
+ Each subplot represents one eigenportfolio, showing the contribution of each asset.
111
+ """
112
+ eigen_portfolios = self.get_portfolios()
113
+ n_cols = 2
114
+ n_rows = (self.n_portfolios + 1) // n_cols
115
+ figsize = (n_cols * 10, n_rows * 5)
116
+ axes = eigen_portfolios.T.plot.bar(
117
+ subplots=True, layout=(n_rows, n_cols), figsize=figsize, legend=False
118
+ )
119
+ for ax in axes.flatten():
120
+ ax.set_ylabel("Portfolio Weight")
121
+ ax.set_xlabel("")
122
+
123
+ sns.despine()
124
+ plt.tight_layout()
125
+ plt.show()
126
+
127
+ def plot_performance(self):
128
+ """
129
+ Plots the cumulative returns of each eigenportfolio over time.
130
+
131
+ Notes
132
+ -----
133
+ This method calculates the historical cumulative performance of each eigenportfolio
134
+ by weighting asset returns according to eigenportfolio weights.
135
+ """
136
+ eigen_portfolios = self.get_portfolios()
137
+ returns = self.returns.copy()
138
+
139
+ n_cols = 2
140
+ n_rows = (self.n_portfolios + 1 + n_cols - 1) // n_cols
141
+ figsize = (n_cols * 10, n_rows * 5)
142
+ fig, axes = plt.subplots(
143
+ nrows=n_rows, ncols=n_cols, figsize=figsize, sharex=True
144
+ )
145
+ axes = axes.flatten()
146
+ returns.mean(1).add(1).cumprod().sub(1).plot(title="The Market", ax=axes[0])
147
+
148
+ for i in range(self.n_portfolios):
149
+ rc = returns.mul(eigen_portfolios.iloc[i]).sum(1).add(1).cumprod().sub(1)
150
+ rc.plot(title=f"Portfolio {i+1}", ax=axes[i + 1], lw=1, rot=0)
151
+
152
+ for j in range(self.n_portfolios + 1, len(axes)):
153
+ fig.delaxes(axes[j])
154
+
155
+ for i in range(self.n_portfolios + 1):
156
+ axes[i].set_xlabel("")
157
+
158
+ sns.despine()
159
+ fig.tight_layout()
160
+ plt.show()
161
+
162
+ def optimize(
163
+ self,
164
+ portfolio: int = 1,
165
+ optimizer: str = "hrp",
166
+ prices=None,
167
+ freq=252,
168
+ plot=True,
169
+ ):
170
+ """
171
+ Optimizes the chosen eigenportfolio based on a specified optimization method.
172
+
173
+ Parameters
174
+ ----------
175
+ portfolio : int, optional
176
+ Index of the eigenportfolio to optimize (default is 1).
177
+ optimizer : str, optional
178
+ Optimization method: 'markowitz', 'hrp' (Hierarchical Risk Parity), or 'equal' (default is 'hrp').
179
+ prices : pd.DataFrame, optional
180
+ Asset prices used for Markowitz optimization (required if optimizer is 'markowitz').
181
+ freq : int, optional
182
+ Frequency of returns (e.g., 252 for daily returns).
183
+ plot : bool, optional
184
+ Whether to plot the performance of the optimized portfolio (default is True).
185
+
186
+ Returns
187
+ -------
188
+ dict
189
+ Dictionary of optimized asset weights.
190
+
191
+ Raises
192
+ ------
193
+ ValueError
194
+ If an unknown optimizer is specified, or if prices are not provided when using Markowitz optimization.
195
+
196
+ Notes
197
+ -----
198
+ The optimization method varies based on risk-return assumptions, with options for traditional Markowitz optimization,
199
+ Hierarchical Risk Parity, or equal weighting.
200
+ """
201
+ portfolio = self.get_portfolios().iloc[portfolio - 1]
202
+ returns = self.returns.loc[:, portfolio.index]
203
+ returns = returns.loc[:, ~returns.columns.duplicated()]
204
+ returns = returns.loc[~returns.index.duplicated(keep="first")]
205
+ if optimizer == "markowitz":
206
+ if prices is None:
207
+ raise ValueError("prices must be provided for markowitz optimization")
208
+ prices = prices.loc[:, returns.columns]
209
+ weights = markowitz_weights(prices=prices, freq=freq)
210
+ elif optimizer == "hrp":
211
+ weights = hierarchical_risk_parity(returns=returns, freq=freq)
212
+ elif optimizer == "equal":
213
+ weights = equal_weighted(returns=returns)
214
+ else:
215
+ raise ValueError(f"Unknown optimizer: {optimizer}")
216
+ if plot:
217
+ # plot the optimized potfolio performance
218
+ returns = returns.filter(weights.keys())
219
+ rc = returns.mul(weights).sum(1).add(1).cumprod().sub(1)
220
+ rc.plot(title=f"Optimized {portfolio.name}", lw=1, rot=0)
221
+ sns.despine()
222
+ plt.show()
223
+ return weights