skfolio 0.9.0__py3-none-any.whl → 0.10.0__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.
Files changed (42) hide show
  1. skfolio/distribution/multivariate/_vine_copula.py +35 -34
  2. skfolio/distribution/univariate/_base.py +20 -15
  3. skfolio/exceptions.py +5 -0
  4. skfolio/measures/__init__.py +2 -0
  5. skfolio/measures/_measures.py +390 -155
  6. skfolio/optimization/_base.py +21 -4
  7. skfolio/optimization/cluster/hierarchical/_base.py +16 -13
  8. skfolio/optimization/cluster/hierarchical/_herc.py +6 -6
  9. skfolio/optimization/cluster/hierarchical/_hrp.py +8 -6
  10. skfolio/optimization/convex/_base.py +238 -144
  11. skfolio/optimization/convex/_distributionally_robust.py +32 -20
  12. skfolio/optimization/convex/_maximum_diversification.py +15 -18
  13. skfolio/optimization/convex/_mean_risk.py +35 -25
  14. skfolio/optimization/convex/_risk_budgeting.py +23 -21
  15. skfolio/optimization/ensemble/__init__.py +2 -4
  16. skfolio/optimization/ensemble/_stacking.py +1 -1
  17. skfolio/optimization/naive/_naive.py +2 -2
  18. skfolio/population/_population.py +30 -9
  19. skfolio/portfolio/_base.py +68 -26
  20. skfolio/portfolio/_multi_period_portfolio.py +5 -0
  21. skfolio/portfolio/_portfolio.py +5 -0
  22. skfolio/pre_selection/_select_non_expiring.py +1 -1
  23. skfolio/prior/__init__.py +6 -2
  24. skfolio/prior/_base.py +7 -3
  25. skfolio/prior/_black_litterman.py +14 -12
  26. skfolio/prior/_empirical.py +8 -7
  27. skfolio/prior/_entropy_pooling.py +1493 -0
  28. skfolio/prior/_factor_model.py +39 -22
  29. skfolio/prior/_opinion_pooling.py +475 -0
  30. skfolio/prior/_synthetic_data.py +10 -8
  31. skfolio/uncertainty_set/_bootstrap.py +4 -4
  32. skfolio/uncertainty_set/_empirical.py +6 -6
  33. skfolio/utils/equations.py +11 -5
  34. skfolio/utils/figure.py +185 -0
  35. skfolio/utils/tools.py +4 -2
  36. {skfolio-0.9.0.dist-info → skfolio-0.10.0.dist-info}/METADATA +94 -5
  37. {skfolio-0.9.0.dist-info → skfolio-0.10.0.dist-info}/RECORD +41 -39
  38. {skfolio-0.9.0.dist-info → skfolio-0.10.0.dist-info}/WHEEL +1 -1
  39. skfolio/synthetic_returns/__init__.py +0 -1
  40. /skfolio/{optimization/ensemble/_base.py → utils/composition.py} +0 -0
  41. {skfolio-0.9.0.dist-info → skfolio-0.10.0.dist-info}/licenses/LICENSE +0 -0
  42. {skfolio-0.9.0.dist-info → skfolio-0.10.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1493 @@
1
+ """Entropy Pooling estimator."""
2
+
3
+ # Copyright (c) 2025
4
+ # Author: Hugo Delatte <delatte.hugo@gmail.com>
5
+ # Credits: Vincent Maladière, Matteo Manzi, Carlo Nicolini
6
+ # SPDX-License-Identifier: BSD-3-Clause
7
+
8
+ import operator
9
+ import re
10
+ import warnings
11
+ from collections.abc import Sequence
12
+ from typing import TYPE_CHECKING
13
+
14
+ import cvxpy as cp
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+ import scipy.optimize as sco
18
+ import scipy.sparse.linalg as scl
19
+ import scipy.special as scs
20
+ import scipy.stats as sts
21
+ import sklearn.utils.metadata_routing as skm
22
+ import sklearn.utils.validation as skv
23
+
24
+ import skfolio.measures as sm
25
+ import skfolio.typing as skt
26
+ from skfolio.exceptions import SolverError
27
+ from skfolio.measures import (
28
+ ExtraRiskMeasure,
29
+ PerfMeasure,
30
+ RiskMeasure,
31
+ )
32
+ from skfolio.prior._base import BasePrior, ReturnDistribution
33
+ from skfolio.prior._empirical import EmpiricalPrior
34
+ from skfolio.utils.equations import equations_to_matrix
35
+ from skfolio.utils.tools import check_estimator, default_asset_names, input_to_array
36
+
37
+
38
+ class EntropyPooling(BasePrior):
39
+ r"""Entropy Pooling estimator.
40
+
41
+ Entropy Pooling, introduced by Attilio Meucci in 2008 as a generalization of the
42
+ Black-Litterman framework, is a nonparametric method for adjusting a baseline
43
+ ("prior") probability distribution to incorporate user-defined views by finding the
44
+ posterior distribution closest to the prior while satisfying those views.
45
+
46
+ User-defined views can be **elicited** from domain experts or **derived** from
47
+ quantitative analyses.
48
+
49
+ Grounded in information theory, it updates the distribution in the least-informative
50
+ way by minimizing the Kullback-Leibler divergence (relative entropy) under the
51
+ specified view constraints.
52
+
53
+ Mathematically, the problem is formulated as:
54
+
55
+ .. math::
56
+
57
+ \begin{aligned}
58
+ \min_{\mathbf{q}} \quad & \sum_{i=1}^T q_i \log\left(\frac{q_i}{p_i}\right) \\
59
+ \text{subject to} \quad & \sum_{i=1}^T q_i = 1 \quad \text{(normalization constraint)} \\
60
+ & \mathbb{E}_q[f_j(X)] = v_j \quad(\text{or } \le v_j, \text{ or } \ge v_j), \quad j = 1,\dots,k, \text{(view constraints)} \\
61
+ & q_i \ge 0, \quad i = 1, \dots, T
62
+ \end{aligned}
63
+
64
+ Where:
65
+
66
+ - :math:`T` is the number of observations (number of scenarios).
67
+ - :math:`p_i` is the prior probability of scenario :math:`x_i`.
68
+ - :math:`q_i` is the posterior probability of scenario :math:`x_i`.
69
+ - :math:`X` is the scenario matrix of shape (n_observations, n_assets).
70
+ - :math:`f_j` is the j :sup:`th` view function.
71
+ - :math:`v_j` is the target value imposed by the j :sup:`th` view.
72
+ - :math:`k` is the total number of views.
73
+
74
+ The `skfolio` implementation supports the following views:
75
+ * Equalities
76
+ * Inequalities
77
+ * Ranking
78
+ * Linear combinations (e.g. relative views)
79
+ * Views on groups of assets
80
+
81
+ On the following measures:
82
+ * Mean
83
+ * Variance
84
+ * Skew
85
+ * Kurtosis
86
+ * Correlation
87
+ * Value-at-Risk (VaR)
88
+ * Conditional Value-at-Risk (CVaR)
89
+
90
+ Notes
91
+ -----
92
+ Entropy Pooling re-weights the sample probabilities of the prior distribution and is
93
+ therefore constrained by the support (completeness) of that distribution. For
94
+ example, if the historical distribution contains no returns below -10% for a given
95
+ asset, we cannot impose a CVaR view of 15%: no matter how we adjust the sample
96
+ probabilities, such tail data do not exist.
97
+
98
+ Therefore, to impose extreme views on a sparse historical distribution, one must
99
+ generate synthetic data. In that case, the EP posterior is only as reliable as the
100
+ synthetic scenarios. It is thus essential to use a generator capable of
101
+ extrapolating tail dependencies, such as :class:`~skfolio.distribution.VineCopula`,
102
+ to model joint extreme events accurately.
103
+
104
+ Two methods are available:
105
+ * Dual form: solves the Fenchel dual of the EP problem using Truncated
106
+ Newton Constrained method.
107
+ * Primal form: solves the original relative-entropy projection in
108
+ probability-space via interior-point algorithms.
109
+
110
+ See the solver parameter's docstring for full details on available solvers and
111
+ options.
112
+
113
+ To handle nonlinear views, constraints are linearized by fixing the relevant asset
114
+ moments (e.g., means or variances) and then solved via **nested entropic tilting**.
115
+ At each stage, the KL-divergence is minimized relative to the original prior,
116
+ while nesting all previously enforced (linearized) views into the feasible set:
117
+
118
+ * Stage 1: impose views on asset means, VaR and CVaR.
119
+ * Stage 2: carry forward Stage 1 constraints and add variance, fixing the
120
+ mean at its Stage 1 value.
121
+ * Stage 3: carry forward Stage 2 constraints and add skewness, kurtosis and
122
+ pairwise correlations, fixing both mean and variance at their Stage 2 values.
123
+
124
+ Because each entropic projection nests the prior views, every constraint from
125
+ earlier stages is preserved as new ones are added, yielding a final distribution
126
+ that satisfies all original nonlinear views while staying as close as possible to
127
+ the original prior.
128
+
129
+ Only the necessary moments are fixed. Slack variables with an L1 norm penalty are
130
+ introduced to avoid solver infeasibility that may arise from overly tight
131
+ constraints.
132
+
133
+ CVaR view constraints cannot be directly expressed as linear functions of the
134
+ posterior probabilities. Therefore, when CVaR views are present, the EP problem is
135
+ solved by recursively solving a series of convex programs that approximate the
136
+ nonlinear CVaR constraint.
137
+
138
+ This implementation improves upon Meucci's algorithm [1]_ by formulating the problem
139
+ in continuous space as a function of the dual variables etas (VaR levels), rather
140
+ than searching over discrete tail sizes. This formulation not only handles the
141
+ CVaR constraint more directly but also supports multiple CVaR views on different
142
+ assets.
143
+
144
+ Although the overall problem is convex in the dual variables etas, it remains
145
+ non-smooth due to the presence of the positive-part operator in the CVaR
146
+ definition. Consequently, we employ derivative-free optimization methods.
147
+ Specifically, for a single CVaR view we use a one-dimensional root-finding
148
+ method (Brent's method), and for the multivariate case (supporting multiple
149
+ CVaR views) we use Powell's method for derivative-free convex descent.
150
+
151
+ Parameters
152
+ ----------
153
+ prior_estimator : BasePrior, optional
154
+ Estimator of the asset's prior distribution, fitted from a
155
+ :ref:`prior estimator <prior>`. The default (`None`) is to use the
156
+ empirical prior :class:`~skfolio.prior.EmpiricalPrior()`. To perform Entropy
157
+ Pooling on synthetic data, you can use :class:`~skfolio.prior.SyntheticData`
158
+ by setting `prior_estimator = SyntheticData()`.
159
+
160
+ mean_views : list[str], optional
161
+ Views on asset means.
162
+ The views must match any of following patterns:
163
+
164
+ * `"ref1 >= a"`
165
+ * `"ref1 == b"`
166
+ * `"ref1 <= ref1"`
167
+ * `"ref1 >= a * prior(ref1)"`
168
+ * `"ref1 == b * prior(ref2)"`
169
+ * `"a * ref1 + b * ref2 + c <= d * ref3"`
170
+
171
+ With `"ref1"`, `"ref2"` ... the assets names or the groups names provided
172
+ in the parameter `groups`. Assets names can be referenced without the need of
173
+ `groups` if the input `X` of the `fit` method is a DataFrame with assets names
174
+ in columns. Otherwise, the default asset names `x0, x1, ...` are assigned.
175
+ By using the term `prior(...)`, you can reference the asset prior mean.
176
+
177
+ For example:
178
+
179
+ * `"SPX >= 0.0015"` --> The mean of SPX is greater than 0.15% (daily mean if
180
+ `X` is daily)
181
+ * `"SX5E == 0.002"` --> The mean of SX5E equals 0.2%
182
+ * `"AAPL <= 0.003"` --> The mean of AAPL is less than to 0.3%
183
+ * `"SPX <= SX5E"` --> Ranking view: the mean of SPX is less than SX5E
184
+ * `"SPX >= 1.5 * prior(SPX)"` --> The mean of SPX increases by at least 50% (versus its prior)
185
+ * `"SX5E == 2 * prior(SX5E)"` --> The mean of SX5E doubles (versus its prior)
186
+ * `"AAPL <= 0.8 * prior(SPX)"` --> The mean of AAPL is less than 0.8 times the SPX prior
187
+ * `"SX5E + SPX >= 0"` --> The sum of SX5E and SPX mean is greater than zero
188
+ * `"US == 0.007"` --> The sum of means of US assets equals 0.7%
189
+ * `"Equity == 3 * Bond"` --> The sum of means of Equity assets equals
190
+ three times the sum of means of Bond assets.
191
+ * `"2*SPX + 3*Europe <= Bond + 0.05"` --> Mixing assets and group mean views
192
+
193
+ variance_views : list[str], optional
194
+ Views on asset variances.
195
+ It supports the same patterns as `mean_views`.
196
+
197
+ For example:
198
+
199
+ * `"SPX >= 0.0009"` --> SPX variance is greater than 0.0009 (daily)
200
+ * `"SX5E == 1.5 * prior(SX5E)"` --> SX5E variance increases by 150% (versus
201
+ its prior)
202
+
203
+ skew_views : list[str], optional
204
+ Views on asset skews.
205
+ It supports the same patterns as `mean_views`.
206
+
207
+ For example:
208
+
209
+ * `"SPX >= 2.0"` --> SPX skew is greater than 2.0
210
+ * `"SX5E == 1.5 * prior(SX5E)"` --> SX5E skew increases by 150% (versus its
211
+ prior)
212
+
213
+ kurtosis_views : list[str], optional
214
+ Views on asset kurtosis.
215
+ It supports the same patterns as `mean_views`.
216
+
217
+ For example:
218
+
219
+ * `"SPX >= 9.0"` --> SPX kurtosis is greater than 9.0
220
+ * `"SX5E == 1.5 * prior(SX5E)"` --> SX5E kurtosis increases by 150% (versus
221
+ its prior)
222
+
223
+ correlation_views : list[str], optional
224
+ Views on asset correlations.
225
+ The views must match any of following patterns:
226
+
227
+ * `"(asset1, asset2) >= a"`
228
+ * `"(asset1, asset2) == a"`
229
+ * `"(asset1, asset2) <= a"`
230
+ * `"(asset1, asset2) >= a * prior(asset1, asset2)"`
231
+ * `"(asset1, asset2) == a * prior(asset1, asset2)"`
232
+ * `"(asset1, asset2) <= a * prior(asset1, asset2)"`
233
+
234
+ For example:
235
+
236
+ * `"(SPX, SX5E) >= 0.8"` --> the correlation between SPX and SX5E is greater than 80%
237
+ * `"(SPX, SX5E) == 1.5 * prior(SPX, SX5E)"` --> the correlation between SPX
238
+ and SX5E increases by 150% (versus its prior)
239
+
240
+ value_at_risk_views : list[str], optional
241
+ Views on asset Value-at-Risks (VaR).
242
+
243
+ For example:
244
+
245
+ * `"SPX >= 0.03"` --> SPX VaR is greater than 3%
246
+ * `"SX5E == 1.5 * prior(SX5E)"` --> SX5E VaR increases by 150% (versus its prior)
247
+
248
+ cvar_views : list[str], optional
249
+ Views on asset Conditional Value-at-Risks (CVaR).
250
+ It only supports equalities.
251
+
252
+ For example:
253
+
254
+ * `"SPX == 0.05"` --> SPX CVaR equals 5%
255
+ * `"SX5E == 1.5 * prior(SX5E)"` --> SX5E CVaR increases by 150% (versus its prior)
256
+
257
+ value_at_risk_beta : float, default=0.95
258
+ Confidence level for VaR views, by default 95%.
259
+
260
+ cvar_beta : float, default=0.95
261
+ Confidence level for CVaR views, by default 95%.
262
+
263
+ groups : dict[str, list[str]] or array-like of strings of shape (n_groups, n_assets), optional
264
+ Asset grouping for use in group-based views. If a dict is provided, keys are
265
+ asset names and values are lists of group labels; then `X` must be a DataFrame
266
+ whose columns match those asset names.
267
+
268
+ For example:
269
+
270
+ * `groups = {"SX5E": ["Equity", "Europe"], "SPX": ["Equity", "US"], "TLT": ["Bond", "US"]}`
271
+ * `groups = [["Equity", "Equity", "Bond"], ["Europe", "US", "US"]]`
272
+
273
+ solver : str, default="TNC"
274
+ The solver to use.
275
+
276
+ - "TNC" (default) solves the entropic-pooling dual via SciPy's Truncated Newton
277
+ Constrained method. By exploiting the smooth Fenchel dual and its
278
+ closed-form gradient, it operates in :math:`\mathbb{R}^k` (the number of
279
+ constraints) rather than :math:`\mathbb{R}^T` (the number of scenarios),
280
+ yielding an order-of-magnitude speedup over primal CVXPY interior-point
281
+ solvers.
282
+
283
+ - CVXPY solvers (e.g. "CLARABEL") solve the entropic-pooling problem in its
284
+ primal form using interior-point methods. While they tend to be slower than
285
+ the dual-based approach, they often achieve higher accuracy by enforcing
286
+ stricter primal feasibility and duality-gap tolerances. See the CVXPY
287
+ documentation for supported solvers:
288
+ https://www.cvxpy.org/tutorial/advanced/index.html#choosing-a-solver.
289
+
290
+ solver_params : dict, optional
291
+ Additional parameters to pass to the chosen solver.
292
+
293
+ - When using **SciPy TNC**, supported options include (but are not limited to)
294
+ `gtol`, `ftol`, `eps`, `maxfun`, `maxCGit`, `stepmx`, `disp`. See the SciPy
295
+ documentation for a full list and descriptions:
296
+ https://docs.scipy.org/doc/scipy/reference/optimize.minimize-tnc.html
297
+
298
+ - When using a **CVXPY** solver (e.g. ``"CLARABEL"``), supply any
299
+ solver-specific parameters here. Refer to the CVXPY solver guide for
300
+ details: https://www.cvxpy.org/tutorial/solvers
301
+
302
+ Attributes
303
+ ----------
304
+ return_distribution_ : ReturnDistribution
305
+ Fitted :class:`~skfolio.prior.ReturnDistribution` to be used by the optimization
306
+ estimators, containing the assets distribution, moments estimation and the EP
307
+ posterior probabilities (sample weights).
308
+
309
+ relative_entropy_ : float
310
+ The KL-divergence between the posterior and prior distributions.
311
+
312
+ effective_number_of_scenarios_ : float
313
+ Effective number of scenarios defined as the perplexity of sample weight
314
+ (exponential of entropy).
315
+
316
+ prior_estimator_ : BasePrior
317
+ Fitted `prior_estimator`.
318
+
319
+ n_features_in_ : int
320
+ Number of assets seen during `fit`.
321
+
322
+ feature_names_in_ : ndarray of shape (`n_features_in_`,)
323
+ Names of features seen during `fit`. Defined only when `X`
324
+ has feature names that are all strings.
325
+
326
+ References
327
+ ----------
328
+ .. [1] "Fully Flexible Extreme Views",
329
+ Journal of Risk, Meucci, Ardia & Keel (2011)
330
+
331
+ .. [2] "Fully Flexible Views: Theory and Practice",
332
+ Risk, Meucci (2013).
333
+
334
+ .. [3] "Effective Number of Scenarios in Fully Flexible Probabilities",
335
+ GARP Risk Professional, Meucci (2012)
336
+
337
+ .. [4] "I-Divergence Geometry of Probability Distributions and Minimization
338
+ Problems", The Annals of Probability, Csiszar (1975)
339
+
340
+
341
+ Examples
342
+ --------
343
+ For a full tutorial on entropy pooling, see :ref:`sphx_glr_auto_examples_entropy_pooling_plot_1_entropy_pooling.py`.
344
+
345
+ >>> from skfolio import RiskMeasure
346
+ >>> from skfolio.datasets import load_sp500_dataset
347
+ >>> from skfolio.preprocessing import prices_to_returns
348
+ >>> from skfolio.prior import EntropyPooling
349
+ >>> from skfolio.optimization import HierarchicalRiskParity
350
+ >>>
351
+ >>> prices = load_sp500_dataset()
352
+ >>> prices = prices[["AMD", "BAC", "GE", "JNJ", "JPM", "LLY", "PG"]]
353
+ >>> X = prices_to_returns(prices)
354
+ >>>
355
+ >>> groups = {
356
+ ... "AMD": ["Technology", "Growth"],
357
+ ... "BAC": ["Financials", "Value"],
358
+ ... "GE": ["Industrials", "Value"],
359
+ ... "JNJ": ["Healthcare", "Defensive"],
360
+ ... "JPM": ["Financials", "Income"],
361
+ ... "LLY": ["Healthcare", "Defensive"],
362
+ ... "PG": ["Consumer", "Defensive"],
363
+ ... }
364
+ >>>
365
+ >>> entropy_pooling = EntropyPooling(
366
+ ... mean_views=[
367
+ ... "JPM == -0.002",
368
+ ... "PG >= LLY",
369
+ ... "BAC >= prior(BAC) * 1.2",
370
+ ... "Financials == 2 * Growth",
371
+ ... ],
372
+ ... variance_views=[
373
+ ... "BAC == prior(BAC) * 4",
374
+ ... ],
375
+ ... correlation_views=[
376
+ ... "(BAC,JPM) == 0.80",
377
+ ... "(BAC,JNJ) <= prior(BAC,JNJ) * 0.5",
378
+ ... ],
379
+ ... skew_views=[
380
+ ... "BAC == -0.05",
381
+ ... ],
382
+ ... cvar_views=[
383
+ ... "GE == 0.08",
384
+ ... ],
385
+ ... cvar_beta=0.90,
386
+ ... groups=groups,
387
+ ... )
388
+ >>>
389
+ >>> entropy_pooling.fit(X)
390
+ EntropyPooling(correlation_views=...
391
+ >>>
392
+ >>> print(entropy_pooling.relative_entropy_)
393
+ 0.18...
394
+ >>> print(entropy_pooling.effective_number_of_scenarios_)
395
+ 6876.67...
396
+ >>> print(entropy_pooling.return_distribution_.sample_weight)
397
+ [0.000103... 0.000093... ... 0.000103... 0.000108...]
398
+ >>>
399
+ >>> # CVaR Hierarchical Risk Parity optimization on Entropy Pooling
400
+ >>> model = HierarchicalRiskParity(
401
+ ... risk_measure=RiskMeasure.CVAR,
402
+ ... prior_estimator=entropy_pooling
403
+ ... )
404
+ >>> model.fit(X)
405
+ HierarchicalRiskParity(prior_estimator=...
406
+ >>> print(model.weights_)
407
+ [0.073... 0.0541... ... 0.200...]
408
+ >>>
409
+ >>> # Stress Test the Portfolio
410
+ >>> entropy_pooling = EntropyPooling(cvar_views=["AMD == 0.10"])
411
+ >>> entropy_pooling.fit(X)
412
+ EntropyPooling(cvar_views=['AMD == 0.10'])
413
+ >>>
414
+ >>> stressed_dist = entropy_pooling.return_distribution_
415
+ >>>
416
+ >>> stressed_ptf = model.predict(stressed_dist)
417
+ """
418
+
419
+ relative_entropy_: float
420
+ effective_number_of_scenarios_: float
421
+ prior_estimator_: BasePrior
422
+ n_features_in_: int
423
+ feature_names_in_: np.ndarray
424
+
425
+ if TYPE_CHECKING:
426
+ _returns: np.ndarray
427
+ _prior_sample_weight: np.ndarray
428
+ _groups: np.ndarray
429
+ _is_fixed_mean: np.ndarray
430
+ _is_fixed_variance: np.ndarray
431
+ _constraints: dict[str, list[np.ndarray] | None]
432
+
433
+ def __init__(
434
+ self,
435
+ prior_estimator: BasePrior | None = None,
436
+ mean_views: list[str] | None = None,
437
+ variance_views: list[str] | None = None,
438
+ correlation_views: list[str] | None = None,
439
+ skew_views: list[str] | None = None,
440
+ kurtosis_views: list[str] | None = None,
441
+ value_at_risk_views: list[str] | None = None,
442
+ cvar_views: list[str] | None = None,
443
+ value_at_risk_beta: float = 0.95,
444
+ cvar_beta: float = 0.95,
445
+ groups: skt.Groups | None = None,
446
+ solver: str = "TNC",
447
+ solver_params: dict | None = None,
448
+ ):
449
+ self.prior_estimator = prior_estimator
450
+ self.mean_views = mean_views
451
+ self.variance_views = variance_views
452
+ self.correlation_views = correlation_views
453
+ self.skew_views = skew_views
454
+ self.kurtosis_views = kurtosis_views
455
+ self.value_at_risk_views = value_at_risk_views
456
+ self.cvar_views = cvar_views
457
+ self.value_at_risk_beta = value_at_risk_beta
458
+ self.cvar_beta = cvar_beta
459
+ self.groups = groups
460
+ self.solver = solver
461
+ self.solver_params = solver_params
462
+
463
+ def get_metadata_routing(self):
464
+ router = skm.MetadataRouter(owner=self.__class__.__name__).add(
465
+ prior_estimator=self.prior_estimator,
466
+ method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"),
467
+ )
468
+ return router
469
+
470
+ def fit(self, X: npt.ArrayLike, y=None, **fit_params) -> "EntropyPooling":
471
+ """Fit the Entropy Pooling estimator.
472
+
473
+ Parameters
474
+ ----------
475
+ X : array-like of shape (n_observations, n_assets)
476
+ Price returns of the assets.
477
+
478
+ y : Ignored
479
+ Not used, present for API consistency by convention.
480
+
481
+ **fit_params : dict
482
+ Parameters to pass to the underlying estimators.
483
+ Only available if `enable_metadata_routing=True`, which can be
484
+ set by using ``sklearn.set_config(enable_metadata_routing=True)``.
485
+ See :ref:`Metadata Routing User Guide <metadata_routing>` for
486
+ more details.
487
+
488
+ Returns
489
+ -------
490
+ self : EntropyPooling
491
+ Fitted estimator.
492
+ """
493
+ routed_params = skm.process_routing(self, "fit", **fit_params)
494
+
495
+ # Validation
496
+ skv.validate_data(self, X)
497
+
498
+ self.prior_estimator_ = check_estimator(
499
+ self.prior_estimator,
500
+ default=EmpiricalPrior(),
501
+ check_type=BasePrior,
502
+ )
503
+ # Fitting prior estimator
504
+ self.prior_estimator_.fit(X, y, **routed_params.prior_estimator.fit)
505
+ # Prior distribution
506
+ self._returns = self.prior_estimator_.return_distribution_.returns
507
+ n_observations, n_assets = self._returns.shape
508
+ self._prior_sample_weight = (
509
+ self.prior_estimator_.return_distribution_.sample_weight
510
+ )
511
+ if self._prior_sample_weight is None:
512
+ self._prior_sample_weight = np.ones(n_observations) / n_observations
513
+
514
+ assets_names = getattr(self, "feature_names_in_", default_asset_names(n_assets))
515
+ if self.groups is None:
516
+ self._groups = np.asarray([assets_names])
517
+ else:
518
+ self._groups = input_to_array(
519
+ items=self.groups,
520
+ n_assets=n_assets,
521
+ fill_value="",
522
+ dim=2,
523
+ assets_names=assets_names,
524
+ name="groups",
525
+ )
526
+
527
+ # Init problem variables
528
+ self._is_fixed_mean = np.zeros(n_assets, dtype=bool)
529
+ self._is_fixed_variance = np.zeros(n_assets, dtype=bool)
530
+ self._constraints = {
531
+ "equality": None,
532
+ "inequality": None,
533
+ "fixed_equality": None,
534
+ "cvar_equality": None,
535
+ }
536
+
537
+ # Step 1: Mean, VaR and CVaR
538
+ self._add_mean_views()
539
+ self._add_value_at_risk_views()
540
+ sample_weight = self._solve_with_cvar()
541
+
542
+ # Step 2: Mean, VaR, CVaR and Variance
543
+ if self.variance_views is not None:
544
+ # Get mean from Step 1
545
+ mean = sm.mean(self._returns, sample_weight=sample_weight)
546
+ # Add new views and solve
547
+ self._add_variance_views(mean=mean)
548
+ sample_weight = self._solve_with_cvar()
549
+
550
+ # Step 3: Mean, VaR, CVaR, Variance, Correlation, Skew and Kurtosis
551
+ if (
552
+ self.correlation_views is not None
553
+ or self.skew_views is not None
554
+ or self.kurtosis_views is not None
555
+ ):
556
+ # Get mean and variance from Step 2
557
+ mean = sm.mean(self._returns, sample_weight=sample_weight)
558
+ variance = sm.variance(
559
+ self._returns, sample_weight=sample_weight, biased=True
560
+ )
561
+ # Add new views and solve
562
+ self._add_correlation_views(mean=mean, variance=variance)
563
+ self._add_skew_views(mean=mean, variance=variance)
564
+ self._add_kurtosis_views(mean=mean, variance=variance)
565
+ sample_weight = self._solve_with_cvar()
566
+
567
+ self.relative_entropy_ = float(
568
+ np.sum(scs.rel_entr(sample_weight, self._prior_sample_weight))
569
+ )
570
+ self.effective_number_of_scenarios_ = np.exp(sts.entropy(sample_weight))
571
+ self.return_distribution_ = ReturnDistribution(
572
+ mu=sm.mean(self._returns, sample_weight=sample_weight),
573
+ covariance=np.cov(self._returns, rowvar=False, aweights=sample_weight),
574
+ returns=self._returns,
575
+ sample_weight=sample_weight,
576
+ )
577
+
578
+ # Manage memory
579
+ del self._returns
580
+ del self._constraints
581
+ del self._prior_sample_weight
582
+
583
+ return self
584
+
585
+ def _add_constraint(self, a: np.ndarray, b: np.ndarray, name: str) -> None:
586
+ """Add the left matrix `a` and right vector `b` of linear equality constraints
587
+ `x @ a == b` and linear inequality constraints `x @ a <= b` to the
588
+ `_constraints` dict.
589
+
590
+ Parameters
591
+ ----------
592
+ a : ndarray of shape (n_observations, n_constrains)
593
+ Left matrix in `x @ a == b` or `x @ a <= b`.
594
+
595
+ a : ndarray of shape (n_observations, n_constrains)
596
+ Right vector in `x @ a == b` or `x @ a <= b`.
597
+
598
+ Returns
599
+ -------
600
+ None
601
+ """
602
+ if b.size == 0:
603
+ return
604
+
605
+ # Init constraints dict
606
+ if self._constraints[name] is None:
607
+ n_observations, _ = self._returns.shape
608
+ self._constraints[name] = [np.empty((n_observations, 0)), np.empty(0)]
609
+
610
+ # Rescaling: views can be on different scales, by rescaling we avoid high
611
+ # disparity, have better conditioning, uniform stopping criteria and slack
612
+ # penalties.
613
+ scales = np.linalg.norm(a, axis=0)
614
+ a /= scales
615
+ b /= scales
616
+
617
+ for i, x in enumerate([a, b]):
618
+ self._constraints[name][i] = np.hstack((self._constraints[name][i], x))
619
+
620
+ def _add_mean_views(self) -> None:
621
+ """Add mean view constraints to the optimization problem."""
622
+ if self.mean_views is None:
623
+ return
624
+
625
+ a_eq, b_eq, a_ineq, b_ineq = self._process_views(measure=PerfMeasure.MEAN)
626
+
627
+ for a, b, name in [(a_eq, b_eq, "equality"), (a_ineq, b_ineq, "inequality")]:
628
+ if a.size != 0:
629
+ self._add_constraint(a=self._returns @ a.T, b=b, name=name)
630
+
631
+ def _add_variance_views(self, mean: np.ndarray) -> None:
632
+ """Add variance view constraints to the optimization problem.
633
+
634
+ Parameters
635
+ ----------
636
+ mean : ndarray of shape (n_assets,)
637
+ The fixed mean vector used to compute variance as a linear function of
638
+ sample weights.
639
+ """
640
+ if self.variance_views is None:
641
+ return
642
+
643
+ a_eq, b_eq, a_ineq, b_ineq = self._process_views(measure=RiskMeasure.VARIANCE)
644
+
645
+ n_observations, n_assets = self._returns.shape
646
+
647
+ fix = np.zeros(n_assets, dtype=bool)
648
+ for a, b, name in [(a_eq, b_eq, "equality"), (a_ineq, b_ineq, "inequality")]:
649
+ if a.size != 0:
650
+ self._add_constraint(
651
+ a=(self._returns - mean) ** 2 @ a.T, b=b, name=name
652
+ )
653
+ fix |= np.any(a != 0, axis=0)
654
+
655
+ self._fix_mean(fix=fix, mean=mean)
656
+
657
+ def _add_skew_views(self, mean: np.ndarray, variance: np.ndarray) -> None:
658
+ """Add skew view constraints to the optimization problem.
659
+
660
+ Parameters
661
+ ----------
662
+ mean : ndarray of shape (n_assets,)
663
+ The fixed mean vector used to compute skew as a linear function of sample
664
+ weights.
665
+
666
+ variance : ndarray of shape (n_assets,)
667
+ The fixed variance vector used to compute skew as a linear function of
668
+ sample weights.
669
+ """
670
+ if self.skew_views is None:
671
+ return
672
+
673
+ a_eq, b_eq, a_ineq, b_ineq = self._process_views(measure=ExtraRiskMeasure.SKEW)
674
+
675
+ _, n_assets = self._returns.shape
676
+
677
+ fix = np.zeros(n_assets, dtype=bool)
678
+ for a, b, name in [(a_eq, b_eq, "equality"), (a_ineq, b_ineq, "inequality")]:
679
+ if a.size != 0:
680
+ self._add_constraint(
681
+ a=(self._returns**3 - mean**3 - 3 * mean * variance)
682
+ / variance**1.5
683
+ @ a.T,
684
+ b=b,
685
+ name=name,
686
+ )
687
+ fix |= np.any(a != 0, axis=0)
688
+
689
+ # Fix the mean and the variance of the correlation views in order for the
690
+ # correlation to match exactly the views
691
+ self._fix_mean(fix=fix, mean=mean)
692
+ self._fix_variance(fix=fix, mean=mean, variance=variance)
693
+
694
+ def _add_kurtosis_views(self, mean: np.ndarray, variance: np.ndarray) -> None:
695
+ """Add kurtosis view constraints to the optimization problem.
696
+
697
+ Parameters
698
+ ----------
699
+ mean : ndarray of shape (n_assets,)
700
+ The fixed mean vector used to compute kurtosis as a linear function of
701
+ sample weights.
702
+
703
+ variance : ndarray of shape (n_assets,)
704
+ The fixed variance vector used to compute kurtosis as a linear function of
705
+ sample weights.
706
+ """
707
+ if self.kurtosis_views is None:
708
+ return
709
+
710
+ a_eq, b_eq, a_ineq, b_ineq = self._process_views(
711
+ measure=ExtraRiskMeasure.KURTOSIS
712
+ )
713
+
714
+ _, n_assets = self._returns.shape
715
+ fix = np.zeros(n_assets, dtype=bool)
716
+ for a, b, name in [(a_eq, b_eq, "equality"), (a_ineq, b_ineq, "inequality")]:
717
+ if a.size != 0:
718
+ self._add_constraint(
719
+ a=(
720
+ (
721
+ self._returns**4
722
+ - 4 * mean * self._returns**3
723
+ + 6 * mean**2 * self._returns**2
724
+ - 3 * mean**4
725
+ )
726
+ / variance**2
727
+ @ a.T
728
+ ),
729
+ b=b,
730
+ name=name,
731
+ )
732
+ fix |= np.any(a != 0, axis=0)
733
+
734
+ self._fix_mean(fix=fix, mean=mean)
735
+ self._fix_variance(fix=fix, mean=mean, variance=variance)
736
+
737
+ def _add_value_at_risk_views(self) -> None:
738
+ """Add Value-at-Risk (VaR) view constraints to the optimization problem."""
739
+ if self.value_at_risk_views is None:
740
+ return
741
+
742
+ a_eq, b_eq, a_ineq, b_ineq = self._process_views(
743
+ measure=ExtraRiskMeasure.VALUE_AT_RISK
744
+ )
745
+
746
+ if (a_eq.size != 0 and np.any(np.count_nonzero(a_eq, axis=1) != 1)) or (
747
+ a_ineq.size != 0 and np.any(np.count_nonzero(a_ineq, axis=1) != 1)
748
+ ):
749
+ raise ValueError(
750
+ "You cannot mix multiple assets in a single Value-at-Risk view."
751
+ )
752
+
753
+ if np.any(b_eq < 0) | np.any(a_ineq * b_ineq[:, np.newaxis] < 0):
754
+ raise ValueError("Value-at-Risk views must be strictly positive.")
755
+
756
+ n_observations, _ = self._returns.shape
757
+
758
+ for a, b, name in [(a_eq, b_eq, "equality"), (a_ineq, b_ineq, "inequality")]:
759
+ for ai, bi in zip(a, b, strict=True):
760
+ idx = np.where(self._returns[:, ai.astype(bool)].flatten() <= -abs(bi))[
761
+ 0
762
+ ]
763
+ if idx.size == 0:
764
+ raise ValueError(
765
+ f"The Value-at-Risk view of {bi:0.3%} is excessively extreme. "
766
+ "Consider lowering the view or adjusting your prior "
767
+ "distribution to include more extreme values."
768
+ )
769
+ sign = 1 if name == "equality" or bi > 0 else -1
770
+ ai = np.zeros(n_observations)
771
+ ai[idx] = 1.0
772
+ self._add_constraint(
773
+ a=sign * ai.reshape(-1, 1),
774
+ b=sign * np.array([1 - self.value_at_risk_beta]),
775
+ name=name,
776
+ )
777
+
778
+ def _add_correlation_views(self, mean: np.ndarray, variance: np.ndarray) -> None:
779
+ """Add correlation view constraints to the optimization problem.
780
+
781
+ Parameters
782
+ ----------
783
+ mean : ndarray of shape (n_assets,)
784
+ The fixed mean vector used to compute correlation as a linear function of
785
+ sample weights.
786
+
787
+ variance : ndarray of shape (n_assets,)
788
+ The fixed variance vector used to compute correlation as a linear function of
789
+ sample weights.
790
+ """
791
+ if self.correlation_views is None:
792
+ return
793
+
794
+ assets = self._groups[0]
795
+ n_observations, n_assets = self._returns.shape
796
+ asset_to_index = {asset: i for i, asset in enumerate(assets)}
797
+ try:
798
+ views = []
799
+ for view in self.correlation_views:
800
+ res = _parse_correlation_view(view, assets=assets)
801
+ expression = res["expression"]
802
+ corr_view = expression["constant"]
803
+ if "prior_assets" in expression:
804
+ i, j = (asset_to_index[a] for a in expression["prior_assets"])
805
+ corr_view += (
806
+ np.corrcoef(self._returns[:, i], self._returns[:, j])[0][1]
807
+ * expression["multiplier"]
808
+ )
809
+ corr_view = np.clip(corr_view, 0 + 1e-8, 1 - 1e-8)
810
+ views.append(
811
+ (
812
+ (asset_to_index[a] for a in res["assets"]),
813
+ res["operator"],
814
+ corr_view,
815
+ )
816
+ )
817
+ except KeyError as e:
818
+ raise ValueError(f"Asset {e.args[0]} is missing from the assets.") from None
819
+
820
+ fix = np.zeros(n_assets, dtype=bool)
821
+ for (i, j), op, corr_view in views:
822
+ if not 0 <= corr_view <= 1:
823
+ raise ValueError("Correlation views must be between 0 and 1.")
824
+ ai = self._returns[:, i] * self._returns[:, j]
825
+ bi = mean[i] * mean[j] + corr_view * np.sqrt(variance[i] * variance[j])
826
+ sign = 1 if op in [operator.eq, operator.lt, operator.le] else -1
827
+ self._add_constraint(
828
+ a=sign * ai.reshape(-1, 1),
829
+ b=sign * np.array([bi]),
830
+ name="equality" if op == operator.eq else "inequality",
831
+ )
832
+ fix[[i, j]] = True
833
+
834
+ self._fix_mean(fix=fix, mean=mean)
835
+ self._fix_variance(fix=fix, mean=mean, variance=variance)
836
+
837
+ def _solve_with_cvar(self) -> np.ndarray:
838
+ """Solve the entropy pooling problem handling CVaR view constraints.
839
+
840
+ CVaR view constraints cannot be directly expressed as linear functions of the
841
+ posterior probabilities. Therefore, when CVaR views are present, the EP problem
842
+ must be solved by recursively solving a series of convex programs that
843
+ approximate the non-linear CVaR constraint.
844
+
845
+ Our approach improves upon Meucci's algorithm [1]_ by formulating the problem
846
+ in continuous space as a function of the dual variables etas (VaR levels)
847
+ rather than searching over discrete tail sizes. This formulation not only
848
+ handles the CVaR constraint more directly but also supports multiple CVaR views
849
+ on different assets.
850
+
851
+ Although the overall problem is convex in the dual variables etas, it remains
852
+ non-smooth due to the presence of the positive-part operator in the CVaR
853
+ definition. Consequently, we employ derivative-free optimization methods.
854
+ Specifically, for a single CVaR view we use a one-dimensional root-finding
855
+ method (Brent's method), and for the multidimensional case (supporting multiple
856
+ CVaR views) we utilize Powell's method for derivative-free convex descent.
857
+
858
+ Returns
859
+ -------
860
+ sample_weight : ndarray of shape (n_observations,)
861
+ The updated probability vector satisfying all view constraints.
862
+
863
+ References
864
+ ----------
865
+ .. [1] "Fully Flexible Extreme Views",
866
+ Journal of Risk, Meucci, Ardia & Keel (2011)
867
+ """
868
+ if self.cvar_views is None:
869
+ sample_weight = self._solve()
870
+ return sample_weight
871
+
872
+ a_eq, b_eq, a_ineq, b_ineq = self._process_views(measure=RiskMeasure.CVAR)
873
+
874
+ if b_ineq.size != 0:
875
+ raise ValueError(
876
+ "CVaR view inequalities are not supported, use equalities views `==`"
877
+ )
878
+
879
+ if np.any(np.count_nonzero(a_eq, axis=1) != 1):
880
+ raise ValueError("You cannot mix multiple assets in a single CVaR view")
881
+
882
+ if np.any(b_eq < 0):
883
+ raise ValueError("CVaR view must be strictly positive")
884
+
885
+ n_observations, _ = self._returns.shape
886
+ asset_returns = self._returns[:, np.where(a_eq.sum(axis=0) != 0)[0]].T
887
+ views = b_eq
888
+ n_views = len(views)
889
+
890
+ min_ret = -np.min(asset_returns, axis=1)
891
+ invalid_views = views >= min_ret
892
+ if np.any(invalid_views):
893
+ msg = ""
894
+ for v, m in zip(views[invalid_views], min_ret[invalid_views], strict=True):
895
+ msg += (
896
+ "The CVaR views of "
897
+ + ", ".join([f"{v:.2%}" for v in views[invalid_views]])
898
+ + f" is excessively extreme and cannot exceed {m:.2%} which is the "
899
+ "worst realization. Consider lowering the view or adjusting your "
900
+ "prior distribution to include more extreme values."
901
+ )
902
+ raise ValueError(msg)
903
+
904
+ def func(etas: list[float]) -> tuple[np.ndarray, float]:
905
+ """Solve the EP with CVaR constraints for a given list of etas (VaR levels).
906
+
907
+ Parameters
908
+ ----------
909
+ etas : list[float]
910
+ The list of etas (VaR levels) of each asset with a CVaR view.
911
+
912
+ Returns
913
+ -------
914
+ sample_weight : ndarray of shape (n_observations,)
915
+ Sample weight of the CVaR EP problem.
916
+ error : float
917
+ For one-dimensional (a single eta), the error is the difference
918
+ between the target CVaR beta and the effective CVaR beta.
919
+ For multidimensional, the error is the RMSE of the difference between
920
+ the target CVaR and the effective CVaR.
921
+ """
922
+ # Init CVaR constraints
923
+ self._constraints["cvar_equality"] = [
924
+ np.empty((n_observations, 0)),
925
+ np.empty(0),
926
+ ]
927
+ pos_part = None
928
+ for i in range(n_views):
929
+ if not (0 < etas[i] < views[i]):
930
+ raise ValueError(
931
+ f"eta[{i}] must be between 0 and the CVaR view {views[i]}"
932
+ )
933
+ pos_part = np.maximum(-asset_returns[i] - etas[i], 0)
934
+ self._add_constraint(
935
+ a=(pos_part / (1 - self.cvar_beta)).reshape(-1, 1),
936
+ b=np.array([views[i] - etas[i]]),
937
+ name="cvar_equality",
938
+ )
939
+ w = self._solve()
940
+
941
+ if n_views == 1:
942
+ error = np.sum(w[pos_part != 0]) - (1 - self.cvar_beta)
943
+ else:
944
+ error = np.linalg.norm(
945
+ sm.cvar(asset_returns.T, beta=self.cvar_beta, sample_weight=w)
946
+ - views
947
+ ) / np.sqrt(n_views)
948
+ return w, error
949
+
950
+ with warnings.catch_warnings():
951
+ warnings.filterwarnings("ignore", message="^Solution may be inaccurate")
952
+
953
+ # One-dimensional: we use one-dimensional root-finding method
954
+ if n_views == 1:
955
+ sol = sco.root_scalar(
956
+ lambda x: func([x])[-1],
957
+ bracket=[1e-3, views[0] - 1e-8],
958
+ method="brentq", # Doesn't required a smooth derivative
959
+ xtol=1e-4,
960
+ maxiter=50,
961
+ )
962
+ if not sol.converged:
963
+ raise RuntimeError(
964
+ "Failed to solve the CVaR view problem. Consider relaxing your "
965
+ "constraints or substituting with VaR views."
966
+ )
967
+ res = [sol.root]
968
+ # Multidimensional: we use derivative-free convex descent
969
+ else:
970
+ sol = sco.minimize(
971
+ lambda x: func(x)[-1],
972
+ x0=np.array([view * 0.5 for view in views]),
973
+ bounds=[(1e-3, view - 1e-8) for view in views],
974
+ method="Powell",
975
+ options={"xtol": 1e-4, "maxiter": 80},
976
+ )
977
+ if not sol.success:
978
+ raise ValueError(
979
+ "Failed to solve the multi-CVaR view problem. Consider "
980
+ "relaxing your constraints, using a single CVaR view, or "
981
+ "substituting with VaR views."
982
+ )
983
+ res = sol.x
984
+
985
+ sample_weight = func(res)[0]
986
+ return sample_weight
987
+
988
+ def _solve(self) -> np.ndarray:
989
+ """Solve the base entropy pooling problem.
990
+ Dispatch either to the EP dual (TNC) solver or the EP primal (CVXPY) solver
991
+ based on the `solver` parameter.
992
+
993
+ Returns
994
+ -------
995
+ sample_weight : ndarray of shape (n_observations,)
996
+ The updated posterior probability vector.
997
+ """
998
+ if all(v is None for v in self._constraints.values()):
999
+ # No view constraints so we don't need to solve the EP problem.
1000
+ return self._prior_sample_weight
1001
+
1002
+ if self.solver == "TNC":
1003
+ return self._solve_dual()
1004
+ return self._solve_primal()
1005
+
1006
+ def _solve_dual(self) -> np.ndarray:
1007
+ r"""Solves the entropic-pooling dual via SciPy's Truncated Newton
1008
+ Constrained method. By exploiting the smooth Fenchel dual and its
1009
+ closed-form gradient, it operates in :math:`\mathbb{R}^k` (the number of
1010
+ constraints) rather than :math:`\mathbb{R}^T` (the number of scenarios),
1011
+ yielding an order-of-magnitude speedup over primal CVXPY interior-point
1012
+ solvers.
1013
+
1014
+ Returns
1015
+ -------
1016
+ sample_weight : ndarray of shape (n_observations,)
1017
+ The updated posterior probability vector.
1018
+
1019
+ References
1020
+ ----------
1021
+ .. [1] "Convex Optimization", Cambridge University Press, Section 5.2.3,
1022
+ Entropy maximization, Boyd & Vandenberghe (2004)
1023
+
1024
+ .. [2] "I-Divergence Geometry of Probability Distributions and Minimization
1025
+ Problems", The Annals of Probability, Csiszar (1975)
1026
+
1027
+ .. [3] "Fully Flexible Views: Theory and Practice",
1028
+ Risk, Meucci (2013).
1029
+
1030
+ """
1031
+ n_observations, _ = self._returns.shape
1032
+ # Init constrains with sum(p)==1, rescaled by its norm
1033
+ # Has better convergence than the normalized form done inside the dual.
1034
+ a = [np.ones(n_observations).reshape(-1, 1) / np.sqrt(n_observations)]
1035
+ b = [np.array([1.0]) / np.sqrt(n_observations)]
1036
+ bounds = [(None, None)]
1037
+ for name, constrains in self._constraints.items():
1038
+ if constrains is not None:
1039
+ a.append(constrains[0])
1040
+ b.append(constrains[1])
1041
+ s = constrains[1].size
1042
+ match name:
1043
+ case "equality" | "cvar_equality":
1044
+ bounds += [(None, None)] * s
1045
+ case "fixed_equality":
1046
+ # Equivalent to relaxing the problem with slack variables with
1047
+ # a norm penalty to avoid solver infeasibility that may arise
1048
+ # from overly tight constraints from fixing the moments.
1049
+ bounds += [(-1000, 1000)] * s
1050
+ case "inequality":
1051
+ bounds += [(0, None)] * s
1052
+ case _:
1053
+ raise KeyError(f"constrain {name}")
1054
+
1055
+ a = np.hstack(a)
1056
+ b = np.hstack(b)
1057
+
1058
+ def func(x: np.ndarray) -> tuple[float, np.ndarray]:
1059
+ """Computes the Fenchel dual of the entropic-pooling problem in its
1060
+ unnormalized form.
1061
+
1062
+ Parameters
1063
+ ----------
1064
+ x : ndarray of shape (n_constraints,)
1065
+ Dual variables (Lagrange multipliers for each constraint).
1066
+
1067
+ Returns
1068
+ -------
1069
+ obj : float
1070
+ Value of the dual objective.
1071
+ grad : ndarray of shape (n_constraints,)
1072
+ Gradient of the dual objective.
1073
+ """
1074
+ z = self._prior_sample_weight * np.exp(-a @ x - 1)
1075
+ obj = z.sum() + x @ b
1076
+ grad = b - a.T @ z
1077
+ return obj, grad
1078
+
1079
+ # We use TNC as it often outperforms L-BFGS-B on EP problems because
1080
+ # it builds (implicitly) true curvature information via Hessian-vector products,
1081
+ # whereas L-BFGS-B only ever uses a low-rank quasi-Newton approximation.
1082
+ # We set stepmx=1 to avoid large solver defaults when the bounds are not None.
1083
+ solver_params = (
1084
+ self.solver_params
1085
+ if self.solver_params is not None
1086
+ else {
1087
+ "maxfun": 5000,
1088
+ "ftol": 1e-11,
1089
+ "xtol": 1e-8,
1090
+ "gtol": 1e-8,
1091
+ "stepmx": 1,
1092
+ }
1093
+ )
1094
+
1095
+ sol = sco.minimize(
1096
+ func,
1097
+ x0=np.zeros_like(b),
1098
+ jac=True,
1099
+ bounds=bounds,
1100
+ method="TNC",
1101
+ options=solver_params,
1102
+ )
1103
+ if not sol.success:
1104
+ raise SolverError(
1105
+ "Dual problem with Solver 'TNC' failed. This typically occurs when the "
1106
+ "specified views conflict or are overly extreme. Consider using a "
1107
+ "prior that generates more synthetic data for extreme views. You can "
1108
+ "also change `solver_params` or try another `solver` such as "
1109
+ f"'CLARABEL'. Solver error: {sol.message}"
1110
+ )
1111
+ sample_weight = self._prior_sample_weight * np.exp(-1 - a @ sol.x)
1112
+ # Handles numerical precision errors
1113
+ sample_weight = np.clip(sample_weight, 0, 1)
1114
+ sample_weight /= sample_weight.sum()
1115
+ return sample_weight
1116
+
1117
+ def _solve_primal(self) -> np.ndarray:
1118
+ """Solve the base entropy-pooling problem in its primal form by minimizing KL
1119
+ divergence to the prior.
1120
+
1121
+ Returns
1122
+ -------
1123
+ sample_weight : ndarray of shape (n_observations,)
1124
+ The updated posterior probability vector.
1125
+ """
1126
+ n_observations, _ = self._returns.shape
1127
+
1128
+ solver_params = self.solver_params if self.solver_params is not None else {}
1129
+
1130
+ posterior = cp.Variable(n_observations) # Posterior probas
1131
+ objective = cp.sum(cp.kl_div(posterior, self._prior_sample_weight))
1132
+ constraints = [posterior >= 0, cp.sum(posterior) == 1]
1133
+
1134
+ if self._constraints["equality"] is not None:
1135
+ a, b = self._constraints["equality"]
1136
+ constraints.append(posterior @ a - b == 0)
1137
+
1138
+ if self._constraints["inequality"] is not None:
1139
+ a, b = self._constraints["inequality"]
1140
+ constraints.append(posterior @ a - b <= 0)
1141
+
1142
+ if self._constraints["cvar_equality"] is not None:
1143
+ a, b = self._constraints["cvar_equality"]
1144
+ constraints.append(posterior @ a - b == 0)
1145
+
1146
+ if self._constraints["fixed_equality"] is not None:
1147
+ a, b = self._constraints["fixed_equality"]
1148
+ # Relaxe the problem with slack variables with a norm1 penalty to avoid
1149
+ # solver infeasibility that may arise from overly tight constraints from
1150
+ # fixing the moments.
1151
+ slack = cp.Variable(b.size)
1152
+ constraints.append(posterior @ a - b == slack)
1153
+ objective += 1e5 * cp.norm1(slack)
1154
+
1155
+ problem = cp.Problem(cp.Minimize(objective), constraints)
1156
+ try:
1157
+ # We suppress cvxpy warning as it is redundant with our warning
1158
+ with warnings.catch_warnings():
1159
+ warnings.simplefilter("ignore")
1160
+ problem.solve(solver=self.solver, **solver_params)
1161
+
1162
+ if posterior.value is None:
1163
+ raise cp.SolverError
1164
+ if problem.status != cp.OPTIMAL:
1165
+ warnings.warn(
1166
+ "Solution may be inaccurate. Try changing the solver params. For "
1167
+ "more details, set `solver_params=dict(verbose=True)`",
1168
+ stacklevel=2,
1169
+ )
1170
+ # Handles numerical precision errors
1171
+ sample_weight = np.clip(posterior.value, 0, 1)
1172
+ sample_weight /= sample_weight.sum()
1173
+ return sample_weight
1174
+ except (cp.SolverError, scl.ArpackNoConvergence):
1175
+ raise SolverError(
1176
+ f"Primal problem with Solver '{self.solver}' failed. This typically "
1177
+ "occurs when the specified views conflict or are overly extreme. "
1178
+ "Consider using a prior that generates more synthetic data for extreme "
1179
+ "views. You can also change `solver_params` or try another `solver` "
1180
+ "such as 'TNC'."
1181
+ ) from None
1182
+
1183
+ def _process_views(self, measure: PerfMeasure | RiskMeasure | ExtraRiskMeasure):
1184
+ """Process and convert view equations into constraint matrices.
1185
+
1186
+ This method uses the provided view strings and groups to generate the equality
1187
+ and inequality matrices (a_eq, b_eq, a_ineq, b_ineq) needed to formulate the
1188
+ constraints. Prior asset expressions are replaced using the current prior.
1189
+
1190
+ Parameters
1191
+ ----------
1192
+ measure : {PerfMeasure, RiskMeasure, ExtraRiskMeasure}
1193
+ The type of view measure to process.
1194
+
1195
+ Returns
1196
+ -------
1197
+ a_eq, b_eq, a_ineq, b_ineq : tuple of ndarray
1198
+ Matrices and vectors corresponding to equality and inequality constraints.
1199
+ """
1200
+ assets = self._groups[0]
1201
+ name = f"{measure.value}_views"
1202
+ views = getattr(self, name)
1203
+ views = np.asarray(views)
1204
+ if views.ndim != 1:
1205
+ raise ValueError(f"{name} must be a list of strings")
1206
+ measure_func = getattr(sm, str(measure.value))
1207
+ measure_args = {}
1208
+ if measure == RiskMeasure.VARIANCE:
1209
+ measure_args["biased"] = True
1210
+ required_prior_assets = _extract_prior_assets(views, assets=assets)
1211
+ if len(required_prior_assets) != 0:
1212
+ prior_values = {
1213
+ k: measure_func(self._returns[:, i], **measure_args)
1214
+ for i, k in enumerate(assets)
1215
+ if k in required_prior_assets
1216
+ }
1217
+ views = _replace_prior_views(views=views, prior_values=prior_values)
1218
+
1219
+ a_eq, b_eq, a_ineq, b_ineq = equations_to_matrix(
1220
+ groups=self._groups,
1221
+ equations=views,
1222
+ raise_if_group_missing=True,
1223
+ names=("groups", "views"),
1224
+ )
1225
+
1226
+ return a_eq, b_eq, a_ineq, b_ineq
1227
+
1228
+ def _fix_mean(self, fix: np.ndarray, mean: np.ndarray) -> None:
1229
+ """Add constraints to fix the mean for assets where view constraints have been
1230
+ applied.
1231
+
1232
+ The method introduces slack variables to avoid solver infeasibility from
1233
+ conflicting tight constraints.
1234
+
1235
+ Parameters
1236
+ ----------
1237
+ fix : ndarray of bool of shape (n_assets,)
1238
+ Boolean mask indicating which assets to fix.
1239
+
1240
+ mean : ndarray of shape (n_assets,)
1241
+ Fixed mean values for the assets.
1242
+ """
1243
+ fix &= ~self._is_fixed_mean
1244
+ if np.any(fix):
1245
+ self._add_constraint(
1246
+ a=self._returns[:, fix], b=mean[fix], name="fixed_equality"
1247
+ )
1248
+ self._is_fixed_mean |= fix
1249
+
1250
+ def _fix_variance(
1251
+ self, fix: np.ndarray, mean: np.ndarray, variance: np.ndarray
1252
+ ) -> None:
1253
+ """Add constraints to fix the variance for assets where view constraints have
1254
+ been applied.
1255
+
1256
+ The method introduces slack variables to avoid solver infeasibility from
1257
+ conflicting tight constraints.
1258
+
1259
+ Parameters
1260
+ ----------
1261
+ fix : ndarray of bool of shape (n_assets,)
1262
+ Boolean mask indicating which assets to fix.
1263
+
1264
+ mean : ndarray of shape (n_assets,)
1265
+ Fixed mean values used for the linearization of the variance.
1266
+
1267
+ variance : np.ndarray
1268
+ Fixed variance values.
1269
+ """
1270
+ fix &= ~self._is_fixed_variance
1271
+ if np.any(fix):
1272
+ self._add_constraint(
1273
+ a=(self._returns[:, fix] - mean[fix]) ** 2,
1274
+ b=variance[fix],
1275
+ name="fixed_equality",
1276
+ )
1277
+ self._is_fixed_variance |= fix
1278
+
1279
+
1280
+ def _extract_prior_assets(views: Sequence[str], assets: Sequence[str]) -> set[str]:
1281
+ """
1282
+ Given a list of views, return a set of asset names referenced within any
1283
+ 'prior(ASSET)' pattern. Only asset names in the provided 'assets' list
1284
+ will be recognized.
1285
+
1286
+ Supported format:
1287
+ - "prior(ASSET)"
1288
+ - "prior(ASSET) * a"
1289
+ - "a * prior(ASSET)"
1290
+
1291
+ Parameters
1292
+ ----------
1293
+ views : list[str]
1294
+ The list of views to scan.
1295
+
1296
+ assets : list[str]
1297
+ The list of allowed asset names.
1298
+
1299
+ Returns
1300
+ -------
1301
+ prior_assets : set[str]
1302
+ Set of asset names that appear in prior() pattern.
1303
+ """
1304
+ allowed_assets = "|".join(re.escape(asset) for asset in assets)
1305
+ pattern = r"prior\(\s*(" + allowed_assets + r")\s*\)"
1306
+
1307
+ prior_assets = set()
1308
+ for view in views:
1309
+ matches = re.findall(pattern, view)
1310
+ prior_assets.update(matches)
1311
+
1312
+ return set(prior_assets)
1313
+
1314
+
1315
+ def _replace_prior_views(
1316
+ views: Sequence[str], prior_values: dict[str, float]
1317
+ ) -> list[str]:
1318
+ """
1319
+ Replace occurrences of the below prior patterns using `prior_values`.
1320
+
1321
+ Supported patterns:
1322
+ - "prior(ASSET)"
1323
+ - "prior(ASSET) * a"
1324
+ - "a * prior(ASSET)"
1325
+
1326
+ Parameters
1327
+ ----------
1328
+ views : list[str]
1329
+ The list of views.
1330
+
1331
+ prior_values : dict[str, float]
1332
+ A dictionary mapping asset names (str) to their prior values.
1333
+
1334
+ Returns
1335
+ -------
1336
+ views : list[str]
1337
+ The views with each prior pattern instance replaced by its computed value.
1338
+ """
1339
+ # Build a regex pattern for allowed asset names.
1340
+ allowed_assets = "|".join(re.escape(asset) for asset in prior_values)
1341
+
1342
+ # Pattern captures:
1343
+ # - An optional multiplier before: ([0-9\.]+)\s*\*\s*
1344
+ # - The prior() function with an allowed asset: prior\(\s*(allowed_asset)\s*\)
1345
+ # - An optional multiplier after: \s*\*\s*([0-9\.]+)
1346
+ pattern = (
1347
+ r"(?:([0-9\.]+)\s*\*\s*)?" # Optional pre-multiplier
1348
+ r"prior\(\s*(" + allowed_assets + r")\s*\)" # prior(ASSET) with allowed asset
1349
+ r"(?:\s*\*\s*([0-9\.]+))?" # Optional post-multiplier
1350
+ )
1351
+
1352
+ def repl(match) -> str:
1353
+ pre_multiplier = float(match.group(1)) if match.group(1) else 1.0
1354
+ asset = match.group(2)
1355
+ post_multiplier = float(match.group(3)) if match.group(3) else 1.0
1356
+ result_value = prior_values[asset] * pre_multiplier * post_multiplier
1357
+ return str(result_value)
1358
+
1359
+ new_views = [re.sub(pattern, repl, view) for view in views]
1360
+
1361
+ # After substitution, check for any unresolved 'prior(ASSET)'
1362
+ unresolved_pattern = r"prior\(\s*([^)]+?)\s*\)"
1363
+ for view in new_views:
1364
+ m = re.search(unresolved_pattern, view)
1365
+ if m is not None:
1366
+ missing_asset = m.group(1)
1367
+ raise ValueError(
1368
+ "Unresolved 'prior' expression found in view. "
1369
+ f"Asset '{missing_asset}' is not available in prior_values."
1370
+ )
1371
+
1372
+ return new_views
1373
+
1374
+
1375
+ def _parse_correlation_view(view: str, assets: Sequence[str]) -> dict:
1376
+ """
1377
+ Parse a correlation view and return its structured representation.
1378
+
1379
+ The view string should follow one of the formats:
1380
+ (asset1, asset2) <operator> <float>
1381
+ (asset1, asset2) <operator> <float> * prior(asset3, asset4) * <float> + <float>
1382
+
1383
+ Only asset names from the provided `assets` list are accepted.
1384
+ If the view contains a "prior(...)" expression, it is replaced by its computed value
1385
+ using multipliers and constant additions. If the asset referenced in any prior
1386
+ expression is not allowed, an error is raised.
1387
+
1388
+ Parameters
1389
+ ----------
1390
+ view : str
1391
+ The correlation view string.
1392
+
1393
+ assets : list[str]
1394
+ A list of allowed asset names.
1395
+
1396
+ Returns
1397
+ -------
1398
+ views : dict
1399
+ A dictionary with keys:
1400
+ - "assets": tuple(asset1, asset2)
1401
+ - "operator": the comparison operator (==, >=, <=, >, or <)
1402
+ - "expression": a dict representing the right-hand side expression.
1403
+ * If there is no prior expression, "expression" contains {"constant": value}.
1404
+ * If a prior expression is present, it returns a dict with keys:
1405
+ "prior_assets": tuple(asset3, asset4),
1406
+ "multiplier": overall multiplier (default 1.0),
1407
+ "constant": constant term (default 0.0).
1408
+ """
1409
+ operator_map = {
1410
+ ">": operator.gt,
1411
+ ">=": operator.ge,
1412
+ "<": operator.lt,
1413
+ "<=": operator.le,
1414
+ "==": operator.eq,
1415
+ }
1416
+
1417
+ # Build a regex pattern that only accepts the allowed asset names.
1418
+ allowed_assets = "|".join(re.escape(asset) for asset in assets)
1419
+
1420
+ main_pattern = (
1421
+ r"^\(\s*(" + allowed_assets + r")\s*,\s*(" + allowed_assets + r")\s*\)\s*"
1422
+ r"(==|>=|<=|>|<)\s*(.+)$"
1423
+ )
1424
+ main_match = re.match(main_pattern, view)
1425
+ if not main_match:
1426
+ raise ValueError(
1427
+ f"Invalid correlation view format or unknown asset in view: {view}"
1428
+ )
1429
+
1430
+ asset1, asset2, operator_str, expression = main_match.groups()
1431
+ expression = expression.strip()
1432
+
1433
+ if "prior(" not in expression:
1434
+ try:
1435
+ constant = float(expression)
1436
+ except ValueError as e:
1437
+ raise ValueError(
1438
+ f"Could not convert constant '{expression}' to float in view: {view}"
1439
+ ) from e
1440
+ parsed_expression = {"constant": constant}
1441
+ else:
1442
+ # Pattern to capture:
1443
+ # - An optional pre multiplier followed by "*" (e.g. "2 *")
1444
+ # - The pattern prior( asset3 , asset4 )
1445
+ # - An optional post multiplier preceded by "*" (e.g. "* 3")
1446
+ # - An optional constant preceded by "+" (e.g. "+ 4")
1447
+ prior_pattern = (
1448
+ r"^(?:\s*([A-Za-z0-9_.-]+)\s*\*\s*)?" # Optional pre-multiplier
1449
+ r"prior\(\s*("
1450
+ + allowed_assets
1451
+ + r")\s*,\s*("
1452
+ + allowed_assets
1453
+ + r")\s*\)" # prior(asset3, asset4)
1454
+ r"(?:\s*\*\s*([A-Za-z0-9_.-]+))?" # Optional post-multiplier
1455
+ r"(?:\s*\+\s*([A-Za-z0-9_.-]+))?" # Optional constant addition
1456
+ r"$"
1457
+ )
1458
+ prior_match = re.match(prior_pattern, expression)
1459
+ if not prior_match:
1460
+ raise ValueError(
1461
+ "Invalid prior expression format or unknown asset in expression: "
1462
+ f"{expression}"
1463
+ )
1464
+
1465
+ (pre_mult, asset3, asset4, post_mult, constant) = prior_match.groups()
1466
+ try:
1467
+ pre_mult = float(pre_mult) if pre_mult is not None else 1.0
1468
+ except ValueError as e:
1469
+ raise ValueError(
1470
+ f"Invalid pre-multiplier '{pre_mult}' in view: {view}"
1471
+ ) from e
1472
+ try:
1473
+ post_mult = float(post_mult) if post_mult is not None else 1.0
1474
+ except ValueError as e:
1475
+ raise ValueError(
1476
+ f"Invalid post-multiplier '{post_mult}' in view: {view}"
1477
+ ) from e
1478
+ try:
1479
+ constant = float(constant) if constant is not None else 0.0
1480
+ except ValueError as e:
1481
+ raise ValueError(f"Invalid constant '{constant}' in view: {view}") from e
1482
+
1483
+ parsed_expression = {
1484
+ "prior_assets": (asset3, asset4),
1485
+ "multiplier": pre_mult * post_mult,
1486
+ "constant": constant,
1487
+ }
1488
+
1489
+ return {
1490
+ "assets": (asset1, asset2),
1491
+ "operator": operator_map[operator_str],
1492
+ "expression": parsed_expression,
1493
+ }