skfolio 0.0.1__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.
- skfolio/__init__.py +29 -0
- skfolio/cluster/__init__.py +8 -0
- skfolio/cluster/_hierarchical.py +387 -0
- skfolio/datasets/__init__.py +20 -0
- skfolio/datasets/_base.py +389 -0
- skfolio/datasets/data/__init__.py +0 -0
- skfolio/datasets/data/factors_dataset.csv.gz +0 -0
- skfolio/datasets/data/sp500_dataset.csv.gz +0 -0
- skfolio/datasets/data/sp500_index.csv.gz +0 -0
- skfolio/distance/__init__.py +26 -0
- skfolio/distance/_base.py +55 -0
- skfolio/distance/_distance.py +574 -0
- skfolio/exceptions.py +30 -0
- skfolio/measures/__init__.py +76 -0
- skfolio/measures/_enums.py +355 -0
- skfolio/measures/_measures.py +607 -0
- skfolio/metrics/__init__.py +3 -0
- skfolio/metrics/_scorer.py +121 -0
- skfolio/model_selection/__init__.py +18 -0
- skfolio/model_selection/_combinatorial.py +407 -0
- skfolio/model_selection/_validation.py +194 -0
- skfolio/model_selection/_walk_forward.py +221 -0
- skfolio/moments/__init__.py +41 -0
- skfolio/moments/covariance/__init__.py +29 -0
- skfolio/moments/covariance/_base.py +101 -0
- skfolio/moments/covariance/_covariance.py +1108 -0
- skfolio/moments/expected_returns/__init__.py +21 -0
- skfolio/moments/expected_returns/_base.py +31 -0
- skfolio/moments/expected_returns/_expected_returns.py +415 -0
- skfolio/optimization/__init__.py +36 -0
- skfolio/optimization/_base.py +147 -0
- skfolio/optimization/cluster/__init__.py +13 -0
- skfolio/optimization/cluster/_nco.py +348 -0
- skfolio/optimization/cluster/hierarchical/__init__.py +13 -0
- skfolio/optimization/cluster/hierarchical/_base.py +440 -0
- skfolio/optimization/cluster/hierarchical/_herc.py +406 -0
- skfolio/optimization/cluster/hierarchical/_hrp.py +368 -0
- skfolio/optimization/convex/__init__.py +16 -0
- skfolio/optimization/convex/_base.py +1944 -0
- skfolio/optimization/convex/_distributionally_robust.py +392 -0
- skfolio/optimization/convex/_maximum_diversification.py +417 -0
- skfolio/optimization/convex/_mean_risk.py +974 -0
- skfolio/optimization/convex/_risk_budgeting.py +560 -0
- skfolio/optimization/ensemble/__init__.py +6 -0
- skfolio/optimization/ensemble/_base.py +87 -0
- skfolio/optimization/ensemble/_stacking.py +326 -0
- skfolio/optimization/naive/__init__.py +3 -0
- skfolio/optimization/naive/_naive.py +173 -0
- skfolio/population/__init__.py +3 -0
- skfolio/population/_population.py +883 -0
- skfolio/portfolio/__init__.py +13 -0
- skfolio/portfolio/_base.py +1096 -0
- skfolio/portfolio/_multi_period_portfolio.py +610 -0
- skfolio/portfolio/_portfolio.py +842 -0
- skfolio/pre_selection/__init__.py +7 -0
- skfolio/pre_selection/_pre_selection.py +342 -0
- skfolio/preprocessing/__init__.py +3 -0
- skfolio/preprocessing/_returns.py +114 -0
- skfolio/prior/__init__.py +18 -0
- skfolio/prior/_base.py +63 -0
- skfolio/prior/_black_litterman.py +238 -0
- skfolio/prior/_empirical.py +163 -0
- skfolio/prior/_factor_model.py +268 -0
- skfolio/typing.py +50 -0
- skfolio/uncertainty_set/__init__.py +23 -0
- skfolio/uncertainty_set/_base.py +108 -0
- skfolio/uncertainty_set/_bootstrap.py +281 -0
- skfolio/uncertainty_set/_empirical.py +237 -0
- skfolio/utils/__init__.py +0 -0
- skfolio/utils/bootstrap.py +115 -0
- skfolio/utils/equations.py +350 -0
- skfolio/utils/sorting.py +117 -0
- skfolio/utils/stats.py +466 -0
- skfolio/utils/tools.py +567 -0
- skfolio-0.0.1.dist-info/LICENSE +29 -0
- skfolio-0.0.1.dist-info/METADATA +568 -0
- skfolio-0.0.1.dist-info/RECORD +79 -0
- skfolio-0.0.1.dist-info/WHEEL +5 -0
- skfolio-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,842 @@
|
|
1
|
+
"""Portfolio module.
|
2
|
+
`Portfolio` is returned by the `predict` method of Optimization estimators.
|
3
|
+
It needs to be homogenous to the convex optimization problems meaning that `Portfolio`
|
4
|
+
is the dot product of the assets weights with the assets returns.
|
5
|
+
"""
|
6
|
+
|
7
|
+
# Author: Hugo Delatte <delatte.hugo@gmail.com>
|
8
|
+
# License: BSD 3 clause
|
9
|
+
|
10
|
+
|
11
|
+
import numbers
|
12
|
+
from typing import ClassVar
|
13
|
+
|
14
|
+
import numpy as np
|
15
|
+
import numpy.typing as npt
|
16
|
+
import pandas as pd
|
17
|
+
import plotly.express as px
|
18
|
+
|
19
|
+
import skfolio.typing as skt
|
20
|
+
from skfolio.measures import RiskMeasure
|
21
|
+
from skfolio.portfolio._base import _ZERO_THRESHOLD, BasePortfolio
|
22
|
+
from skfolio.utils.tools import (
|
23
|
+
args_names,
|
24
|
+
cached_property_slots,
|
25
|
+
default_asset_names,
|
26
|
+
input_to_array,
|
27
|
+
)
|
28
|
+
|
29
|
+
pd.options.plotting.backend = "plotly"
|
30
|
+
|
31
|
+
|
32
|
+
class Portfolio(BasePortfolio):
|
33
|
+
r"""
|
34
|
+
Portfolio class.
|
35
|
+
|
36
|
+
`Portfolio` is returned by the `predict` method of Optimization estimators.
|
37
|
+
It is homogenous to the convex optimization problems meaning that `Portfolio` is
|
38
|
+
the dot product of the assets weights with the assets returns.
|
39
|
+
|
40
|
+
Parameters
|
41
|
+
----------
|
42
|
+
X : array-like of shape (n_observations, n_assets)
|
43
|
+
Price returns of the assets.
|
44
|
+
If `X` is a DataFrame or another array containers that implements 'columns'
|
45
|
+
and 'index', the columns will be considered as assets names and the
|
46
|
+
indices will be considered as observations.
|
47
|
+
Otherwise, we use `["x0", "x1", ..., "x(n_assets - 1)"]` as asset names
|
48
|
+
and `[0, 1, ..., n_observations]` as observations.
|
49
|
+
|
50
|
+
weights : array-like of shape (n_assets,) | dict[str, float]
|
51
|
+
Portfolio weights.
|
52
|
+
If a dictionary is provided, its (key/value) pair must be the
|
53
|
+
(asset name/asset weight) and `X` must be a DataFrame with assets names
|
54
|
+
in columns.
|
55
|
+
|
56
|
+
transaction_costs : float | dict[str, float] | array-like of shape (n_assets, ), optional
|
57
|
+
Linear transaction costs of the assets. The Portfolio total transaction cost
|
58
|
+
is:
|
59
|
+
|
60
|
+
.. math:: total\_cost = \sum_{i=1}^{N} c_{i} \times |w_{i} - w\_prev_{i}|
|
61
|
+
|
62
|
+
with :math:`c_{i}` the transaction cost of asset i, :math:`w_{i}` its weight
|
63
|
+
and :math:`w\_prev_{i}` its previous weight (defined in `previous_weights`).
|
64
|
+
The float :math:`total\_cost` is used in the portfolio returns:
|
65
|
+
|
66
|
+
.. math:: ptf\_returns = R \cdot w - total\_cost
|
67
|
+
|
68
|
+
with :math:`R` the matrix af assets returns and :math:`w` the vector of
|
69
|
+
assets weights.
|
70
|
+
|
71
|
+
If a float is provided, it is applied to each asset.
|
72
|
+
If a dictionary is provided, its (key/value) pair must be the
|
73
|
+
(asset name/asset weight) and `X` must be a DataFrame with assets names
|
74
|
+
in columns.
|
75
|
+
The default (`None`) means no transaction costs.
|
76
|
+
|
77
|
+
.. warning::
|
78
|
+
|
79
|
+
To be homogenous to the optimization problems, the periodicity of the
|
80
|
+
transaction costs needs to be homogenous to the periodicity of the
|
81
|
+
returns `X`. For example, if `X` is composed of **daily** returns,
|
82
|
+
the `transaction_costs` need to be expressed in **daily** transaction costs.
|
83
|
+
|
84
|
+
management_fees : float | dict[str, float] | array-like of shape (n_assets, ), optional
|
85
|
+
Linear management fees of the assets. The Portfolio total management cost
|
86
|
+
is:
|
87
|
+
|
88
|
+
.. math:: total\_fee = \sum_{i=1}^{N} f_{i} \times w_{i}
|
89
|
+
|
90
|
+
with :math:`f_{i}` the management fee of asset i and :math:`w_{i}` its weight.
|
91
|
+
The float :math:`total\_fee` is used in the portfolio returns:
|
92
|
+
|
93
|
+
.. math:: ptf\_returns = R \cdot w - total\_fee
|
94
|
+
|
95
|
+
with :math:`R` the matrix af assets returns and :math:`w` the vector of
|
96
|
+
assets weights.
|
97
|
+
|
98
|
+
If a float is provided, it is applied to each asset.
|
99
|
+
If a dictionary is provided, its (key/value) pair must be the
|
100
|
+
(asset name/asset weight) and `X` must be a DataFrame with assets names
|
101
|
+
in columns.
|
102
|
+
The default (`None`) means no management fees.
|
103
|
+
|
104
|
+
.. warning::
|
105
|
+
|
106
|
+
To be homogenous to the optimization problems, the periodicity of the
|
107
|
+
management fees needs to be homogenous to the periodicity of the
|
108
|
+
returns `X`. For example, if `X` is composed of **daily** returns,
|
109
|
+
the `management_fees` need to be expressed in **daily** fees.
|
110
|
+
|
111
|
+
previous_weights : float | dict[str, float] | array-like of shape (n_assets, ), optional
|
112
|
+
Previous portfolio weights.
|
113
|
+
Previous weights are used to compute the total portfolio cost.
|
114
|
+
If `transaction_costs` is 0, `previous_weights` will have no impact.
|
115
|
+
If a float is provided, it is applied to each asset.
|
116
|
+
If a dictionary is provided, its (key/value) pair must be the
|
117
|
+
(asset name/asset previous weight) and `X` must be a DataFrame with assets names
|
118
|
+
in columns.
|
119
|
+
The default (`None`) means no previous weights.
|
120
|
+
|
121
|
+
name : str, optional
|
122
|
+
Name of the portfolio.
|
123
|
+
The default (`None`) is to use the object id.
|
124
|
+
|
125
|
+
tag : str, optional
|
126
|
+
Tag given to the portfolio.
|
127
|
+
Tags are used to manipulate groups of Portfolios from a `Population`.
|
128
|
+
|
129
|
+
fitness_measures : list[measures], optional
|
130
|
+
List of fitness measures.
|
131
|
+
Fitness measures are used to compute the portfolio fitness which is used to
|
132
|
+
compute domination.
|
133
|
+
The default (`None`) is to use the list [PerfMeasure.MEAN, RiskMeasure.VARIANCE]
|
134
|
+
|
135
|
+
annualized_factor : float, default=255.0
|
136
|
+
Factor used to annualize the below measures using the square-root rule:
|
137
|
+
|
138
|
+
* Annualized Mean = Mean * factor
|
139
|
+
* Annualized Variance = Variance * factor
|
140
|
+
* Annualized Semi-Variance = Semi-Variance * factor
|
141
|
+
* Annualized Standard-Deviation = Standard-Deviation * sqrt(factor)
|
142
|
+
* Annualized Semi-Deviation = Semi-Deviation * sqrt(factor)
|
143
|
+
* Annualized Sharpe Ratio = Sharpe Ratio * sqrt(factor)
|
144
|
+
* Annualized Sortino Ratio = Sortino Ratio * sqrt(factor)
|
145
|
+
|
146
|
+
risk_free_rate : float, default=0.0
|
147
|
+
Risk-free rate. The default value is `0.0`.
|
148
|
+
|
149
|
+
compounded : bool, default=False
|
150
|
+
If this is set to True, cumulative returns are compounded.
|
151
|
+
The default is `False`.
|
152
|
+
|
153
|
+
min_acceptable_return : float, optional
|
154
|
+
The minimum acceptable return used to distinguish "downside" and "upside"
|
155
|
+
returns for the computation of lower partial moments:
|
156
|
+
|
157
|
+
* First Lower Partial Moment
|
158
|
+
* Semi-Variance
|
159
|
+
* Semi-Deviation
|
160
|
+
|
161
|
+
The default (`None`) is to use the mean.
|
162
|
+
|
163
|
+
value_at_risk_beta : float, default=0.95
|
164
|
+
The confidence level of the Portfolio VaR (Value At Risk) which represents
|
165
|
+
the return on the worst (1-beta)% observations.
|
166
|
+
The default value is `0.95`.
|
167
|
+
|
168
|
+
entropic_risk_measure_theta : float, default=1.0
|
169
|
+
The risk aversion level of the Portfolio Entropic Risk Measure.
|
170
|
+
The default value is `1.0`.
|
171
|
+
|
172
|
+
entropic_risk_measure_beta : float, default=0.95
|
173
|
+
The confidence level of the Portfolio Entropic Risk Measure.
|
174
|
+
The default value is `0.95`.
|
175
|
+
|
176
|
+
cvar_beta : float, default=0.95
|
177
|
+
The confidence level of the Portfolio CVaR (Conditional Value at Risk) which
|
178
|
+
represents the expected VaR on the worst (1-beta)% observations.
|
179
|
+
The default value is `0.95`.
|
180
|
+
|
181
|
+
evar_beta : float, default=0.95
|
182
|
+
The confidence level of the Portfolio EVaR (Entropic Value at Risk).
|
183
|
+
The default value is `0.95`.
|
184
|
+
|
185
|
+
drawdown_at_risk_beta : float, default=0.95
|
186
|
+
The confidence level of the Portfolio Drawdown at Risk (DaR) which represents
|
187
|
+
the drawdown on the worst (1-beta)% observations.
|
188
|
+
The default value is `0.95`.
|
189
|
+
|
190
|
+
cdar_beta : float, default=0.95
|
191
|
+
The confidence level of the Portfolio CDaR (Conditional Drawdown at Risk) which
|
192
|
+
represents the expected drawdown on the worst (1-beta)% observations.
|
193
|
+
The default value is `0.95`.
|
194
|
+
|
195
|
+
edar_beta : float, default=0.95
|
196
|
+
The confidence level of the Portfolio EDaR (Entropic Drawdown at Risk).
|
197
|
+
The default value is `0.95`.
|
198
|
+
|
199
|
+
Attributes
|
200
|
+
----------
|
201
|
+
n_observations : float
|
202
|
+
Number of observations.
|
203
|
+
|
204
|
+
mean : float
|
205
|
+
Mean of the portfolio returns.
|
206
|
+
|
207
|
+
annualized_mean : float
|
208
|
+
Mean annualized by :math:`mean \times annualization\_factor`
|
209
|
+
|
210
|
+
mean_absolute_deviation : float
|
211
|
+
Mean Absolute Deviation. The deviation is the difference between the
|
212
|
+
return and a minimum acceptable return (`min_acceptable_return`).
|
213
|
+
|
214
|
+
first_lower_partial_moment : float
|
215
|
+
First Lower Partial Moment. The First Lower Partial Moment is the mean of the
|
216
|
+
returns below a minimum acceptable return (`min_acceptable_return`).
|
217
|
+
|
218
|
+
variance : float
|
219
|
+
Variance (Second Moment)
|
220
|
+
|
221
|
+
annualized_variance : float
|
222
|
+
Variance annualized by :math:`variance \times annualization\_factor`
|
223
|
+
|
224
|
+
semi_variance : float
|
225
|
+
Semi-variance (Second Lower Partial Moment).
|
226
|
+
The semi-variance is the variance of the returns below a minimum acceptable
|
227
|
+
return (`min_acceptable_return`).
|
228
|
+
|
229
|
+
annualized_semi_variance : float
|
230
|
+
Semi-variance annualized by
|
231
|
+
:math:`semi\_variance \times annualization\_factor`
|
232
|
+
|
233
|
+
standard_deviation : float
|
234
|
+
Standard Deviation (Square Root of the Second Moment).
|
235
|
+
|
236
|
+
annualized_standard_deviation : float
|
237
|
+
Standard Deviation annualized by
|
238
|
+
:math:`standard\_deviation \times \sqrt{annualization\_factor}`
|
239
|
+
|
240
|
+
semi_deviation : float
|
241
|
+
Semi-deviation (Square Root of the Second Lower Partial Moment).
|
242
|
+
The Semi Standard Deviation is the Standard Deviation of the returns below a
|
243
|
+
minimum acceptable return (`min_acceptable_return`).
|
244
|
+
|
245
|
+
annualized_semi_deviation : float
|
246
|
+
Semi-deviation annualized by
|
247
|
+
:math:`semi\_deviation \times \sqrt{annualization\_factor}`
|
248
|
+
|
249
|
+
skew : float
|
250
|
+
Skew. The Skew is a measure of the lopsidedness of the distribution.
|
251
|
+
A symmetric distribution have a Skew of zero.
|
252
|
+
Higher Skew corresponds to longer right tail.
|
253
|
+
|
254
|
+
kurtosis : float
|
255
|
+
Kurtosis. It is a measure of the heaviness of the tail of the distribution.
|
256
|
+
Higher Kurtosis corresponds to greater extremity of deviations (fat tails).
|
257
|
+
|
258
|
+
fourth_central_moment : float
|
259
|
+
Fourth Central Moment.
|
260
|
+
|
261
|
+
fourth_lower_partial_moment : float
|
262
|
+
Fourth Lower Partial Moment. It is a measure of the heaviness of the downside
|
263
|
+
tail of the returns below a minimum acceptable return (`min_acceptable_return`).
|
264
|
+
Higher Fourth Lower Partial Moment corresponds to greater extremity of downside
|
265
|
+
deviations (downside fat tail).
|
266
|
+
|
267
|
+
worst_realization : float
|
268
|
+
Worst Realization which is the worst return.
|
269
|
+
|
270
|
+
value_at_risk : float
|
271
|
+
Historical VaR (Value at Risk).
|
272
|
+
The VaR is the maximum loss at a given confidence level (`value_at_risk_beta`).
|
273
|
+
|
274
|
+
cvar : float
|
275
|
+
Historical CVaR (Conditional Value at Risk). The CVaR (or Tail VaR) represents
|
276
|
+
the mean shortfall at a specified confidence level (`cvar_beta`).
|
277
|
+
|
278
|
+
entropic_risk_measure : float
|
279
|
+
Historical Entropic Risk Measure. It is a risk measure which depends on the
|
280
|
+
risk aversion defined by the investor (`entropic_risk_measure_theta`) through
|
281
|
+
the exponential utility function at a given confidence level
|
282
|
+
(`entropic_risk_measure_beta`).
|
283
|
+
|
284
|
+
evar : float
|
285
|
+
Historical EVaR (Entropic Value at Risk). It is a coherent risk measure which
|
286
|
+
is an upper bound for the VaR and the CVaR, obtained from the Chernoff
|
287
|
+
inequality at a given confidence level (`evar_beta`). The EVaR can be
|
288
|
+
represented by using the concept of relative entropy.
|
289
|
+
|
290
|
+
drawdown_at_risk : float
|
291
|
+
Historical Drawdown at Risk. It is the maximum drawdown at a given
|
292
|
+
confidence level (`drawdown_at_risk_beta`).
|
293
|
+
|
294
|
+
cdar : float
|
295
|
+
Historical CDaR (Conditional Drawdown at Risk) at a given confidence level
|
296
|
+
(`cdar_beta`).
|
297
|
+
|
298
|
+
max_drawdown : float
|
299
|
+
Maximum Drawdown.
|
300
|
+
|
301
|
+
average_drawdown : float
|
302
|
+
Average Drawdown.
|
303
|
+
|
304
|
+
edar : float
|
305
|
+
EDaR (Entropic Drawdown at Risk). It is a coherent risk measure which is an
|
306
|
+
upper bound for the Drawdown at Risk and the CDaR, obtained from the Chernoff
|
307
|
+
inequality at a given confidence level (`edar_beta`). The EDaR can be
|
308
|
+
represented by using the concept of relative entropy.
|
309
|
+
|
310
|
+
ulcer_index : float
|
311
|
+
Ulcer Index
|
312
|
+
|
313
|
+
gini_mean_difference : float
|
314
|
+
Gini Mean Difference (GMD). It is the expected absolute difference between two
|
315
|
+
realizations. The GMD is a superior measure of variability for non-normal
|
316
|
+
distribution than the variance. It can be used to form necessary conditions
|
317
|
+
for second-degree stochastic dominance, while the variance cannot.
|
318
|
+
|
319
|
+
mean_absolute_deviation_ratio : float
|
320
|
+
Mean Absolute Deviation ratio.
|
321
|
+
It is the excess mean (mean - risk_free_rate) divided by the MaD.
|
322
|
+
|
323
|
+
first_lower_partial_moment_ratio : float
|
324
|
+
First Lower Partial Moment ratio.
|
325
|
+
It is the excess mean (mean - risk_free_rate) divided by the First Lower
|
326
|
+
Partial Moment.
|
327
|
+
|
328
|
+
sharpe_ratio : float
|
329
|
+
Sharpe ratio.
|
330
|
+
It is the excess mean (mean - risk_free_rate) divided by the standard-deviation.
|
331
|
+
|
332
|
+
annualized_sharpe_ratio : float
|
333
|
+
Sharpe ratio annualized by
|
334
|
+
:math:`sharpe\_ratio \times \sqrt{annualization\_factor}`.
|
335
|
+
|
336
|
+
sortino_ratio : float
|
337
|
+
Sortino ratio.
|
338
|
+
It is the excess mean (mean - risk_free_rate) divided by the semi
|
339
|
+
standard-deviation.
|
340
|
+
|
341
|
+
annualized_sortino_ratio : float
|
342
|
+
Sortino ratio annualized by
|
343
|
+
:math:`sortino\_ratio \times \sqrt{annualization\_factor}`.
|
344
|
+
|
345
|
+
value_at_risk_ratio : float
|
346
|
+
VaR ratio.
|
347
|
+
It is the excess mean (mean - risk_free_rate) divided by the Value at Risk
|
348
|
+
(VaR).
|
349
|
+
|
350
|
+
cvar_ratio : float
|
351
|
+
CVaR ratio.
|
352
|
+
It is the excess mean (mean - risk_free_rate) divided by the Conditional Value
|
353
|
+
at Risk (CVaR).
|
354
|
+
|
355
|
+
entropic_risk_measure_ratio : float
|
356
|
+
Entropic risk measure ratio.
|
357
|
+
It is the excess mean (mean - risk_free_rate) divided by the Entropic risk
|
358
|
+
measure.
|
359
|
+
|
360
|
+
evar_ratio : float
|
361
|
+
EVaR ratio.
|
362
|
+
It is the excess mean (mean - risk_free_rate) divided by the EVaR (Entropic
|
363
|
+
Value at Risk).
|
364
|
+
|
365
|
+
worst_realization_ratio : float
|
366
|
+
Worst Realization ratio.
|
367
|
+
It is the excess mean (mean - risk_free_rate) divided by the Worst Realization
|
368
|
+
(worst return).
|
369
|
+
|
370
|
+
drawdown_at_risk_ratio : float
|
371
|
+
Drawdown at Risk ratio.
|
372
|
+
It is the excess mean (mean - risk_free_rate) divided by the drawdown at
|
373
|
+
risk.
|
374
|
+
|
375
|
+
cdar_ratio : float
|
376
|
+
CDaR ratio.
|
377
|
+
It is the excess mean (mean - risk_free_rate) divided by the CDaR (conditional
|
378
|
+
drawdown at risk).
|
379
|
+
|
380
|
+
calmar_ratio : float
|
381
|
+
Calmar ratio.
|
382
|
+
It is the excess mean (mean - risk_free_rate) divided by the Maximum Drawdown.
|
383
|
+
|
384
|
+
average_drawdown_ratio : float
|
385
|
+
Average Drawdown ratio.
|
386
|
+
It is the excess mean (mean - risk_free_rate) divided by the Average Drawdown.
|
387
|
+
|
388
|
+
edar_ratio : float
|
389
|
+
EDaR ratio.
|
390
|
+
It is the excess mean (mean - risk_free_rate) divided by the EDaR (Entropic
|
391
|
+
Drawdown at Risk).
|
392
|
+
|
393
|
+
ulcer_index_ratio : float
|
394
|
+
Ulcer Index ratio.
|
395
|
+
It is the excess mean (mean - risk_free_rate) divided by the Ulcer Index.
|
396
|
+
|
397
|
+
gini_mean_difference_ratio : float
|
398
|
+
Gini Mean Difference ratio.
|
399
|
+
It is the excess mean (mean - risk_free_rate) divided by the Gini Mean
|
400
|
+
Difference.
|
401
|
+
"""
|
402
|
+
|
403
|
+
_read_only_attrs: ClassVar[set] = BasePortfolio._read_only_attrs.copy()
|
404
|
+
_read_only_attrs.update(
|
405
|
+
{
|
406
|
+
"X",
|
407
|
+
"assets",
|
408
|
+
"weights",
|
409
|
+
"previous_weights",
|
410
|
+
"transaction_costs",
|
411
|
+
"management_fees",
|
412
|
+
"n_assets",
|
413
|
+
"total_cost",
|
414
|
+
"total_fee",
|
415
|
+
}
|
416
|
+
)
|
417
|
+
|
418
|
+
__slots__ = {
|
419
|
+
# read-only
|
420
|
+
"X",
|
421
|
+
"weights",
|
422
|
+
"previous_weights",
|
423
|
+
"transaction_costs",
|
424
|
+
"management_fees",
|
425
|
+
"assets",
|
426
|
+
"n_assets",
|
427
|
+
"total_cost",
|
428
|
+
"total_fee",
|
429
|
+
# custom getter (read-only and cached)
|
430
|
+
"_nonzero_assets",
|
431
|
+
"_nonzero_assets_index",
|
432
|
+
}
|
433
|
+
|
434
|
+
def __init__(
|
435
|
+
self,
|
436
|
+
X: npt.ArrayLike,
|
437
|
+
weights: skt.MultiInput,
|
438
|
+
previous_weights: skt.MultiInput = None,
|
439
|
+
transaction_costs: skt.MultiInput = None,
|
440
|
+
management_fees: skt.MultiInput = None,
|
441
|
+
risk_free_rate: float = 0,
|
442
|
+
name: str | None = None,
|
443
|
+
tag: str | None = None,
|
444
|
+
annualized_factor: float = 255,
|
445
|
+
fitness_measures: list[skt.Measure] | None = None,
|
446
|
+
compounded: bool = False,
|
447
|
+
min_acceptable_return: float | None = None,
|
448
|
+
value_at_risk_beta: float = 0.95,
|
449
|
+
entropic_risk_measure_theta: float = 1,
|
450
|
+
entropic_risk_measure_beta: float = 0.95,
|
451
|
+
cvar_beta: float = 0.95,
|
452
|
+
evar_beta: float = 0.95,
|
453
|
+
drawdown_at_risk_beta: float = 0.95,
|
454
|
+
cdar_beta: float = 0.95,
|
455
|
+
edar_beta: float = 0.95,
|
456
|
+
):
|
457
|
+
# extract assets names from X
|
458
|
+
assets = None
|
459
|
+
observations = None
|
460
|
+
if hasattr(X, "columns"):
|
461
|
+
assets = np.asarray(X.columns, dtype=object)
|
462
|
+
observations = np.asarray(X.index)
|
463
|
+
|
464
|
+
# We don't perform extensive checks (like in check_X) for faster instantiation.
|
465
|
+
rets = np.asarray(X)
|
466
|
+
if rets.ndim != 2:
|
467
|
+
raise ValueError("`X` must be a 2D array-like")
|
468
|
+
|
469
|
+
n_observations, n_assets = rets.shape
|
470
|
+
|
471
|
+
weights = input_to_array(
|
472
|
+
items=weights,
|
473
|
+
n_assets=n_assets,
|
474
|
+
fill_value=0,
|
475
|
+
dim=1,
|
476
|
+
assets_names=assets,
|
477
|
+
name="weights",
|
478
|
+
)
|
479
|
+
|
480
|
+
if previous_weights is None:
|
481
|
+
previous_weights = np.zeros(n_assets)
|
482
|
+
else:
|
483
|
+
previous_weights = input_to_array(
|
484
|
+
items=previous_weights,
|
485
|
+
n_assets=n_assets,
|
486
|
+
fill_value=0,
|
487
|
+
dim=1,
|
488
|
+
assets_names=assets,
|
489
|
+
name="previous_weights",
|
490
|
+
)
|
491
|
+
|
492
|
+
if transaction_costs is None:
|
493
|
+
transaction_costs = 0
|
494
|
+
elif not np.isscalar(transaction_costs):
|
495
|
+
transaction_costs = input_to_array(
|
496
|
+
items=transaction_costs,
|
497
|
+
n_assets=n_assets,
|
498
|
+
fill_value=0,
|
499
|
+
dim=1,
|
500
|
+
assets_names=assets,
|
501
|
+
name="transaction_costs",
|
502
|
+
)
|
503
|
+
|
504
|
+
if management_fees is None:
|
505
|
+
management_fees = 0
|
506
|
+
elif not np.isscalar(management_fees):
|
507
|
+
management_fees = input_to_array(
|
508
|
+
items=management_fees,
|
509
|
+
n_assets=n_assets,
|
510
|
+
fill_value=0,
|
511
|
+
dim=1,
|
512
|
+
assets_names=assets,
|
513
|
+
name="management_fees",
|
514
|
+
)
|
515
|
+
|
516
|
+
# Default observations and assets if X is not a DataFrame
|
517
|
+
if observations is None or len(observations) == 0:
|
518
|
+
observations = np.arange(n_observations)
|
519
|
+
|
520
|
+
if assets is None or len(assets) == 0:
|
521
|
+
assets = default_asset_names(n_assets=n_assets)
|
522
|
+
|
523
|
+
# Computing portfolio returns
|
524
|
+
if np.isscalar(transaction_costs) and transaction_costs == 0:
|
525
|
+
total_cost = 0
|
526
|
+
else:
|
527
|
+
total_cost = (transaction_costs * abs(previous_weights - weights)).sum()
|
528
|
+
|
529
|
+
if np.isscalar(management_fees) and management_fees == 0:
|
530
|
+
total_fee = 0
|
531
|
+
else:
|
532
|
+
total_fee = (management_fees * weights).sum()
|
533
|
+
|
534
|
+
returns = weights @ rets.T - total_cost - total_fee
|
535
|
+
|
536
|
+
if np.any(np.isnan(returns)):
|
537
|
+
raise ValueError("NaN found in `returns`")
|
538
|
+
|
539
|
+
super().__init__(
|
540
|
+
returns=returns,
|
541
|
+
observations=observations,
|
542
|
+
name=name,
|
543
|
+
tag=tag,
|
544
|
+
fitness_measures=fitness_measures,
|
545
|
+
compounded=compounded,
|
546
|
+
risk_free_rate=risk_free_rate,
|
547
|
+
annualized_factor=annualized_factor,
|
548
|
+
min_acceptable_return=min_acceptable_return,
|
549
|
+
value_at_risk_beta=value_at_risk_beta,
|
550
|
+
cvar_beta=cvar_beta,
|
551
|
+
entropic_risk_measure_theta=entropic_risk_measure_theta,
|
552
|
+
entropic_risk_measure_beta=entropic_risk_measure_beta,
|
553
|
+
evar_beta=evar_beta,
|
554
|
+
drawdown_at_risk_beta=drawdown_at_risk_beta,
|
555
|
+
cdar_beta=cdar_beta,
|
556
|
+
edar_beta=edar_beta,
|
557
|
+
)
|
558
|
+
self._loaded = False
|
559
|
+
# We save the original array-like object and not the numpy copy for improved
|
560
|
+
# memory
|
561
|
+
self.X = X
|
562
|
+
self.assets = assets
|
563
|
+
self.n_assets = n_assets
|
564
|
+
self.weights = weights
|
565
|
+
self.transaction_costs = transaction_costs
|
566
|
+
self.management_fees = management_fees
|
567
|
+
self.previous_weights = previous_weights
|
568
|
+
self.total_cost = total_cost
|
569
|
+
self.total_fee = total_fee
|
570
|
+
self._loaded = True
|
571
|
+
|
572
|
+
def __neg__(self):
|
573
|
+
args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
|
574
|
+
args["weights"] = -self.weights
|
575
|
+
return self.__class__(**args)
|
576
|
+
|
577
|
+
def __abs__(self):
|
578
|
+
args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
|
579
|
+
args["weights"] = np.abs(self.weights)
|
580
|
+
return self.__class__(**args)
|
581
|
+
|
582
|
+
def __round__(self, n: int):
|
583
|
+
args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
|
584
|
+
args["weights"] = np.round(self.weights, n)
|
585
|
+
return self.__class__(**args)
|
586
|
+
|
587
|
+
def __add__(self, other):
|
588
|
+
if not isinstance(other, Portfolio):
|
589
|
+
raise TypeError(
|
590
|
+
f"Cannot add a Portfolio with an object of type {type(other)}"
|
591
|
+
)
|
592
|
+
args = args_names(self.__init__)
|
593
|
+
for arg in args:
|
594
|
+
if arg not in ["weights", "name", "tag"] and not np.array_equal(
|
595
|
+
getattr(self, arg), getattr(other, arg)
|
596
|
+
):
|
597
|
+
raise ValueError(f"Cannot add two Portfolios with different `{arg}`")
|
598
|
+
args = {arg: getattr(self, arg) for arg in args}
|
599
|
+
args["weights"] = self.weights + other.weights
|
600
|
+
return self.__class__(**args)
|
601
|
+
|
602
|
+
def __sub__(self, other):
|
603
|
+
if not isinstance(other, Portfolio):
|
604
|
+
raise TypeError(
|
605
|
+
f"Cannot add a Portfolio with an object of type {type(other)}"
|
606
|
+
)
|
607
|
+
args = args_names(self.__init__)
|
608
|
+
for arg in args:
|
609
|
+
if arg not in ["weights", "name", "tag"] and not np.array_equal(
|
610
|
+
getattr(self, arg), getattr(other, arg)
|
611
|
+
):
|
612
|
+
raise ValueError(
|
613
|
+
f"Cannot subtract two Portfolios with different `{arg}`"
|
614
|
+
)
|
615
|
+
args = {arg: getattr(self, arg) for arg in args}
|
616
|
+
args["weights"] = self.weights - other.weights
|
617
|
+
return self.__class__(**args)
|
618
|
+
|
619
|
+
def __mul__(self, other: numbers.Number):
|
620
|
+
if not isinstance(other, numbers.Number):
|
621
|
+
raise TypeError(
|
622
|
+
"Portfolio can only be multiplied by a number, but received a"
|
623
|
+
f" {type(other)}"
|
624
|
+
)
|
625
|
+
args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
|
626
|
+
args["weights"] = other * self.weights
|
627
|
+
return self.__class__(**args)
|
628
|
+
|
629
|
+
__rmul__ = __mul__
|
630
|
+
|
631
|
+
def __floordiv__(self, other: numbers.Number):
|
632
|
+
if not isinstance(other, numbers.Number):
|
633
|
+
raise TypeError(
|
634
|
+
"Portfolio can only be floor divided by a number, but received a"
|
635
|
+
f" {type(other)}"
|
636
|
+
)
|
637
|
+
args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
|
638
|
+
args["weights"] = np.floor_divide(self.weights, other)
|
639
|
+
return self.__class__(**args)
|
640
|
+
|
641
|
+
def __truediv__(self, other: numbers.Number):
|
642
|
+
if not isinstance(other, numbers.Number):
|
643
|
+
raise TypeError(
|
644
|
+
"Portfolio can only be divided by a number, but received a"
|
645
|
+
f" {type(other)}"
|
646
|
+
)
|
647
|
+
args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
|
648
|
+
args["weights"] = self.weights / other
|
649
|
+
return self.__class__(**args)
|
650
|
+
|
651
|
+
# Custom attribute getter (read-only and cached)
|
652
|
+
@cached_property_slots
|
653
|
+
def nonzero_assets(self) -> np.ndarray:
|
654
|
+
"""Invested asset :math:`abs(weights) > 0.001%`"""
|
655
|
+
return self.assets[self.nonzero_assets_index]
|
656
|
+
|
657
|
+
@cached_property_slots
|
658
|
+
def nonzero_assets_index(self) -> np.ndarray:
|
659
|
+
"""Indices of invested asset :math:`abs(weights) > 0.001%`"""
|
660
|
+
return np.flatnonzero(abs(self.weights) > _ZERO_THRESHOLD)
|
661
|
+
|
662
|
+
@property
|
663
|
+
def composition(self) -> pd.DataFrame:
|
664
|
+
"""DataFrame of the Portfolio composition."""
|
665
|
+
weights = self.weights[self.nonzero_assets_index]
|
666
|
+
df = pd.DataFrame({"asset": self.nonzero_assets, "weight": weights})
|
667
|
+
df.sort_values(by="weight", ascending=False, inplace=True)
|
668
|
+
df.rename(columns={"weight": self.name}, inplace=True)
|
669
|
+
df.set_index("asset", inplace=True)
|
670
|
+
return df
|
671
|
+
|
672
|
+
@property
|
673
|
+
def diversification(self):
|
674
|
+
"""Weighted average of volatility divided by the portfolio volatility."""
|
675
|
+
return (
|
676
|
+
self.weights @ np.std(np.asarray(self.X), axis=0) / self.standard_deviation
|
677
|
+
)
|
678
|
+
|
679
|
+
@property
|
680
|
+
def sric(self) -> float:
|
681
|
+
"""Sharpe Ratio Information Criterion (SRIC).
|
682
|
+
|
683
|
+
It is an unbiased estimator of the Sharpe Ratio adjusting for both sources of
|
684
|
+
bias which are noise fit and estimation error [1]_.
|
685
|
+
|
686
|
+
References
|
687
|
+
----------
|
688
|
+
.. [1] "Noise Fit, Estimation Error and a Sharpe Information Criterion",
|
689
|
+
Dirk Paulsen (2019)
|
690
|
+
"""
|
691
|
+
return self.sharpe_ratio - self.n_assets / (
|
692
|
+
self.n_observations * self.sharpe_ratio
|
693
|
+
)
|
694
|
+
|
695
|
+
# Public methods
|
696
|
+
def expected_returns_from_assets(
|
697
|
+
self, assets_expected_returns: np.ndarray
|
698
|
+
) -> float:
|
699
|
+
"""Compute the Portfolio expected returns from the assets expected returns,
|
700
|
+
weights, management costs and transaction fees.
|
701
|
+
|
702
|
+
Parameters
|
703
|
+
----------
|
704
|
+
assets_expected_returns : ndarray of shape (n_assets,)
|
705
|
+
The vector of assets expected returns.
|
706
|
+
|
707
|
+
Returns
|
708
|
+
-------
|
709
|
+
value : float
|
710
|
+
The Portfolio expected returns.
|
711
|
+
"""
|
712
|
+
return (
|
713
|
+
self.weights @ assets_expected_returns.T - self.total_cost - self.total_fee
|
714
|
+
)
|
715
|
+
|
716
|
+
def variance_from_assets(self, assets_covariance: np.ndarray) -> float:
|
717
|
+
"""Compute the Portfolio variance expectation from the assets covariance and
|
718
|
+
weights.
|
719
|
+
|
720
|
+
Parameters
|
721
|
+
----------
|
722
|
+
assets_covariance : ndarray of shape (n_assets,n_assets)
|
723
|
+
The matrix of assets covariance expectation.
|
724
|
+
|
725
|
+
Returns
|
726
|
+
-------
|
727
|
+
value : float
|
728
|
+
The Portfolio variance from the assets covariance.
|
729
|
+
"""
|
730
|
+
return float(self.weights @ assets_covariance @ self.weights.T)
|
731
|
+
|
732
|
+
def contribution(
|
733
|
+
self, measure: skt.Measure, spacing: float | None = None
|
734
|
+
) -> np.ndarray:
|
735
|
+
r"""Compute the contribution of each asset to a given measure.
|
736
|
+
|
737
|
+
Parameters
|
738
|
+
----------
|
739
|
+
measure : Measure
|
740
|
+
The measure used for the contribution computation.
|
741
|
+
|
742
|
+
spacing : float, optional
|
743
|
+
Spacing "h" of the finite difference:
|
744
|
+
:math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
|
745
|
+
|
746
|
+
Returns
|
747
|
+
-------
|
748
|
+
values : ndrray of shape (n_assets,)
|
749
|
+
The measure contribution of each asset.
|
750
|
+
"""
|
751
|
+
if spacing is None:
|
752
|
+
if measure in [
|
753
|
+
RiskMeasure.MAX_DRAWDOWN,
|
754
|
+
RiskMeasure.AVERAGE_DRAWDOWN,
|
755
|
+
RiskMeasure.CDAR,
|
756
|
+
RiskMeasure.EDAR,
|
757
|
+
]:
|
758
|
+
spacing = 1e-1
|
759
|
+
else:
|
760
|
+
spacing = 1e-5
|
761
|
+
args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
|
762
|
+
|
763
|
+
def get_risk(i: int, h: float) -> float:
|
764
|
+
a = args.copy()
|
765
|
+
w = a["weights"].copy()
|
766
|
+
w[i] += h
|
767
|
+
a["weights"] = w
|
768
|
+
return getattr(Portfolio(**a), measure.value)
|
769
|
+
|
770
|
+
cont = [
|
771
|
+
(get_risk(i, h=spacing) - get_risk(i, h=-spacing))
|
772
|
+
/ (2 * spacing)
|
773
|
+
* self.weights[i]
|
774
|
+
for i in range(len(self.weights))
|
775
|
+
]
|
776
|
+
return np.array(cont)
|
777
|
+
|
778
|
+
def plot_contribution(self, measure: skt.Measure, spacing: float | None = None):
|
779
|
+
r"""Plot the contribution of each asset to a given measure.
|
780
|
+
|
781
|
+
Parameters
|
782
|
+
----------
|
783
|
+
measure : Measure
|
784
|
+
The measure used for the contribution computation.
|
785
|
+
|
786
|
+
spacing : float, optional
|
787
|
+
Spacing "h" of the finite difference:
|
788
|
+
:math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
|
789
|
+
|
790
|
+
Returns
|
791
|
+
-------
|
792
|
+
plot : Figure
|
793
|
+
The plotly Figure of assets contribution to the measure.
|
794
|
+
"""
|
795
|
+
cont = self.contribution(measure=measure, spacing=spacing)
|
796
|
+
df = pd.DataFrame(cont, index=self.assets, columns=["contribution"])
|
797
|
+
fig = px.bar(df, x=df.index, y=df.columns)
|
798
|
+
fig.update_layout(
|
799
|
+
title=f"{measure} contribution",
|
800
|
+
xaxis_title="Asset",
|
801
|
+
yaxis_title=f"{measure} contribution",
|
802
|
+
)
|
803
|
+
return fig
|
804
|
+
|
805
|
+
def summary(self, formatted: bool = True) -> pd.Series:
|
806
|
+
"""Portfolio summary of all its measures.
|
807
|
+
|
808
|
+
Parameters
|
809
|
+
----------
|
810
|
+
formatted : bool, default=True
|
811
|
+
If this is set to True, the measures are formatted into rounded string
|
812
|
+
with units.
|
813
|
+
|
814
|
+
Returns
|
815
|
+
-------
|
816
|
+
summary : series
|
817
|
+
Portfolio summary.
|
818
|
+
"""
|
819
|
+
df = super().summary(formatted=formatted)
|
820
|
+
assets_number = self.n_assets
|
821
|
+
if formatted:
|
822
|
+
assets_number = str(self.n_assets)
|
823
|
+
df["Assets number"] = assets_number
|
824
|
+
return df
|
825
|
+
|
826
|
+
def get_weight(self, asset: str) -> float:
|
827
|
+
"""Get the weight of a given asset.
|
828
|
+
|
829
|
+
Parameters
|
830
|
+
----------
|
831
|
+
asset : str
|
832
|
+
Name of the asset.
|
833
|
+
|
834
|
+
Returns
|
835
|
+
-------
|
836
|
+
weight : float
|
837
|
+
Weight of the asset.
|
838
|
+
"""
|
839
|
+
try:
|
840
|
+
return self.weights[np.where(self.assets == asset)[0][0]]
|
841
|
+
except IndexError:
|
842
|
+
raise IndexError("{asset} is not a valid asset name.") from None
|