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,1096 @@
|
|
1
|
+
"""Base Portfolio module"""
|
2
|
+
|
3
|
+
# Author: Hugo Delatte <delatte.hugo@gmail.com>
|
4
|
+
# License: BSD 3 clause
|
5
|
+
|
6
|
+
# The Portfolio class contains more than 40 measures than can be computationally
|
7
|
+
# expensive. The use of __slots__ instead of __dict__ is based on the following
|
8
|
+
# consideration:
|
9
|
+
# * Fast Portfolio instantiation.
|
10
|
+
# * Compute a measure only when needed.
|
11
|
+
# * Reuse the measures functions in measures.py module independently of the
|
12
|
+
# Portfolio class.
|
13
|
+
# * Have the measures as Class attributes and not as Class generic
|
14
|
+
# methods for better usability.
|
15
|
+
# * Caching of the 40 measures.
|
16
|
+
# * DRY by not re-writing @cached_property decorated methods for all the 40 measures.
|
17
|
+
#
|
18
|
+
# We define 7 types of attributes:
|
19
|
+
# * Public (read and right)
|
20
|
+
# * Private (read and right for private usage)
|
21
|
+
# * Read-only (handled in __setattr__)
|
22
|
+
# * Global abd local measures arguments: when they change, we clear the cache of
|
23
|
+
# all the measures (handled in __setattr__)
|
24
|
+
# * Attributes with custom getter and setter (using @property + private name
|
25
|
+
# in __slots__)
|
26
|
+
# * Attributes with custom getter without setter (read-only) that caches the result
|
27
|
+
# (using custom decorator @cached_property_slots + private name in __slots__)
|
28
|
+
# * Measures that are cached (handled in __getattribute__)
|
29
|
+
#
|
30
|
+
# In order to generate the measures attributes we call the measure functions and their
|
31
|
+
# arguments dynamically from the measures.py module. The function arguments are
|
32
|
+
# retrieved from the class attributes following the below rules:
|
33
|
+
# * Global measures function arguments (defined in GLOBAL_ARGS) need to be defined
|
34
|
+
# in the class attributes with identical name.
|
35
|
+
# * Local measures function arguments (defined in LOCAL_ARGS) need to be defined in
|
36
|
+
# the class attributes with the argument name preceded by the measure name and
|
37
|
+
# separated by '_'.
|
38
|
+
|
39
|
+
|
40
|
+
import warnings
|
41
|
+
from abc import abstractmethod
|
42
|
+
from typing import ClassVar
|
43
|
+
|
44
|
+
import numpy as np
|
45
|
+
import pandas as pd
|
46
|
+
import plotly.express as px
|
47
|
+
import plotly.graph_objects as go
|
48
|
+
|
49
|
+
import skfolio.typing as skt
|
50
|
+
from skfolio import measures as mt
|
51
|
+
from skfolio.measures import (
|
52
|
+
ExtraRiskMeasure,
|
53
|
+
PerfMeasure,
|
54
|
+
RatioMeasure,
|
55
|
+
RiskMeasure,
|
56
|
+
)
|
57
|
+
from skfolio.utils.sorting import dominate
|
58
|
+
from skfolio.utils.tools import (
|
59
|
+
args_names,
|
60
|
+
cached_property_slots,
|
61
|
+
format_measure,
|
62
|
+
)
|
63
|
+
|
64
|
+
# TODO: remove and use plotly express
|
65
|
+
pd.options.plotting.backend = "plotly"
|
66
|
+
|
67
|
+
|
68
|
+
_ZERO_THRESHOLD = 1e-5
|
69
|
+
_MEASURES = {
|
70
|
+
e for enu in [PerfMeasure, RiskMeasure, ExtraRiskMeasure, RatioMeasure] for e in enu
|
71
|
+
}
|
72
|
+
_MEASURES_VALUES = {e.value: e for e in _MEASURES}
|
73
|
+
|
74
|
+
|
75
|
+
class BasePortfolio:
|
76
|
+
r"""Base Portfolio class for all portfolios in skfolio.
|
77
|
+
|
78
|
+
Parameters
|
79
|
+
----------
|
80
|
+
returns : array-like of shape (n_observations,)
|
81
|
+
Vector of portfolio returns.
|
82
|
+
|
83
|
+
observations : array-like of shape (n_observations,)
|
84
|
+
Vector of portfolio observations.
|
85
|
+
|
86
|
+
name : str, optional
|
87
|
+
Name of the portfolio.
|
88
|
+
The default (`None`) is to use the object id.
|
89
|
+
|
90
|
+
tag : str, optional
|
91
|
+
Tag given to the portfolio.
|
92
|
+
Tags are used to manipulate groups of Portfolios from a `Population`.
|
93
|
+
|
94
|
+
fitness_measures : list[measures], optional
|
95
|
+
List of fitness measures.
|
96
|
+
Fitness measures are used to compute the portfolio fitness which is used to
|
97
|
+
compute domination.
|
98
|
+
The default (`None`) is to use the list [PerfMeasure.MEAN, RiskMeasure.VARIANCE]
|
99
|
+
|
100
|
+
annualized_factor : float, default=255.0
|
101
|
+
Factor used to annualize the below measures using the square-root rule:
|
102
|
+
|
103
|
+
* Annualized Mean = Mean * factor
|
104
|
+
* Annualized Variance = Variance * factor
|
105
|
+
* Annualized Semi-Variance = Semi-Variance * factor
|
106
|
+
* Annualized Standard-Deviation = Standard-Deviation * sqrt(factor)
|
107
|
+
* Annualized Semi-Deviation = Semi-Deviation * sqrt(factor)
|
108
|
+
* Annualized Sharpe Ratio = Sharpe Ratio * sqrt(factor)
|
109
|
+
* Annualized Sortino Ratio = Sortino Ratio * sqrt(factor)
|
110
|
+
|
111
|
+
risk_free_rate : float, default=0.0
|
112
|
+
Risk-free rate. The default value is `0.0`.
|
113
|
+
|
114
|
+
compounded : bool, default=False
|
115
|
+
If this is set to True, cumulative returns are compounded.
|
116
|
+
The default is `False`.
|
117
|
+
|
118
|
+
min_acceptable_return : float, optional
|
119
|
+
The minimum acceptable return used to distinguish "downside" and "upside"
|
120
|
+
returns for the computation of lower partial moments:
|
121
|
+
|
122
|
+
* First Lower Partial Moment
|
123
|
+
* Semi-Variance
|
124
|
+
* Semi-Deviation
|
125
|
+
|
126
|
+
The default (`None`) is to use the mean.
|
127
|
+
|
128
|
+
value_at_risk_beta : float, default=0.95
|
129
|
+
The confidence level of the Portfolio VaR (Value At Risk) which represents
|
130
|
+
the return on the worst (1-beta)% observations.
|
131
|
+
The default value is `0.95`.
|
132
|
+
|
133
|
+
entropic_risk_measure_theta : float, default=1.0
|
134
|
+
The risk aversion level of the Portfolio Entropic Risk Measure.
|
135
|
+
The default value is `1.0`.
|
136
|
+
|
137
|
+
entropic_risk_measure_beta : float, default=0.95
|
138
|
+
The confidence level of the Portfolio Entropic Risk Measure.
|
139
|
+
The default value is `0.95`.
|
140
|
+
|
141
|
+
cvar_beta : float, default=0.95
|
142
|
+
The confidence level of the Portfolio CVaR (Conditional Value at Risk) which
|
143
|
+
represents the expected VaR on the worst (1-beta)% observations.
|
144
|
+
The default value is `0.95`.
|
145
|
+
|
146
|
+
evar_beta : float, default=0.95
|
147
|
+
The confidence level of the Portfolio EVaR (Entropic Value at Risk).
|
148
|
+
The default value is `0.95`.
|
149
|
+
|
150
|
+
drawdown_at_risk_beta : float, default=0.95
|
151
|
+
The confidence level of the Portfolio Drawdown at Risk (DaR) which represents
|
152
|
+
the drawdown on the worst (1-beta)% observations.
|
153
|
+
The default value is `0.95`.
|
154
|
+
|
155
|
+
cdar_beta : float, default=0.95
|
156
|
+
The confidence level of the Portfolio CDaR (Conditional Drawdown at Risk) which
|
157
|
+
represents the expected drawdown on the worst (1-beta)% observations.
|
158
|
+
The default value is `0.95`.
|
159
|
+
|
160
|
+
edar_beta : float, default=0.95
|
161
|
+
The confidence level of the Portfolio EDaR (Entropic Drawdown at Risk).
|
162
|
+
The default value is `0.95`.
|
163
|
+
|
164
|
+
Attributes
|
165
|
+
----------
|
166
|
+
n_observations : float
|
167
|
+
Number of observations.
|
168
|
+
|
169
|
+
mean : float
|
170
|
+
Mean of the portfolio returns.
|
171
|
+
|
172
|
+
annualized_mean : float
|
173
|
+
Mean annualized by :math:`mean \times annualization\_factor`
|
174
|
+
|
175
|
+
mean_absolute_deviation : float
|
176
|
+
Mean Absolute Deviation. The deviation is the difference between the
|
177
|
+
return and a minimum acceptable return (`min_acceptable_return`).
|
178
|
+
|
179
|
+
first_lower_partial_moment : float
|
180
|
+
First Lower Partial Moment. The First Lower Partial Moment is the mean of the
|
181
|
+
returns below a minimum acceptable return (`min_acceptable_return`).
|
182
|
+
|
183
|
+
variance : float
|
184
|
+
Variance (Second Moment)
|
185
|
+
|
186
|
+
annualized_variance : float
|
187
|
+
Variance annualized by :math:`variance \times annualization\_factor`
|
188
|
+
|
189
|
+
semi_variance : float
|
190
|
+
Semi-variance (Second Lower Partial Moment).
|
191
|
+
The semi-variance is the variance of the returns below a minimum acceptable
|
192
|
+
return (`min_acceptable_return`).
|
193
|
+
|
194
|
+
annualized_semi_variance : float
|
195
|
+
Semi-variance annualized by
|
196
|
+
:math:`semi\_variance \times annualization\_factor`
|
197
|
+
|
198
|
+
standard_deviation : float
|
199
|
+
Standard Deviation (Square Root of the Second Moment).
|
200
|
+
|
201
|
+
annualized_standard_deviation : float
|
202
|
+
Standard Deviation annualized by
|
203
|
+
:math:`standard\_deviation \times \sqrt{annualization\_factor}`
|
204
|
+
|
205
|
+
semi_deviation : float
|
206
|
+
Semi-deviation (Square Root of the Second Lower Partial Moment).
|
207
|
+
The Semi Standard Deviation is the Standard Deviation of the returns below a
|
208
|
+
minimum acceptable return (`min_acceptable_return`).
|
209
|
+
|
210
|
+
annualized_semi_deviation : float
|
211
|
+
Semi-deviation annualized by
|
212
|
+
:math:`semi\_deviation \times \sqrt{annualization\_factor}`
|
213
|
+
|
214
|
+
skew : float
|
215
|
+
Skew. The Skew is a measure of the lopsidedness of the distribution.
|
216
|
+
A symmetric distribution have a Skew of zero.
|
217
|
+
Higher Skew corresponds to longer right tail.
|
218
|
+
|
219
|
+
kurtosis : float
|
220
|
+
Kurtosis. It is a measure of the heaviness of the tail of the distribution.
|
221
|
+
Higher Kurtosis corresponds to greater extremity of deviations (fat tails).
|
222
|
+
|
223
|
+
fourth_central_moment : float
|
224
|
+
Fourth Central Moment.
|
225
|
+
|
226
|
+
fourth_lower_partial_moment : float
|
227
|
+
Fourth Lower Partial Moment. It is a measure of the heaviness of the downside
|
228
|
+
tail of the returns below a minimum acceptable return (`min_acceptable_return`).
|
229
|
+
Higher Fourth Lower Partial Moment corresponds to greater extremity of downside
|
230
|
+
deviations (downside fat tail).
|
231
|
+
|
232
|
+
worst_realization : float
|
233
|
+
Worst Realization which is the worst return.
|
234
|
+
|
235
|
+
value_at_risk : float
|
236
|
+
Historical VaR (Value at Risk).
|
237
|
+
The VaR is the maximum loss at a given confidence level (`value_at_risk_beta`).
|
238
|
+
|
239
|
+
cvar : float
|
240
|
+
Historical CVaR (Conditional Value at Risk). The CVaR (or Tail VaR) represents
|
241
|
+
the mean shortfall at a specified confidence level (`cvar_beta`).
|
242
|
+
|
243
|
+
entropic_risk_measure : float
|
244
|
+
Historical Entropic Risk Measure. It is a risk measure which depends on the
|
245
|
+
risk aversion defined by the investor (`entropic_risk_measure_theta`) through
|
246
|
+
the exponential utility function at a given confidence level
|
247
|
+
(`entropic_risk_measure_beta`).
|
248
|
+
|
249
|
+
evar : float
|
250
|
+
Historical EVaR (Entropic Value at Risk). It is a coherent risk measure which
|
251
|
+
is an upper bound for the VaR and the CVaR, obtained from the Chernoff
|
252
|
+
inequality at a given confidence level (`evar_beta`). The EVaR can be
|
253
|
+
represented by using the concept of relative entropy.
|
254
|
+
|
255
|
+
drawdown_at_risk : float
|
256
|
+
Historical Drawdown at Risk. It is the maximum drawdown at a given
|
257
|
+
confidence level (`drawdown_at_risk_beta`).
|
258
|
+
|
259
|
+
cdar : float
|
260
|
+
Historical CDaR (Conditional Drawdown at Risk) at a given confidence level
|
261
|
+
(`cdar_beta`).
|
262
|
+
|
263
|
+
max_drawdown : float
|
264
|
+
Maximum Drawdown.
|
265
|
+
|
266
|
+
average_drawdown : float
|
267
|
+
Average Drawdown.
|
268
|
+
|
269
|
+
edar : float
|
270
|
+
EDaR (Entropic Drawdown at Risk). It is a coherent risk measure which is an
|
271
|
+
upper bound for the Drawdown at Risk and the CDaR, obtained from the Chernoff
|
272
|
+
inequality at a given confidence level (`edar_beta`). The EDaR can be
|
273
|
+
represented by using the concept of relative entropy.
|
274
|
+
|
275
|
+
ulcer_index : float
|
276
|
+
Ulcer Index
|
277
|
+
|
278
|
+
gini_mean_difference : float
|
279
|
+
Gini Mean Difference (GMD). It is the expected absolute difference between two
|
280
|
+
realizations. The GMD is a superior measure of variability for non-normal
|
281
|
+
distribution than the variance. It can be used to form necessary conditions
|
282
|
+
for second-degree stochastic dominance, while the variance cannot.
|
283
|
+
|
284
|
+
mean_absolute_deviation_ratio : float
|
285
|
+
Mean Absolute Deviation ratio.
|
286
|
+
It is the excess mean (mean - risk_free_rate) divided by the MaD.
|
287
|
+
|
288
|
+
first_lower_partial_moment_ratio : float
|
289
|
+
First Lower Partial Moment ratio.
|
290
|
+
It is the excess mean (mean - risk_free_rate) divided by the First Lower
|
291
|
+
Partial Moment.
|
292
|
+
|
293
|
+
sharpe_ratio : float
|
294
|
+
Sharpe ratio.
|
295
|
+
It is the excess mean (mean - risk_free_rate) divided by the standard-deviation.
|
296
|
+
|
297
|
+
annualized_sharpe_ratio : float
|
298
|
+
Sharpe ratio annualized by
|
299
|
+
:math:`sharpe\_ratio \times \sqrt{annualization\_factor}`.
|
300
|
+
|
301
|
+
sortino_ratio : float
|
302
|
+
Sortino ratio.
|
303
|
+
It is the excess mean (mean - risk_free_rate) divided by the semi
|
304
|
+
standard-deviation.
|
305
|
+
|
306
|
+
annualized_sortino_ratio : float
|
307
|
+
Sortino ratio annualized by
|
308
|
+
:math:`sortino\_ratio \times \sqrt{annualization\_factor}`.
|
309
|
+
|
310
|
+
value_at_risk_ratio : float
|
311
|
+
VaR ratio.
|
312
|
+
It is the excess mean (mean - risk_free_rate) divided by the Value at Risk
|
313
|
+
(VaR).
|
314
|
+
|
315
|
+
cvar_ratio : float
|
316
|
+
CVaR ratio.
|
317
|
+
It is the excess mean (mean - risk_free_rate) divided by the Conditional Value
|
318
|
+
at Risk (CVaR).
|
319
|
+
|
320
|
+
entropic_risk_measure_ratio : float
|
321
|
+
Entropic risk measure ratio.
|
322
|
+
It is the excess mean (mean - risk_free_rate) divided by the Entropic risk
|
323
|
+
measure.
|
324
|
+
|
325
|
+
evar_ratio : float
|
326
|
+
EVaR ratio.
|
327
|
+
It is the excess mean (mean - risk_free_rate) divided by the EVaR (Entropic
|
328
|
+
Value at Risk).
|
329
|
+
|
330
|
+
worst_realization_ratio : float
|
331
|
+
Worst Realization ratio.
|
332
|
+
It is the excess mean (mean - risk_free_rate) divided by the Worst Realization
|
333
|
+
(worst return).
|
334
|
+
|
335
|
+
drawdown_at_risk_ratio : float
|
336
|
+
Drawdown at Risk ratio.
|
337
|
+
It is the excess mean (mean - risk_free_rate) divided by the drawdown at
|
338
|
+
risk.
|
339
|
+
|
340
|
+
cdar_ratio : float
|
341
|
+
CDaR ratio.
|
342
|
+
It is the excess mean (mean - risk_free_rate) divided by the CDaR (conditional
|
343
|
+
drawdown at risk).
|
344
|
+
|
345
|
+
calmar_ratio : float
|
346
|
+
Calmar ratio.
|
347
|
+
It is the excess mean (mean - risk_free_rate) divided by the Maximum Drawdown.
|
348
|
+
|
349
|
+
average_drawdown_ratio : float
|
350
|
+
Average Drawdown ratio.
|
351
|
+
It is the excess mean (mean - risk_free_rate) divided by the Average Drawdown.
|
352
|
+
|
353
|
+
edar_ratio : float
|
354
|
+
EDaR ratio.
|
355
|
+
It is the excess mean (mean - risk_free_rate) divided by the EDaR (Entropic
|
356
|
+
Drawdown at Risk).
|
357
|
+
|
358
|
+
ulcer_index_ratio : float
|
359
|
+
Ulcer Index ratio.
|
360
|
+
It is the excess mean (mean - risk_free_rate) divided by the Ulcer Index.
|
361
|
+
|
362
|
+
gini_mean_difference_ratio : float
|
363
|
+
Gini Mean Difference ratio.
|
364
|
+
It is the excess mean (mean - risk_free_rate) divided by the Gini Mean
|
365
|
+
Difference.
|
366
|
+
"""
|
367
|
+
|
368
|
+
_read_only_attrs: ClassVar[set] = {
|
369
|
+
"returns",
|
370
|
+
"observations",
|
371
|
+
"n_observations",
|
372
|
+
}
|
373
|
+
|
374
|
+
# Arguments globally used in measures computation
|
375
|
+
_measure_global_args: ClassVar[set] = {
|
376
|
+
"returns",
|
377
|
+
"cumulative_returns",
|
378
|
+
"drawdowns",
|
379
|
+
"min_acceptable_return",
|
380
|
+
"compounded",
|
381
|
+
"risk_free_rate",
|
382
|
+
}
|
383
|
+
|
384
|
+
# Arguments locally used in measures computation
|
385
|
+
_measure_local_args: ClassVar[set] = {
|
386
|
+
"value_at_risk_beta",
|
387
|
+
"cvar_beta",
|
388
|
+
"entropic_risk_measure_theta",
|
389
|
+
"entropic_risk_measure_beta",
|
390
|
+
"evar_beta",
|
391
|
+
"drawdown_at_risk_beta",
|
392
|
+
"cdar_beta",
|
393
|
+
"edar_beta",
|
394
|
+
}
|
395
|
+
|
396
|
+
__slots__ = {
|
397
|
+
# public
|
398
|
+
"tag",
|
399
|
+
"name",
|
400
|
+
# public read-only
|
401
|
+
"returns",
|
402
|
+
"observations",
|
403
|
+
"n_observations",
|
404
|
+
# private
|
405
|
+
"_loaded",
|
406
|
+
# custom getter and setter
|
407
|
+
"_fitness_measures",
|
408
|
+
"_annualized_factor",
|
409
|
+
# custom getter (read-only and cached)
|
410
|
+
"_fitness",
|
411
|
+
"_cumulative_returns",
|
412
|
+
"_drawdowns",
|
413
|
+
# global args
|
414
|
+
"min_acceptable_return",
|
415
|
+
"compounded",
|
416
|
+
"risk_free_rate",
|
417
|
+
# local args
|
418
|
+
"value_at_risk_beta",
|
419
|
+
"cvar_beta",
|
420
|
+
"entropic_risk_measure_theta",
|
421
|
+
"entropic_risk_measure_beta",
|
422
|
+
"evar_beta",
|
423
|
+
"drawdown_at_risk_beta",
|
424
|
+
"cdar_beta",
|
425
|
+
"edar_beta",
|
426
|
+
# measures
|
427
|
+
# perf
|
428
|
+
"mean",
|
429
|
+
# annualized
|
430
|
+
"annualized_mean",
|
431
|
+
# risk measure
|
432
|
+
"mean_absolute_deviation",
|
433
|
+
"first_lower_partial_moment",
|
434
|
+
"variance",
|
435
|
+
"standard_deviation",
|
436
|
+
"semi_variance",
|
437
|
+
"semi_deviation",
|
438
|
+
"fourth_central_moment",
|
439
|
+
"fourth_lower_partial_moment",
|
440
|
+
"value_at_risk",
|
441
|
+
"cvar",
|
442
|
+
"entropic_risk_measure",
|
443
|
+
"evar",
|
444
|
+
"worst_realization",
|
445
|
+
"drawdown_at_risk",
|
446
|
+
"cdar",
|
447
|
+
"max_drawdown",
|
448
|
+
"average_drawdown",
|
449
|
+
"edar",
|
450
|
+
"ulcer_index",
|
451
|
+
"gini_mean_difference",
|
452
|
+
"skew",
|
453
|
+
"kurtosis",
|
454
|
+
# annualized
|
455
|
+
"annualized_variance",
|
456
|
+
"annualized_semi_variance",
|
457
|
+
"annualized_standard_deviation",
|
458
|
+
"annualized_semi_deviation",
|
459
|
+
# ratio
|
460
|
+
"mean_absolute_deviation_ratio",
|
461
|
+
"first_lower_partial_moment_ratio",
|
462
|
+
"sharpe_ratio",
|
463
|
+
"sortino_ratio",
|
464
|
+
"value_at_risk_ratio",
|
465
|
+
"cvar_ratio",
|
466
|
+
"entropic_risk_measure_ratio",
|
467
|
+
"evar_ratio",
|
468
|
+
"worst_realization_ratio",
|
469
|
+
"drawdown_at_risk_ratio",
|
470
|
+
"cdar_ratio",
|
471
|
+
"calmar_ratio",
|
472
|
+
"average_drawdown_ratio",
|
473
|
+
"edar_ratio",
|
474
|
+
"ulcer_index_ratio",
|
475
|
+
"gini_mean_difference_ratio",
|
476
|
+
# annualized
|
477
|
+
"annualized_sharpe_ratio",
|
478
|
+
"annualized_sortino_ratio",
|
479
|
+
}
|
480
|
+
|
481
|
+
def __init__(
|
482
|
+
self,
|
483
|
+
returns: np.ndarray | list,
|
484
|
+
observations: np.ndarray | list,
|
485
|
+
name: str | None = None,
|
486
|
+
tag: str | None = None,
|
487
|
+
annualized_factor: float = 255.0,
|
488
|
+
fitness_measures: list[skt.Measure] | None = None,
|
489
|
+
risk_free_rate: float = 0.0,
|
490
|
+
compounded: bool = False,
|
491
|
+
min_acceptable_return: float | None = None,
|
492
|
+
value_at_risk_beta: float = 0.95,
|
493
|
+
entropic_risk_measure_theta: float = 1.0,
|
494
|
+
entropic_risk_measure_beta: float = 0.95,
|
495
|
+
cvar_beta: float = 0.95,
|
496
|
+
evar_beta: float = 0.95,
|
497
|
+
drawdown_at_risk_beta: float = 0.95,
|
498
|
+
cdar_beta: float = 0.95,
|
499
|
+
edar_beta: float = 0.95,
|
500
|
+
):
|
501
|
+
self._loaded = False
|
502
|
+
self._annualized_factor = annualized_factor
|
503
|
+
self.returns = np.asarray(returns)
|
504
|
+
self.observations = np.asarray(observations)
|
505
|
+
self.risk_free_rate = risk_free_rate
|
506
|
+
self.tag = tag
|
507
|
+
self.compounded = compounded
|
508
|
+
self.min_acceptable_return = min_acceptable_return
|
509
|
+
self.value_at_risk_beta = value_at_risk_beta
|
510
|
+
self.entropic_risk_measure_theta = entropic_risk_measure_theta
|
511
|
+
self.entropic_risk_measure_beta = entropic_risk_measure_beta
|
512
|
+
self.cvar_beta = cvar_beta
|
513
|
+
self.evar_beta = evar_beta
|
514
|
+
self.drawdown_at_risk_beta = drawdown_at_risk_beta
|
515
|
+
self.cdar_beta = cdar_beta
|
516
|
+
self.edar_beta = edar_beta
|
517
|
+
|
518
|
+
self.name = str(id(self)) if name is None else name
|
519
|
+
if fitness_measures is None:
|
520
|
+
self._fitness_measures = [PerfMeasure.MEAN, RiskMeasure.VARIANCE]
|
521
|
+
else:
|
522
|
+
self._fitness_measures = fitness_measures
|
523
|
+
self.n_observations = len(observations)
|
524
|
+
self._loaded = True
|
525
|
+
|
526
|
+
def __reduce__(self):
|
527
|
+
# For fast serialization and deserialization
|
528
|
+
# We don't want to serialize generic slots but only init arguments
|
529
|
+
return self.__class__, tuple(
|
530
|
+
[getattr(self, arg) for arg in args_names(self.__init__)]
|
531
|
+
)
|
532
|
+
|
533
|
+
def __len__(self) -> int:
|
534
|
+
return len(self.observations)
|
535
|
+
|
536
|
+
def __repr__(self) -> str:
|
537
|
+
return f"<{type(self).__name__} {self.name}>"
|
538
|
+
|
539
|
+
def __eq__(self, other) -> bool:
|
540
|
+
return isinstance(other, BasePortfolio) and np.array_equal(
|
541
|
+
self.fitness, other.fitness
|
542
|
+
)
|
543
|
+
|
544
|
+
def __gt__(self, other) -> bool:
|
545
|
+
if not isinstance(other, BasePortfolio):
|
546
|
+
raise TypeError(
|
547
|
+
"`>` not supported between instances of `Portfolio` and"
|
548
|
+
f" `{type(other)}`"
|
549
|
+
)
|
550
|
+
return self.dominates(other)
|
551
|
+
|
552
|
+
def __ge__(self, other) -> bool:
|
553
|
+
if not isinstance(other, BasePortfolio):
|
554
|
+
raise TypeError(
|
555
|
+
"`>=` not supported between instances of `Portfolio` and"
|
556
|
+
f" `{type(other)}`"
|
557
|
+
)
|
558
|
+
return self.__eq__(other) or self.__gt__(other)
|
559
|
+
|
560
|
+
def __copy__(self):
|
561
|
+
cls = self.__class__
|
562
|
+
result = cls.__new__(cls)
|
563
|
+
result._loaded = False
|
564
|
+
for attr in self._slots():
|
565
|
+
if attr not in _MEASURES_VALUES and attr != "_loaded":
|
566
|
+
try:
|
567
|
+
setattr(result, attr, getattr(self, attr))
|
568
|
+
except AttributeError:
|
569
|
+
pass
|
570
|
+
result._loaded = True
|
571
|
+
return result
|
572
|
+
|
573
|
+
def __getattribute__(self, name):
|
574
|
+
try:
|
575
|
+
return object.__getattribute__(self, name)
|
576
|
+
except AttributeError as e:
|
577
|
+
# The Measures are the only attributes in __slots__ that are not yet
|
578
|
+
# assigned.
|
579
|
+
# We assign their values dynamically the first time they are called.
|
580
|
+
if name not in _MEASURES_VALUES:
|
581
|
+
raise AttributeError(e) from None
|
582
|
+
measure = _MEASURES_VALUES[name]
|
583
|
+
value = self.get_measure(measure=measure)
|
584
|
+
setattr(self, name, value)
|
585
|
+
return value
|
586
|
+
|
587
|
+
def __setattr__(self, name, value):
|
588
|
+
if name != "_loaded" and self._loaded:
|
589
|
+
if name in self._read_only_attrs:
|
590
|
+
raise AttributeError(
|
591
|
+
f"can't set attribute '{name}' because it is read-only"
|
592
|
+
)
|
593
|
+
if name in self._measure_global_args or name in self._measure_local_args:
|
594
|
+
# When an attribute in GLOBAL_ARGS or LOCAL_ARGS is set, we reset all
|
595
|
+
# the measures
|
596
|
+
self.clear()
|
597
|
+
object.__setattr__(self, name, value)
|
598
|
+
|
599
|
+
def __delattr__(self, name):
|
600
|
+
# We only want to raise an error when the attribute doesn't exist and we don't
|
601
|
+
# want to raise an error when it's a valid attribute that has not been assigned
|
602
|
+
# a value.
|
603
|
+
try:
|
604
|
+
object.__delattr__(self, name)
|
605
|
+
except AttributeError:
|
606
|
+
if name not in self._slots():
|
607
|
+
raise AttributeError(
|
608
|
+
f"`{type(self).__name__}` object has no attribute '{name}'"
|
609
|
+
) from None
|
610
|
+
|
611
|
+
def __array__(self) -> np.ndarray:
|
612
|
+
return self.returns
|
613
|
+
|
614
|
+
# Private methods
|
615
|
+
def _slots(self) -> set[str]:
|
616
|
+
slots = set()
|
617
|
+
for s in self.__class__.__mro__:
|
618
|
+
slots.update(getattr(s, "__slots__", set()))
|
619
|
+
return slots
|
620
|
+
|
621
|
+
@property
|
622
|
+
@abstractmethod
|
623
|
+
def composition(self) -> pd.DataFrame:
|
624
|
+
"""DataFrame of the Portfolio composition"""
|
625
|
+
pass
|
626
|
+
|
627
|
+
# Custom attribute setter and getter
|
628
|
+
@property
|
629
|
+
def fitness_measures(self) -> list[skt.Measure]:
|
630
|
+
"""Portfolio fitness measures."""
|
631
|
+
return self._fitness_measures
|
632
|
+
|
633
|
+
@fitness_measures.setter
|
634
|
+
def fitness_measures(self, value: list[skt.Measure]) -> None:
|
635
|
+
if not isinstance(value, list) or len(value) == 0:
|
636
|
+
raise TypeError("`fitness_measures` must be a non-empty list of Measure")
|
637
|
+
for val in value:
|
638
|
+
if not isinstance(
|
639
|
+
val, PerfMeasure | RiskMeasure | ExtraRiskMeasure | RatioMeasure
|
640
|
+
):
|
641
|
+
raise TypeError("`fitness_measures` must be a list of Measure")
|
642
|
+
self._fitness_measures = value
|
643
|
+
delattr(self, "_fitness")
|
644
|
+
|
645
|
+
@property
|
646
|
+
def annualized_factor(self) -> float:
|
647
|
+
"""Portfolio annualized factor."""
|
648
|
+
return self._annualized_factor
|
649
|
+
|
650
|
+
@annualized_factor.setter
|
651
|
+
def annualized_factor(self, value: float) -> None:
|
652
|
+
self._annualized_factor = value
|
653
|
+
self.clear()
|
654
|
+
|
655
|
+
# Custom attribute getter (read-only and cached)
|
656
|
+
@cached_property_slots
|
657
|
+
def fitness(self) -> np.ndarray:
|
658
|
+
"""The Portfolio fitness."""
|
659
|
+
res = []
|
660
|
+
for measure in self.fitness_measures:
|
661
|
+
if isinstance(measure, PerfMeasure | RatioMeasure):
|
662
|
+
sign = 1
|
663
|
+
else:
|
664
|
+
sign = -1
|
665
|
+
res.append(sign * getattr(self, str(measure.value)))
|
666
|
+
return np.array(res)
|
667
|
+
|
668
|
+
@cached_property_slots
|
669
|
+
def cumulative_returns(self) -> np.ndarray:
|
670
|
+
"""Portfolio cumulative returns array."""
|
671
|
+
return mt.get_cumulative_returns(
|
672
|
+
returns=self.returns, compounded=self.compounded
|
673
|
+
)
|
674
|
+
|
675
|
+
@cached_property_slots
|
676
|
+
def drawdowns(self) -> np.ndarray:
|
677
|
+
"""Portfolio drawdowns array."""
|
678
|
+
return mt.get_drawdowns(returns=self.returns, compounded=self.compounded)
|
679
|
+
|
680
|
+
# Classic property
|
681
|
+
@property
|
682
|
+
def returns_df(self) -> pd.Series:
|
683
|
+
"""Portfolio returns DataFrame."""
|
684
|
+
return pd.Series(index=self.observations, data=self.returns, name="returns")
|
685
|
+
|
686
|
+
@property
|
687
|
+
def cumulative_returns_df(self) -> pd.Series:
|
688
|
+
"""Portfolio cumulative returns Series."""
|
689
|
+
return pd.Series(
|
690
|
+
index=self.observations,
|
691
|
+
data=self.cumulative_returns,
|
692
|
+
name="cumulative_returns",
|
693
|
+
)
|
694
|
+
|
695
|
+
@property
|
696
|
+
def measures_df(self) -> pd.DataFrame:
|
697
|
+
"""DataFrame of all measures."""
|
698
|
+
idx = [e.value for enu in [PerfMeasure, RiskMeasure, RatioMeasure] for e in enu]
|
699
|
+
res = [getattr(self, attr) for attr in idx]
|
700
|
+
return pd.DataFrame(res, index=idx, columns=["measures"])
|
701
|
+
|
702
|
+
# Public methods
|
703
|
+
def copy(self):
|
704
|
+
"""Copy the Portfolio attributes without its measures values."""
|
705
|
+
return self.__copy__()
|
706
|
+
|
707
|
+
def clear(self) -> None:
|
708
|
+
"""CLear all measures, fitness, cumulative returns and drawdowns in slots"""
|
709
|
+
attrs = ["_fitness", "_cumulative_returns", "_drawdowns"]
|
710
|
+
for attr in attrs + list(_MEASURES_VALUES):
|
711
|
+
delattr(self, attr)
|
712
|
+
|
713
|
+
def get_measure(self, measure: skt.Measure) -> float:
|
714
|
+
"""Returns the value of a given measure.
|
715
|
+
|
716
|
+
Parameters
|
717
|
+
----------
|
718
|
+
measure : PerfMeasure | RiskMeasure | ExtraRiskMeasure | RatioMeasure
|
719
|
+
The input measure.
|
720
|
+
|
721
|
+
Returns
|
722
|
+
-------
|
723
|
+
value : float
|
724
|
+
The measure value.
|
725
|
+
"""
|
726
|
+
if isinstance(measure, PerfMeasure | RiskMeasure | ExtraRiskMeasure):
|
727
|
+
# We call the measure functions and their arguments dynamically.
|
728
|
+
# The measure functions are called from the "measures" module.
|
729
|
+
# The function arguments are retrieved from the class attributes following
|
730
|
+
# the below rules:
|
731
|
+
# Global measures function arguments (defined in GLOBAL_ARGS) need to be
|
732
|
+
# defined in the class attributes with identical name.
|
733
|
+
# Local measures function arguments need to be defined in the class
|
734
|
+
# attributes with the argument name preceded by the measure name and
|
735
|
+
# separated by "_".
|
736
|
+
if measure.is_annualized:
|
737
|
+
func = getattr(mt, str(measure.non_annualized_measure.value))
|
738
|
+
else:
|
739
|
+
func = getattr(mt, str(measure.value))
|
740
|
+
|
741
|
+
args = {
|
742
|
+
arg: (
|
743
|
+
getattr(self, arg)
|
744
|
+
if arg in self._measure_global_args
|
745
|
+
else getattr(self, f"{measure.value}_{arg}")
|
746
|
+
)
|
747
|
+
for arg in args_names(func)
|
748
|
+
}
|
749
|
+
try:
|
750
|
+
value = func(**args)
|
751
|
+
if measure in [
|
752
|
+
PerfMeasure.ANNUALIZED_MEAN,
|
753
|
+
RiskMeasure.ANNUALIZED_VARIANCE,
|
754
|
+
RiskMeasure.ANNUALIZED_SEMI_VARIANCE,
|
755
|
+
]:
|
756
|
+
value *= self.annualized_factor
|
757
|
+
elif measure in [
|
758
|
+
RiskMeasure.ANNUALIZED_STANDARD_DEVIATION,
|
759
|
+
RiskMeasure.ANNUALIZED_SEMI_DEVIATION,
|
760
|
+
]:
|
761
|
+
value *= np.sqrt(self.annualized_factor)
|
762
|
+
except Exception as e:
|
763
|
+
warnings.warn(
|
764
|
+
f"Unable to calculate the portfolio '{measure.value}' with"
|
765
|
+
f" error: {e}",
|
766
|
+
stacklevel=2,
|
767
|
+
)
|
768
|
+
value = np.nan
|
769
|
+
elif isinstance(measure, RatioMeasure):
|
770
|
+
# ratio
|
771
|
+
if measure.is_annualized:
|
772
|
+
mean = self.annualized_mean
|
773
|
+
else:
|
774
|
+
mean = self.mean
|
775
|
+
risk = getattr(self, str(measure.linked_risk_measure.value))
|
776
|
+
value = (mean - self.risk_free_rate) / risk
|
777
|
+
else:
|
778
|
+
raise ValueError(f"{measure} is not a Measure.")
|
779
|
+
return value
|
780
|
+
|
781
|
+
def dominates(
|
782
|
+
self, other: "BasePortfolio", idx: slice | np.ndarray | None = None
|
783
|
+
) -> bool:
|
784
|
+
"""Portfolio domination.
|
785
|
+
|
786
|
+
Returns true if each objective of the current portfolio fitness is not
|
787
|
+
strictly worse than the corresponding objective of the other portfolio fitness
|
788
|
+
and at least one objective is strictly better.
|
789
|
+
|
790
|
+
Parameters
|
791
|
+
----------
|
792
|
+
other : BasePortfolio
|
793
|
+
The other portfolio.
|
794
|
+
|
795
|
+
idx : slice | array, optional
|
796
|
+
Indexes or slice indicating on which objectives the domination is performed.
|
797
|
+
The default (`None`) is to use all objectives.
|
798
|
+
|
799
|
+
Returns
|
800
|
+
-------
|
801
|
+
value : bool
|
802
|
+
Returns True if the Portfolio dominates the other one.
|
803
|
+
"""
|
804
|
+
if idx is None:
|
805
|
+
idx = slice(None)
|
806
|
+
return dominate(self.fitness[idx], other.fitness[idx])
|
807
|
+
|
808
|
+
def rolling_measure(
|
809
|
+
self, measure: skt.Measure = RatioMeasure.SHARPE_RATIO, window: int = 30
|
810
|
+
) -> pd.Series:
|
811
|
+
"""Compute the measure over a rolling window.
|
812
|
+
|
813
|
+
Parameters
|
814
|
+
----------
|
815
|
+
measure : ct.Measure, default=RatioMeasure.SHARPE_RATIO
|
816
|
+
The measure. The default measure is the Sharpe Ratio.
|
817
|
+
|
818
|
+
window : int, default=30
|
819
|
+
The window size. The default value is `30`.
|
820
|
+
|
821
|
+
Returns
|
822
|
+
-------
|
823
|
+
series : pandas Series
|
824
|
+
The rolling measure Series.
|
825
|
+
"""
|
826
|
+
if measure.is_annualized:
|
827
|
+
non_annualized_measure = measure.non_annualized_measure
|
828
|
+
else:
|
829
|
+
non_annualized_measure = measure
|
830
|
+
|
831
|
+
if measure.is_perf:
|
832
|
+
perf_measure = non_annualized_measure
|
833
|
+
risk_measure = None
|
834
|
+
elif measure.is_ratio:
|
835
|
+
perf_measure = PerfMeasure.MEAN
|
836
|
+
risk_measure = non_annualized_measure.linked_risk_measure
|
837
|
+
else:
|
838
|
+
perf_measure = None
|
839
|
+
risk_measure = non_annualized_measure
|
840
|
+
|
841
|
+
if risk_measure is not None:
|
842
|
+
risk_func = getattr(mt, str(risk_measure.value))
|
843
|
+
risk_func_args = {
|
844
|
+
arg: (
|
845
|
+
getattr(self, arg)
|
846
|
+
if arg in self._measure_global_args
|
847
|
+
else getattr(self, f"{risk_measure.value}_{arg}")
|
848
|
+
)
|
849
|
+
for arg in args_names(risk_func)
|
850
|
+
}
|
851
|
+
|
852
|
+
if "drawdowns" in risk_func_args:
|
853
|
+
del risk_func_args["drawdowns"]
|
854
|
+
|
855
|
+
def meta_risk_func(returns):
|
856
|
+
drawdowns = mt.get_drawdowns(returns, compounded=self.compounded)
|
857
|
+
return risk_func(drawdowns=drawdowns, **risk_func_args)
|
858
|
+
|
859
|
+
else:
|
860
|
+
del risk_func_args["returns"]
|
861
|
+
|
862
|
+
def meta_risk_func(returns):
|
863
|
+
return risk_func(returns=returns, **risk_func_args)
|
864
|
+
|
865
|
+
if perf_measure is not None:
|
866
|
+
perf_func = getattr(mt, str(perf_measure.value))
|
867
|
+
|
868
|
+
def func(returns):
|
869
|
+
return (perf_func(returns) - self.risk_free_rate) / meta_risk_func(
|
870
|
+
returns
|
871
|
+
)
|
872
|
+
|
873
|
+
else:
|
874
|
+
func = meta_risk_func
|
875
|
+
else:
|
876
|
+
perf_func = getattr(mt, str(perf_measure.value))
|
877
|
+
|
878
|
+
def func(returns):
|
879
|
+
return perf_func(returns)
|
880
|
+
|
881
|
+
rolling = (
|
882
|
+
pd.Series(self.returns, index=self.observations)
|
883
|
+
.rolling(window=window)
|
884
|
+
.apply(func)
|
885
|
+
)
|
886
|
+
if measure.is_annualized:
|
887
|
+
if measure in [
|
888
|
+
PerfMeasure.ANNUALIZED_MEAN,
|
889
|
+
RiskMeasure.ANNUALIZED_VARIANCE,
|
890
|
+
RiskMeasure.ANNUALIZED_SEMI_VARIANCE,
|
891
|
+
]:
|
892
|
+
rolling *= self.annualized_factor
|
893
|
+
elif measure in [
|
894
|
+
RiskMeasure.ANNUALIZED_STANDARD_DEVIATION,
|
895
|
+
RiskMeasure.ANNUALIZED_SEMI_DEVIATION,
|
896
|
+
RatioMeasure.ANNUALIZED_SHARPE_RATIO,
|
897
|
+
RatioMeasure.ANNUALIZED_SORTINO_RATIO,
|
898
|
+
]:
|
899
|
+
rolling *= np.sqrt(self.annualized_factor)
|
900
|
+
return rolling
|
901
|
+
|
902
|
+
def summary(self, formatted: bool = True) -> pd.Series:
|
903
|
+
"""Portfolio summary of all its measures.
|
904
|
+
|
905
|
+
Parameters
|
906
|
+
----------
|
907
|
+
formatted : bool, default=True
|
908
|
+
If this is set to True, the measures are formatted into rounded string
|
909
|
+
with units.
|
910
|
+
|
911
|
+
Returns
|
912
|
+
-------
|
913
|
+
summary : pandas Series
|
914
|
+
The Portfolio summary.
|
915
|
+
"""
|
916
|
+
measures = (
|
917
|
+
e
|
918
|
+
for enu in [PerfMeasure, RiskMeasure, ExtraRiskMeasure, RatioMeasure]
|
919
|
+
for e in enu
|
920
|
+
)
|
921
|
+
summary = {}
|
922
|
+
for e in measures:
|
923
|
+
e: skt.Measure
|
924
|
+
try:
|
925
|
+
if e.is_ratio:
|
926
|
+
base_measure = e.linked_risk_measure
|
927
|
+
else:
|
928
|
+
base_measure = e
|
929
|
+
beta = getattr(self, f"{base_measure.value}_beta")
|
930
|
+
key = f"{e!s} at {beta:.0%}"
|
931
|
+
except AttributeError:
|
932
|
+
key = str(e)
|
933
|
+
if isinstance(e, RatioMeasure) or e in [
|
934
|
+
ExtraRiskMeasure.ENTROPIC_RISK_MEASURE,
|
935
|
+
RiskMeasure.ULCER_INDEX,
|
936
|
+
ExtraRiskMeasure.SKEW,
|
937
|
+
ExtraRiskMeasure.KURTOSIS,
|
938
|
+
]:
|
939
|
+
percent = False
|
940
|
+
else:
|
941
|
+
percent = True
|
942
|
+
if formatted:
|
943
|
+
value = format_measure(getattr(self, str(e.value)), percent=percent)
|
944
|
+
else:
|
945
|
+
value = getattr(self, str(e.value))
|
946
|
+
summary[key] = value
|
947
|
+
return pd.Series(summary)
|
948
|
+
|
949
|
+
def plot_cumulative_returns(
|
950
|
+
self, log_scale: bool = False, idx: slice | np.ndarray | None = None
|
951
|
+
) -> go.Figure:
|
952
|
+
"""Plot the Portfolio cumulative returns.
|
953
|
+
Non-compounded cumulative returns start at 0.
|
954
|
+
Compounded cumulative returns are rescaled to start at 1000.
|
955
|
+
|
956
|
+
Parameters
|
957
|
+
----------
|
958
|
+
log_scale : bool, default=False
|
959
|
+
If this is set to True, the cumulative returns are displayed with a
|
960
|
+
logarithm scale on the y-axis and rebased at 1000. The cumulative returns
|
961
|
+
must be compounded otherwise an exception is raised.
|
962
|
+
|
963
|
+
idx : slice | array, optional
|
964
|
+
Indexes or slice of the observations to plot.
|
965
|
+
The default (`None`) is to plot all observations.
|
966
|
+
|
967
|
+
Returns
|
968
|
+
-------
|
969
|
+
plot : Figure
|
970
|
+
Returns the plot Figure object.
|
971
|
+
"""
|
972
|
+
if idx is None:
|
973
|
+
idx = slice(None)
|
974
|
+
df = self.cumulative_returns_df.iloc[idx]
|
975
|
+
title = "Cumulative Returns"
|
976
|
+
if self.compounded:
|
977
|
+
yaxis_title = f"{title} (rebased at 1000)"
|
978
|
+
if log_scale:
|
979
|
+
title = f"{title} (compounded & log scaled)"
|
980
|
+
else:
|
981
|
+
title = f"{title} (compounded)"
|
982
|
+
else:
|
983
|
+
if log_scale:
|
984
|
+
raise ValueError(
|
985
|
+
"Plotting with logarithm scaling must be done on cumulative "
|
986
|
+
"returns that are compounded as opposed to non-compounded."
|
987
|
+
"You can change to compounded with `compounded=True`"
|
988
|
+
)
|
989
|
+
yaxis_title = title
|
990
|
+
title = f"{title} (non-compounded)"
|
991
|
+
|
992
|
+
fig = df.plot()
|
993
|
+
fig.update_layout(
|
994
|
+
title=title,
|
995
|
+
xaxis_title="Observations",
|
996
|
+
yaxis_title=yaxis_title,
|
997
|
+
showlegend=False,
|
998
|
+
)
|
999
|
+
if self.compounded:
|
1000
|
+
fig.update_yaxes(tickformat=".0f")
|
1001
|
+
else:
|
1002
|
+
fig.update_yaxes(tickformat=".2%")
|
1003
|
+
if log_scale:
|
1004
|
+
fig.update_yaxes(type="log")
|
1005
|
+
return fig
|
1006
|
+
|
1007
|
+
def plot_returns(self, idx: slice | np.ndarray | None = None) -> go.Figure:
|
1008
|
+
"""Plot the Portfolio returns
|
1009
|
+
|
1010
|
+
Parameters
|
1011
|
+
----------
|
1012
|
+
idx : slice | array, optional
|
1013
|
+
Indexes or slice of the observations to plot.
|
1014
|
+
The default (`None`) is to plot all observations.
|
1015
|
+
|
1016
|
+
Returns
|
1017
|
+
-------
|
1018
|
+
plot : Figure
|
1019
|
+
Returns the plot Figure object
|
1020
|
+
"""
|
1021
|
+
if idx is None:
|
1022
|
+
idx = slice(None)
|
1023
|
+
fig = self.returns_df.iloc[idx].plot()
|
1024
|
+
fig.update_layout(
|
1025
|
+
title="Returns",
|
1026
|
+
xaxis_title="Observations",
|
1027
|
+
yaxis_title="Returns",
|
1028
|
+
showlegend=False,
|
1029
|
+
)
|
1030
|
+
return fig
|
1031
|
+
|
1032
|
+
def plot_rolling_measure(
|
1033
|
+
self,
|
1034
|
+
measure: skt.Measure = RatioMeasure.SHARPE_RATIO,
|
1035
|
+
window: int = 30,
|
1036
|
+
) -> go.Figure:
|
1037
|
+
"""Plot the measure over a rolling window.
|
1038
|
+
|
1039
|
+
Parameters
|
1040
|
+
----------
|
1041
|
+
measure : ct.Measure, default = RatioMeasure.SHARPE_RATIO
|
1042
|
+
The measure.
|
1043
|
+
|
1044
|
+
window : int, default=30
|
1045
|
+
The window size.
|
1046
|
+
|
1047
|
+
Returns
|
1048
|
+
-------
|
1049
|
+
plot : Figure
|
1050
|
+
Returns the plot Figure object
|
1051
|
+
"""
|
1052
|
+
rolling = self.rolling_measure(measure=measure, window=window)
|
1053
|
+
rolling.name = f"{measure} {window} observations"
|
1054
|
+
fig = rolling.plot()
|
1055
|
+
fig.add_hline(
|
1056
|
+
y=getattr(self, measure.value),
|
1057
|
+
line_width=1,
|
1058
|
+
line_dash="dash",
|
1059
|
+
line_color="blue",
|
1060
|
+
)
|
1061
|
+
max_val = rolling.max()
|
1062
|
+
min_val = rolling.min()
|
1063
|
+
if max_val > 0:
|
1064
|
+
fig.add_hrect(
|
1065
|
+
y0=0, y1=max_val * 1.3, line_width=0, fillcolor="green", opacity=0.1
|
1066
|
+
)
|
1067
|
+
if min_val < 0:
|
1068
|
+
fig.add_hrect(
|
1069
|
+
y0=min_val * 1.3, y1=0, line_width=0, fillcolor="red", opacity=0.1
|
1070
|
+
)
|
1071
|
+
|
1072
|
+
fig.update_layout(
|
1073
|
+
title=f"rolling {measure} - {window} observations window",
|
1074
|
+
xaxis_title="Observations",
|
1075
|
+
yaxis_title=str(measure),
|
1076
|
+
showlegend=False,
|
1077
|
+
)
|
1078
|
+
return fig
|
1079
|
+
|
1080
|
+
def plot_composition(self) -> go.Figure:
|
1081
|
+
"""Plot the Portfolio composition.
|
1082
|
+
|
1083
|
+
Returns
|
1084
|
+
-------
|
1085
|
+
plot : Figure
|
1086
|
+
Returns the plot Figure object.
|
1087
|
+
"""
|
1088
|
+
df = self.composition.T
|
1089
|
+
fig = px.bar(df, x=df.index, y=df.columns)
|
1090
|
+
fig.update_layout(
|
1091
|
+
title="Portfolio Composition",
|
1092
|
+
xaxis_title="Portfolio",
|
1093
|
+
yaxis_title="Weight",
|
1094
|
+
legend_title_text="Assets",
|
1095
|
+
)
|
1096
|
+
return fig
|