skfolio 0.4.2__tar.gz → 0.4.3__tar.gz

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.
Files changed (97) hide show
  1. {skfolio-0.4.2/src/skfolio.egg-info → skfolio-0.4.3}/PKG-INFO +2 -2
  2. {skfolio-0.4.2 → skfolio-0.4.3}/README.rst +1 -1
  3. {skfolio-0.4.2 → skfolio-0.4.3}/pyproject.toml +1 -1
  4. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/cluster/hierarchical/_base.py +12 -65
  5. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/cluster/hierarchical/_herc.py +75 -26
  6. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/cluster/hierarchical/_hrp.py +68 -12
  7. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/utils/stats.py +87 -0
  8. {skfolio-0.4.2 → skfolio-0.4.3/src/skfolio.egg-info}/PKG-INFO +2 -2
  9. {skfolio-0.4.2 → skfolio-0.4.3}/LICENSE +0 -0
  10. {skfolio-0.4.2 → skfolio-0.4.3}/MANIFEST.in +0 -0
  11. {skfolio-0.4.2 → skfolio-0.4.3}/setup.cfg +0 -0
  12. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/__init__.py +0 -0
  13. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/cluster/__init__.py +0 -0
  14. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/cluster/_hierarchical.py +0 -0
  15. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/datasets/__init__.py +0 -0
  16. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/datasets/_base.py +0 -0
  17. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/datasets/data/__init__.py +0 -0
  18. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/datasets/data/factors_dataset.csv.gz +0 -0
  19. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/datasets/data/sp500_dataset.csv.gz +0 -0
  20. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/datasets/data/sp500_index.csv.gz +0 -0
  21. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/distance/__init__.py +0 -0
  22. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/distance/_base.py +0 -0
  23. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/distance/_distance.py +0 -0
  24. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/exceptions.py +0 -0
  25. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/measures/__init__.py +0 -0
  26. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/measures/_enums.py +0 -0
  27. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/measures/_measures.py +0 -0
  28. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/metrics/__init__.py +0 -0
  29. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/metrics/_scorer.py +0 -0
  30. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/model_selection/__init__.py +0 -0
  31. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/model_selection/_combinatorial.py +0 -0
  32. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/model_selection/_validation.py +0 -0
  33. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/model_selection/_walk_forward.py +0 -0
  34. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/__init__.py +0 -0
  35. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/__init__.py +0 -0
  36. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_base.py +0 -0
  37. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_denoise_covariance.py +0 -0
  38. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_detone_covariance.py +0 -0
  39. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_empirical_covariance.py +0 -0
  40. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_ew_covariance.py +0 -0
  41. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_gerber_covariance.py +0 -0
  42. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_graphical_lasso_cv.py +0 -0
  43. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_implied_covariance.py +0 -0
  44. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_ledoit_wolf.py +0 -0
  45. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_oas.py +0 -0
  46. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/covariance/_shrunk_covariance.py +0 -0
  47. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/expected_returns/__init__.py +0 -0
  48. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/expected_returns/_base.py +0 -0
  49. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/expected_returns/_empirical_mu.py +0 -0
  50. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/expected_returns/_equilibrium_mu.py +0 -0
  51. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/expected_returns/_ew_mu.py +0 -0
  52. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/moments/expected_returns/_shrunk_mu.py +0 -0
  53. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/__init__.py +0 -0
  54. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/_base.py +0 -0
  55. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/cluster/__init__.py +0 -0
  56. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/cluster/_nco.py +0 -0
  57. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/cluster/hierarchical/__init__.py +0 -0
  58. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/convex/__init__.py +0 -0
  59. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/convex/_base.py +0 -0
  60. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/convex/_distributionally_robust.py +0 -0
  61. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/convex/_maximum_diversification.py +0 -0
  62. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/convex/_mean_risk.py +0 -0
  63. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/convex/_risk_budgeting.py +0 -0
  64. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/ensemble/__init__.py +0 -0
  65. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/ensemble/_base.py +0 -0
  66. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/ensemble/_stacking.py +0 -0
  67. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/naive/__init__.py +0 -0
  68. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/optimization/naive/_naive.py +0 -0
  69. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/population/__init__.py +0 -0
  70. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/population/_population.py +0 -0
  71. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/portfolio/__init__.py +0 -0
  72. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/portfolio/_base.py +0 -0
  73. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/portfolio/_multi_period_portfolio.py +0 -0
  74. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/portfolio/_portfolio.py +0 -0
  75. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/pre_selection/__init__.py +0 -0
  76. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/pre_selection/_pre_selection.py +0 -0
  77. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/preprocessing/__init__.py +0 -0
  78. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/preprocessing/_returns.py +0 -0
  79. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/prior/__init__.py +0 -0
  80. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/prior/_base.py +0 -0
  81. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/prior/_black_litterman.py +0 -0
  82. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/prior/_empirical.py +0 -0
  83. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/prior/_factor_model.py +0 -0
  84. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/typing.py +0 -0
  85. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/uncertainty_set/__init__.py +0 -0
  86. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/uncertainty_set/_base.py +0 -0
  87. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/uncertainty_set/_bootstrap.py +0 -0
  88. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/uncertainty_set/_empirical.py +0 -0
  89. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/utils/__init__.py +0 -0
  90. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/utils/bootstrap.py +0 -0
  91. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/utils/equations.py +0 -0
  92. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/utils/sorting.py +0 -0
  93. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio/utils/tools.py +0 -0
  94. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio.egg-info/SOURCES.txt +0 -0
  95. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio.egg-info/dependency_links.txt +0 -0
  96. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio.egg-info/requires.txt +0 -0
  97. {skfolio-0.4.2 → skfolio-0.4.3}/src/skfolio.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: skfolio
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Portfolio optimization built on top of scikit-learn
5
5
  Author-email: Hugo Delatte <delatte.hugo@gmail.com>
6
6
  Maintainer-email: Hugo Delatte <delatte.hugo@gmail.com>
@@ -600,7 +600,7 @@ K-fold Cross-Validation
600
600
  # mmp is the predicted MultiPeriodPortfolio object composed of 5 Portfolios (1 per testing fold)
601
601
 
602
602
  mmp.plot_cumulative_returns()
603
- print(mmp.summary()
603
+ print(mmp.summary())
604
604
 
605
605
 
606
606
  Combinatorial Purged Cross-Validation
@@ -515,7 +515,7 @@ K-fold Cross-Validation
515
515
  # mmp is the predicted MultiPeriodPortfolio object composed of 5 Portfolios (1 per testing fold)
516
516
 
517
517
  mmp.plot_cumulative_returns()
518
- print(mmp.summary()
518
+ print(mmp.summary())
519
519
 
520
520
 
521
521
  Combinatorial Purged Cross-Validation
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skfolio"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  maintainers = [
9
9
  { name = "Hugo Delatte", email = "delatte.hugo@gmail.com" },
10
10
  ]
@@ -52,8 +52,6 @@ class BaseHierarchicalOptimization(BaseOptimization, ABC):
52
52
  * ENTROPIC_RISK_MEASURE
53
53
  * FOURTH_CENTRAL_MOMENT
54
54
  * FOURTH_LOWER_PARTIAL_MOMENT
55
- * SKEW
56
- * KURTOSIS
57
55
 
58
56
  The default is `RiskMeasure.VARIANCE`.
59
57
 
@@ -80,12 +78,12 @@ class BaseHierarchicalOptimization(BaseOptimization, ABC):
80
78
 
81
79
  min_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
82
80
  Minimum assets weights (weights lower bounds). Negative weights are not allowed.
83
- If a float is provided, it is applied to each asset. `None` is equivalent to
84
- `-np.Inf` (no lower bound). If a dictionary is provided, its (key/value) pair
85
- must be the (asset name/asset minium weight) and the input `X` of the `fit`
86
- methods must be a DataFrame with the assets names in columns. When using a
87
- dictionary, assets values that are not provided are assigned a minimum weight
88
- of `0.0`. The default is 0.0 (no short selling).
81
+ If a float is provided, it is applied to each asset.
82
+ If a dictionary is provided, its (key/value) pair must be the
83
+ (asset name/asset minium weight) and the input `X` of the `fit` methods must be
84
+ a DataFrame with the assets names in columns.
85
+ When using a dictionary, assets values that are not provided are assigned a
86
+ minimum weight of `0.0`. The default is 0.0 (no short selling).
89
87
 
90
88
  Example:
91
89
 
@@ -96,12 +94,12 @@ class BaseHierarchicalOptimization(BaseOptimization, ABC):
96
94
 
97
95
  max_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=1.0
98
96
  Maximum assets weights (weights upper bounds). Weights above 1.0 are not
99
- allowed. If a float is provided, it is applied to each asset. `None` is
100
- equivalent to `+np.Inf` (no upper bound). If a dictionary is provided, its
101
- (key/value) pair must be the (asset name/asset maximum weight) and the input `X`
102
- of the `fit` method must be a DataFrame with the assets names in columns. When
103
- using a dictionary, assets values that are not provided are assigned a minimum
104
- weight of `1.0`. The default is 1.0 (each asset is below 100%).
97
+ allowed. If a float is provided, it is applied to each asset.
98
+ If a dictionary is provided, its (key/value) pair must be the
99
+ (asset name/asset maximum weight) and the input `X` of the `fit` method must be
100
+ a DataFrame with the assets names in columns.
101
+ When using a dictionary, assets values that are not provided are assigned a
102
+ minimum weight of `1.0`. The default is 1.0 (each asset is below 100%).
105
103
 
106
104
  Example:
107
105
 
@@ -388,57 +386,6 @@ class BaseHierarchicalOptimization(BaseOptimization, ABC):
388
386
 
389
387
  return min_weights, max_weights
390
388
 
391
- @staticmethod
392
- def _apply_weight_constraints_to_alpha(
393
- alpha: float,
394
- max_weights: np.ndarray,
395
- min_weights: np.ndarray,
396
- weights: np.ndarray,
397
- left_cluster: np.ndarray,
398
- right_cluster: np.ndarray,
399
- ) -> float:
400
- """Apply weight constraints to the alpha multiplication factor of the
401
- Hierarchical Tree Clustering algorithm.
402
-
403
- Parameters
404
- ----------
405
- alpha : float
406
- The alpha multiplication factor of the Hierarchical Tree Clustering
407
- algorithm.
408
-
409
- min_weights : ndarray of shape (n_assets,)
410
- The weight lower bound 1D array.
411
-
412
- max_weights : ndarray of shape (n_assets,)
413
- The weight upper bound 1D array.
414
-
415
- weights : np.ndarray of shape (n_assets,)
416
- The assets weights.
417
-
418
- left_cluster : ndarray of shape (n_left_cluster,)
419
- Indices of the left cluster weights.
420
-
421
- right_cluster : ndarray of shape (n_right_cluster,)
422
- Indices of the right cluster weights.
423
-
424
- Returns
425
- -------
426
- value : float
427
- The transformed alpha incorporating the weight constraints.
428
- """
429
- alpha = min(
430
- np.sum(max_weights[left_cluster]) / weights[left_cluster[0]],
431
- max(np.sum(min_weights[left_cluster]) / weights[left_cluster[0]], alpha),
432
- )
433
- alpha = 1 - min(
434
- np.sum(max_weights[right_cluster]) / weights[right_cluster[0]],
435
- max(
436
- np.sum(min_weights[right_cluster]) / weights[right_cluster[0]],
437
- 1 - alpha,
438
- ),
439
- )
440
- return alpha
441
-
442
389
  def get_metadata_routing(self):
443
390
  # noinspection PyTypeChecker
444
391
  router = (
@@ -3,8 +3,7 @@
3
3
  # Copyright (c) 2023
4
4
  # Author: Hugo Delatte <delatte.hugo@gmail.com>
5
5
  # License: BSD 3 clause
6
- # The risk measure generalization and constraint features are derived
7
- # from Riskfolio-Lib, Copyright (c) 2020-2023, Dany Cajas, Licensed under BSD 3 clause.
6
+ # Weight constraints is a novel implementation, see docstring for more details.
8
7
 
9
8
  import numpy as np
10
9
  import numpy.typing as npt
@@ -20,6 +19,7 @@ from skfolio.optimization.cluster.hierarchical._base import (
20
19
  BaseHierarchicalOptimization,
21
20
  )
22
21
  from skfolio.prior import BasePrior, EmpiricalPrior
22
+ from skfolio.utils.stats import minimize_relative_weight_deviation
23
23
  from skfolio.utils.tools import check_estimator
24
24
 
25
25
 
@@ -45,6 +45,32 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
45
45
  which is more stable and has better properties than the single-linkage
46
46
  method [4]_.
47
47
 
48
+ Also, the initial paper does not provide an algorithm for handling weight
49
+ constraints, and no standard solution currently exists.
50
+ In contrast to HRP (Hierarchical Risk Parity), where weight constraints
51
+ can be applied to the split factor at each bisection step, HERC
52
+ (Hierarchical Equal Risk Contribution) cannot incorporate weight constraints
53
+ during the intermediate steps of the allocation. Therefore, in HERC, the
54
+ weight constraints must be enforced after the top-down allocation has been
55
+ completed.
56
+ In skfolio, we minimize the relative deviation of the final weights from
57
+ the initial weights. This is formulated as a convex optimization problem:
58
+
59
+ .. math::
60
+ \begin{cases}
61
+ \begin{aligned}
62
+ &\min_{w} & & \Vert \frac{w - w_{init}}{w_{init}} \Vert_{2}^{2} \\
63
+ &\text{s.t.} & & \sum_{i=1}^{N} w_{i} = 1 \\
64
+ & & & w_{min} \leq w_i \leq w_{max}, \quad \forall i
65
+ \end{aligned}
66
+ \end{cases}
67
+
68
+ The reason for minimizing the relative deviation (as opposed to the absolute
69
+ deviation) is that we want to limit the impact on the risk contribution of
70
+ each asset. Since HERC allocates inversely to risk, adjusting the weights
71
+ based on relative deviation ensures that the assets' risk contributions
72
+ remain proportionally consistent with the initial allocation.
73
+
48
74
  Parameters
49
75
  ----------
50
76
  risk_measure : RiskMeasure or ExtraRiskMeasure, default=RiskMeasure.VARIANCE
@@ -70,8 +96,6 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
70
96
  * ENTROPIC_RISK_MEASURE
71
97
  * FOURTH_CENTRAL_MOMENT
72
98
  * FOURTH_LOWER_PARTIAL_MOMENT
73
- * SKEW
74
- * KURTOSIS
75
99
 
76
100
  The default is `RiskMeasure.VARIANCE`.
77
101
 
@@ -98,12 +122,12 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
98
122
 
99
123
  min_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
100
124
  Minimum assets weights (weights lower bounds). Negative weights are not allowed.
101
- If a float is provided, it is applied to each asset. `None` is equivalent to
102
- `-np.Inf` (no lower bound). If a dictionary is provided, its (key/value) pair
103
- must be the (asset name/asset minium weight) and the input `X` of the `fit`
104
- methods must be a DataFrame with the assets names in columns. When using a
105
- dictionary, assets values that are not provided are assigned a minimum weight
106
- of `0.0`. The default is 0.0 (no short selling).
125
+ If a float is provided, it is applied to each asset.
126
+ If a dictionary is provided, its (key/value) pair must be the
127
+ (asset name/asset minium weight) and the input `X` of the `fit` methods must be
128
+ a DataFrame with the assets names in columns.
129
+ When using a dictionary, assets values that are not provided are assigned a
130
+ minimum weight of `0.0`. The default is 0.0 (no short selling).
107
131
 
108
132
  Example:
109
133
 
@@ -114,12 +138,12 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
114
138
 
115
139
  max_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=1.0
116
140
  Maximum assets weights (weights upper bounds). Weights above 1.0 are not
117
- allowed. If a float is provided, it is applied to each asset. `None` is
118
- equivalent to `+np.Inf` (no upper bound). If a dictionary is provided, its
119
- (key/value) pair must be the (asset name/asset maximum weight) and the input `X`
120
- of the `fit` method must be a DataFrame with the assets names in columns. When
121
- using a dictionary, assets values that are not provided are assigned a minimum
122
- weight of `1.0`. The default is 1.0 (each asset is below 100%).
141
+ allowed. If a float is provided, it is applied to each asset.
142
+ If a dictionary is provided, its (key/value) pair must be the
143
+ (asset name/asset maximum weight) and the input `X` of the `fit` method must be
144
+ a DataFrame with the assets names in columns.
145
+ When using a dictionary, assets values that are not provided are assigned a
146
+ minimum weight of `1.0`. The default is 1.0 (each asset is below 100%).
123
147
 
124
148
  Example:
125
149
 
@@ -208,6 +232,19 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
208
232
  `management_fees`, `previous_weights` and `risk_free_rate` are copied from the
209
233
  optimization model and passed to the portfolio.
210
234
 
235
+ solver : str, default="CLARABEL"
236
+ The solver used for the weights constraints optimization. The default is
237
+ "CLARABEL" which is written in Rust and has better numerical stability and
238
+ performance than ECOS and SCS.
239
+ For more details about available solvers, check the CVXPY documentation:
240
+ https://www.cvxpy.org/tutorial/advanced/index.html#choosing-a-solver
241
+
242
+ solver_params : dict, optional
243
+ Solver parameters. For example, `solver_params=dict(verbose=True)`.
244
+ The default (`None`) is to use the CVXPY default.
245
+ For more details about solver arguments, check the CVXPY documentation:
246
+ https://www.cvxpy.org/tutorial/advanced/index.html#setting-solver-options
247
+
211
248
  Attributes
212
249
  ----------
213
250
  weights_ : ndarray of shape (n_assets,)
@@ -251,6 +288,8 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
251
288
  hierarchical_clustering_estimator: HierarchicalClustering | None = None,
252
289
  min_weights: skt.MultiInput | None = 0.0,
253
290
  max_weights: skt.MultiInput | None = 1.0,
291
+ solver: str = "CLARABEL",
292
+ solver_params: dict | None = None,
254
293
  transaction_costs: skt.MultiInput = 0.0,
255
294
  management_fees: skt.MultiInput = 0.0,
256
295
  previous_weights: skt.MultiInput | None = None,
@@ -268,6 +307,8 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
268
307
  previous_weights=previous_weights,
269
308
  portfolio_params=portfolio_params,
270
309
  )
310
+ self.solver = solver
311
+ self.solver_params = solver_params
271
312
 
272
313
  def fit(
273
314
  self, X: npt.ArrayLike, y: None = None, **fit_params
@@ -301,6 +342,13 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
301
342
  raise TypeError(
302
343
  "`risk_measure` must be of type `RiskMeasure` or `ExtraRiskMeasure`"
303
344
  )
345
+
346
+ if self.risk_measure in [ExtraRiskMeasure.SKEW, ExtraRiskMeasure.KURTOSIS]:
347
+ # Because Skew and Kurtosis can take negative values
348
+ raise ValueError(
349
+ f"risk_measure {self.risk_measure} currently not supported" f"in HERC"
350
+ )
351
+
304
352
  self.prior_estimator_ = check_estimator(
305
353
  self.prior_estimator,
306
354
  default=EmpiricalPrior(),
@@ -393,21 +441,12 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
393
441
 
394
442
  left_cluster = np.array(left_cluster)
395
443
  right_cluster = np.array(right_cluster)
444
+
396
445
  left_risk = np.sum(cluster_risks[left_cluster])
397
446
  right_risk = np.sum(cluster_risks[right_cluster])
398
447
 
399
448
  alpha = 1 - left_risk / (left_risk + right_risk)
400
449
 
401
- # Weights constraints
402
- alpha = self._apply_weight_constraints_to_alpha(
403
- alpha=alpha,
404
- weights=weights,
405
- max_weights=max_weights,
406
- min_weights=min_weights,
407
- left_cluster=left_cluster,
408
- right_cluster=right_cluster,
409
- )
410
-
411
450
  clusters_weights[left_cluster] *= alpha
412
451
  clusters_weights[right_cluster] *= 1 - alpha
413
452
 
@@ -421,5 +460,15 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
421
460
  for i, cluster_ids in enumerate(clusters):
422
461
  weights[cluster_ids] *= clusters_weights[i]
423
462
 
463
+ # Apply weights constraints
464
+ weights = minimize_relative_weight_deviation(
465
+ weights=weights,
466
+ min_weights=min_weights,
467
+ max_weights=max_weights,
468
+ solver=self.solver,
469
+ solver_params=self.solver_params,
470
+ )
471
+
424
472
  self.weights_ = weights
473
+
425
474
  return self
@@ -72,8 +72,6 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
72
72
  * ENTROPIC_RISK_MEASURE
73
73
  * FOURTH_CENTRAL_MOMENT
74
74
  * FOURTH_LOWER_PARTIAL_MOMENT
75
- * SKEW
76
- * KURTOSIS
77
75
 
78
76
  The default is `RiskMeasure.VARIANCE`.
79
77
 
@@ -100,9 +98,9 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
100
98
 
101
99
  min_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
102
100
  Minimum assets weights (weights lower bounds). Negative weights are not allowed.
103
- If a float is provided, it is applied to each asset. `None` is equivalent to
104
- `-np.Inf` (no lower bound). If a dictionary is provided, its (key/value) pair
105
- must be the (asset name/asset minium weight) and the input `X` of the `fit`
101
+ If a float is provided, it is applied to each asset.
102
+ If a dictionary is provided, its (key/value) pair must be the
103
+ (asset name/asset minium weight) and the input `X` of the `fit`
106
104
  methods must be a DataFrame with the assets names in columns. When using a
107
105
  dictionary, assets values that are not provided are assigned a minimum weight
108
106
  of `0.0`. The default is 0.0 (no short selling).
@@ -116,12 +114,12 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
116
114
 
117
115
  max_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=1.0
118
116
  Maximum assets weights (weights upper bounds). Weights above 1.0 are not
119
- allowed. If a float is provided, it is applied to each asset. `None` is
120
- equivalent to `+np.Inf` (no upper bound). If a dictionary is provided, its
121
- (key/value) pair must be the (asset name/asset maximum weight) and the input `X`
122
- of the `fit` method must be a DataFrame with the assets names in columns. When
123
- using a dictionary, assets values that are not provided are assigned a minimum
124
- weight of `1.0`. The default is 1.0 (each asset is below 100%).
117
+ allowed. If a float is provided, it is applied to each asset.
118
+ If a dictionary is provided, its (key/value) pair must be the
119
+ (asset name/asset maximum weight) and the input `X` of the `fit` method must
120
+ be a DataFrame with the assets names in columns.
121
+ When using a dictionary, assets values that are not provided are assigned a
122
+ minimum weight of `1.0`. The default is 1.0 (each asset is below 100%).
125
123
 
126
124
  Example:
127
125
 
@@ -296,6 +294,13 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
296
294
  raise TypeError(
297
295
  "`risk_measure` must be of type `RiskMeasure` or `ExtraRiskMeasure`"
298
296
  )
297
+
298
+ if self.risk_measure in [ExtraRiskMeasure.SKEW, ExtraRiskMeasure.KURTOSIS]:
299
+ # Because Skew and Kurtosis can take negative values
300
+ raise ValueError(
301
+ f"risk_measure {self.risk_measure} currently not supported" f"in HRP"
302
+ )
303
+
299
304
  self.prior_estimator_ = check_estimator(
300
305
  self.prior_estimator,
301
306
  default=EmpiricalPrior(),
@@ -365,7 +370,7 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
365
370
  left_cluster, right_cluster = clusters_ids
366
371
  alpha = 1 - left_risk / (left_risk + right_risk)
367
372
  # Weights constraints
368
- alpha = self._apply_weight_constraints_to_alpha(
373
+ alpha = _apply_weight_constraints_to_split_factor(
369
374
  alpha=alpha,
370
375
  weights=weights,
371
376
  max_weights=max_weights,
@@ -379,3 +384,54 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
379
384
 
380
385
  self.weights_ = weights
381
386
  return self
387
+
388
+
389
+ def _apply_weight_constraints_to_split_factor(
390
+ alpha: float,
391
+ max_weights: np.ndarray,
392
+ min_weights: np.ndarray,
393
+ weights: np.ndarray,
394
+ left_cluster: np.ndarray,
395
+ right_cluster: np.ndarray,
396
+ ) -> float:
397
+ """
398
+ Apply weight constraints to the split factor alpha of the ,Hierarchical Tree
399
+ Clustering algorithm.
400
+
401
+ Parameters
402
+ ----------
403
+ alpha : float
404
+ The split factor alpha of the Hierarchical Tree Clustering algorithm.
405
+
406
+ min_weights : ndarray of shape (n_assets,)
407
+ The weight lower bound 1D array.
408
+
409
+ max_weights : ndarray of shape (n_assets,)
410
+ The weight upper bound 1D array.
411
+
412
+ weights : np.ndarray of shape (n_assets,)
413
+ The assets weights.
414
+
415
+ left_cluster : ndarray of shape (n_left_cluster,)
416
+ Indices of the left cluster weights.
417
+
418
+ right_cluster : ndarray of shape (n_right_cluster,)
419
+ Indices of the right cluster weights.
420
+
421
+ Returns
422
+ -------
423
+ value : float
424
+ The transformed split factor alpha incorporating the weight constraints.
425
+ """
426
+ alpha = min(
427
+ np.sum(max_weights[left_cluster]) / weights[left_cluster[0]],
428
+ max(np.sum(min_weights[left_cluster]) / weights[left_cluster[0]], alpha),
429
+ )
430
+ alpha = 1 - min(
431
+ np.sum(max_weights[right_cluster]) / weights[right_cluster[0]],
432
+ max(
433
+ np.sum(min_weights[right_cluster]) / weights[right_cluster[0]],
434
+ 1 - alpha,
435
+ ),
436
+ )
437
+ return alpha
@@ -10,9 +10,11 @@ import warnings
10
10
  # Statsmodels, Copyright (C) 2006, Jonathan E. Taylor, Licensed under BSD 3 clause.
11
11
  from enum import auto
12
12
 
13
+ import cvxpy as cp
13
14
  import numpy as np
14
15
  import scipy.cluster.hierarchy as sch
15
16
  import scipy.optimize as sco
17
+ import scipy.sparse.linalg as scl
16
18
  import scipy.spatial.distance as scd
17
19
  import scipy.special as scs
18
20
  from scipy.sparse import csr_matrix
@@ -34,6 +36,7 @@ __all__ = [
34
36
  "compute_optimal_n_clusters",
35
37
  "rand_weights",
36
38
  "rand_weights_dirichlet",
39
+ "minimize_relative_weight_deviation",
37
40
  ]
38
41
 
39
42
 
@@ -488,3 +491,87 @@ def compute_optimal_n_clusters(distance: np.ndarray, linkage_matrix: np.ndarray)
488
491
  # k=0 represents one cluster
489
492
  k = np.argmax(gaps) + 2
490
493
  return k
494
+
495
+
496
+ def minimize_relative_weight_deviation(
497
+ weights: np.ndarray,
498
+ min_weights: np.ndarray,
499
+ max_weights: np.ndarray,
500
+ solver: str = "CLARABEL",
501
+ solver_params: dict | None = None,
502
+ ) -> np.ndarray:
503
+ r"""
504
+ Apply weight constraints to an initial array of weights by minimizing the relative
505
+ weight deviation of the final weights from the initial weights.
506
+
507
+ .. math::
508
+ \begin{cases}
509
+ \begin{aligned}
510
+ &\min_{w} & & \Vert \frac{w - w_{init}}{w_{init}} \Vert_{2}^{2} \\
511
+ &\text{s.t.} & & \sum_{i=1}^{N} w_{i} = 1 \\
512
+ & & & w_{min} \leq w_i \leq w_{max}, \quad \forall i
513
+ \end{aligned}
514
+ \end{cases}
515
+
516
+ Parameters
517
+ ----------
518
+ weights : ndarray of shape (n_assets,)
519
+ Initial weights.
520
+
521
+ min_weights : ndarray of shape (n_assets,)
522
+ Minimum assets weights (weights lower bounds).
523
+
524
+ max_weights : ndarray of shape (n_assets,)
525
+ Maximum assets weights (weights upper bounds).
526
+
527
+ solver : str, default="CLARABEL"
528
+ The solver to use. The default is "CLARABEL" which is written in Rust and has
529
+ better numerical stability and performance than ECOS and SCS.
530
+ For more details about available solvers, check the CVXPY documentation:
531
+ https://www.cvxpy.org/tutorial/advanced/index.html#choosing-a-solver
532
+
533
+ solver_params : dict, optional
534
+ Solver parameters. For example, `solver_params=dict(verbose=True)`.
535
+ The default (`None`) is to use the CVXPY default.
536
+ For more details about solver arguments, check the CVXPY documentation:
537
+ https://www.cvxpy.org/tutorial/advanced/index.html#setting-solver-options
538
+ """
539
+ if not (weights.shape == min_weights.shape == max_weights.shape):
540
+ raise ValueError("`min_weights` and `max_weights` must have same size")
541
+
542
+ if np.any(weights < 0):
543
+ raise ValueError("Initial weights must be strictly positive")
544
+
545
+ if not np.isclose(np.sum(weights), 1.0):
546
+ raise ValueError("Initial weights must sum to one")
547
+
548
+ if np.any(max_weights < min_weights):
549
+ raise ValueError("`min_weights` must be lower or equal to `max_weights`")
550
+
551
+ if np.all((weights >= min_weights) & (weights <= max_weights)):
552
+ return weights
553
+
554
+ if solver_params is None:
555
+ solver_params = {}
556
+
557
+ n = len(weights)
558
+ w = cp.Variable(n)
559
+
560
+ objective = cp.Minimize(cp.norm(w / weights - 1))
561
+ constraints = [cp.sum(w) == 1, w >= min_weights, w <= max_weights]
562
+ problem = cp.Problem(objective, constraints)
563
+
564
+ try:
565
+ problem.solve(solver=solver, **solver_params)
566
+
567
+ if w.value is None:
568
+ raise cp.SolverError("No solution found")
569
+
570
+ except (cp.SolverError, scl.ArpackNoConvergence):
571
+ raise cp.SolverError(
572
+ f"Solver '{solver}' failed. Try another"
573
+ " solver, or solve with solver_params=dict(verbose=True) for more"
574
+ " information"
575
+ ) from None
576
+
577
+ return w.value
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: skfolio
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Portfolio optimization built on top of scikit-learn
5
5
  Author-email: Hugo Delatte <delatte.hugo@gmail.com>
6
6
  Maintainer-email: Hugo Delatte <delatte.hugo@gmail.com>
@@ -600,7 +600,7 @@ K-fold Cross-Validation
600
600
  # mmp is the predicted MultiPeriodPortfolio object composed of 5 Portfolios (1 per testing fold)
601
601
 
602
602
  mmp.plot_cumulative_returns()
603
- print(mmp.summary()
603
+ print(mmp.summary())
604
604
 
605
605
 
606
606
  Combinatorial Purged Cross-Validation
File without changes
File without changes
File without changes
File without changes
File without changes