allocation 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.
allocation/base.py ADDED
@@ -0,0 +1,141 @@
1
+ """Base class for online portfolio estimators.
2
+
3
+ ``BaseOnlinePortfolio`` follows scikit-learn / skfolio conventions: hyperparameters
4
+ in ``__init__``, ``fit(X)`` / ``partial_fit(X)`` taking asset returns ``X`` and
5
+ storing weights in ``weights_``, plus a ``predict`` that returns portfolio
6
+ returns. Subclasses implement two hooks:
7
+
8
+ * ``_cold_start(cov)`` -- compute weights from scratch given a covariance, and
9
+ initialise any persistent state (e.g. a fixed seed ensemble);
10
+ * ``_online_update(cov)`` -- update weights for a new covariance, reusing that
11
+ state (this is where the smooth, low-turnover transport happens).
12
+
13
+ Covariance is produced by a pluggable estimator (default :class:`EwmaCovariance`);
14
+ anything exposing ``partial_fit`` and ``covariance_`` works, including the
15
+ ``precise`` skaters.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+
22
+ from .moments import EwmaCovariance
23
+
24
+ __all__ = ["BaseOnlinePortfolio"]
25
+
26
+
27
+ class NotFittedError(ValueError):
28
+ pass
29
+
30
+
31
+ class BaseOnlinePortfolio:
32
+ # subclasses set hyperparameters in their own __init__ then call super().__init__()
33
+ def __init__(self, covariance_estimator=None, halflife: float = 60.0):
34
+ self.covariance_estimator = covariance_estimator
35
+ self.halflife = halflife
36
+ self._cov_estimator = None
37
+ self._weights: np.ndarray | None = None
38
+ self.n_features_in_: int | None = None
39
+ self._n_updates = 0
40
+
41
+ # ------------------------------------------------------------------ hooks
42
+ def _cold_start(self, cov: np.ndarray) -> None:
43
+ raise NotImplementedError
44
+
45
+ def _online_update(self, cov: np.ndarray) -> None:
46
+ raise NotImplementedError
47
+
48
+ # ------------------------------------------------------------- internals
49
+ def _new_cov_estimator(self):
50
+ if self.covariance_estimator is None:
51
+ return EwmaCovariance(halflife=self.halflife)
52
+ # a pluggable estimator instance (skfolio.moments, precise skater, ...)
53
+ est = self.covariance_estimator
54
+ if hasattr(est, "fit") or hasattr(est, "partial_fit"):
55
+ return est
56
+ raise TypeError(
57
+ "covariance_estimator must be None or expose fit/partial_fit and covariance_"
58
+ )
59
+
60
+ @staticmethod
61
+ def _as_2d(X) -> np.ndarray:
62
+ X = np.asarray(X, dtype=float)
63
+ return X[None, :] if X.ndim == 1 else X
64
+
65
+ def _feed(self, X) -> np.ndarray:
66
+ self._cov_estimator.partial_fit(X)
67
+ return np.asarray(self._cov_estimator.covariance_, dtype=float)
68
+
69
+ # -------------------------------------------------------------- fitting
70
+ def fit(self, X, y=None):
71
+ X = self._as_2d(X)
72
+ self.n_features_in_ = X.shape[1]
73
+ self._cov_estimator = self._new_cov_estimator()
74
+ if hasattr(self._cov_estimator, "fit"):
75
+ self._cov_estimator.fit(X)
76
+ cov = np.asarray(self._cov_estimator.covariance_, dtype=float)
77
+ self._cold_start(cov)
78
+ self._n_updates = 1
79
+ return self
80
+
81
+ def partial_fit(self, X, y=None):
82
+ if self._weights is None:
83
+ return self.fit(X)
84
+ X = self._as_2d(X)
85
+ if X.shape[1] != self.n_features_in_:
86
+ raise ValueError(
87
+ "number of assets changed; keyed dynamic-universe support is not "
88
+ "wired up yet (see universe.py). Re-fit, or pass a fixed universe."
89
+ )
90
+ cov = self._feed(X)
91
+ self._online_update(cov)
92
+ self._n_updates += 1
93
+ return self
94
+
95
+ # --------------------------------------------------- fitted attributes
96
+ @property
97
+ def weights_(self) -> np.ndarray:
98
+ if self._weights is None:
99
+ raise NotFittedError(f"{type(self).__name__} is not fitted; call fit first.")
100
+ return self._weights
101
+
102
+ def predict(self, X) -> np.ndarray:
103
+ """Portfolio returns of each row of ``X`` under the fitted weights."""
104
+ return self._as_2d(X) @ self.weights_
105
+
106
+ def score(self, X, y=None) -> float:
107
+ """Per-period Sharpe ratio of the portfolio on ``X`` (higher is better).
108
+
109
+ Follows skfolio's convention that an optimizer's score is the Sharpe
110
+ ratio of its predicted portfolio, so the estimator works with
111
+ scikit-learn / skfolio model-selection out of the box.
112
+ """
113
+ r = np.asarray(self.predict(X), dtype=float)
114
+ sd = float(np.std(r))
115
+ return float(np.mean(r) / sd) if sd > 0 else 0.0
116
+
117
+ def to_portfolio(self, X):
118
+ """Wrap the fitted weights as a skfolio ``Portfolio`` (optional dependency).
119
+
120
+ Lets the estimator's output drop into skfolio's metrics, ``Population``,
121
+ and plotting, and is the bridge for contributing the estimator upstream
122
+ (where ``predict`` returns a ``Portfolio`` natively). Requires skfolio.
123
+ """
124
+ try:
125
+ from skfolio import Portfolio
126
+ except Exception as exc: # pragma: no cover - optional dependency
127
+ raise ImportError(
128
+ "to_portfolio requires skfolio (pip install skfolio)"
129
+ ) from exc
130
+ return Portfolio(X=X, weights=self.weights_)
131
+
132
+ def get_params(self, deep: bool = True) -> dict:
133
+ import inspect
134
+
135
+ names = [p for p in inspect.signature(self.__init__).parameters if p != "self"]
136
+ return {k: getattr(self, k) for k in names}
137
+
138
+ def set_params(self, **params):
139
+ for k, v in params.items():
140
+ setattr(self, k, v)
141
+ return self
allocation/keyed.py ADDED
@@ -0,0 +1,223 @@
1
+ """River-style streaming portfolio over a *changing* universe.
2
+
3
+ Real backtests cannot assume a fixed asset set: index members are added and
4
+ dropped, names list and delist. Following river, the streaming interface is
5
+ dict-based -- ``learn_one(x)`` with ``x = {asset_id: return}`` and
6
+ ``predict_one()`` returning ``{asset_id: weight}`` -- so a changing universe is
7
+ handled natively: it is just a changing set of dict keys.
8
+
9
+ Per-asset state is keyed by id and carried across updates. The common-seed
10
+ transport composes with this: each asset owns a persistent seed column, so a
11
+ continuing name keeps its column (and a smooth weight), a new name gets a fresh
12
+ column, and a dropped name's column simply falls out of the active set.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import zlib
18
+
19
+ import numpy as np
20
+
21
+ from ._schur.coupling import compute_monotonic_weights, compute_weights
22
+ from ._schur.seriation import seriate
23
+ from ._thurstone.ability import base_density
24
+ from ._thurstone.calibrate import calibrate_diagonal, calibrate_one_factor
25
+ from ._thurstone.covariance import market_betas, one_factor_corr
26
+ from ._thurstone.diagonal import diagonal_portfolio
27
+ from ._thurstone.transport import blend_correlation, transport_weights
28
+
29
+ __all__ = ["KeyedEwmaCovariance", "StreamingThurstone", "StreamingSchur"]
30
+
31
+
32
+ def _halflife_to_alpha(halflife: float) -> float:
33
+ return 1.0 - 0.5 ** (1.0 / max(float(halflife), 1e-9))
34
+
35
+
36
+ class KeyedEwmaCovariance:
37
+ """Dict-keyed exponentially-weighted online covariance.
38
+
39
+ ``learn_one({id: value})`` updates means and pairwise covariances keyed by
40
+ asset id; ``matrix(ids)`` returns the covariance submatrix for any set of
41
+ ids. Assets may appear or disappear between calls.
42
+ """
43
+
44
+ def __init__(self, halflife: float = 60.0):
45
+ self.halflife = halflife
46
+ self.mean: dict = {}
47
+ self.cov: dict = {} # frozenset-like sorted (i, j) -> ewma covariance
48
+ self.count: dict = {}
49
+
50
+ def learn_one(self, x: dict) -> "KeyedEwmaCovariance":
51
+ a = _halflife_to_alpha(self.halflife)
52
+ for k, v in x.items():
53
+ self.mean[k] = v if k not in self.mean else (1 - a) * self.mean[k] + a * v
54
+ self.count[k] = self.count.get(k, 0) + 1
55
+ dev = {k: x[k] - self.mean[k] for k in x}
56
+ keys = list(x.keys())
57
+ for i in keys:
58
+ di = dev[i]
59
+ for j in keys:
60
+ if j < i:
61
+ continue
62
+ key = (i, j)
63
+ self.cov[key] = (1 - a) * self.cov.get(key, 0.0) + a * di * dev[j]
64
+ return self
65
+
66
+ def matrix(self, ids) -> np.ndarray:
67
+ ids = list(ids)
68
+ n = len(ids)
69
+ C = np.zeros((n, n), dtype=float)
70
+ for a, i in enumerate(ids):
71
+ for b in range(a, n):
72
+ j = ids[b]
73
+ key = (i, j) if i <= j else (j, i)
74
+ val = self.cov.get(key, 0.0)
75
+ C[a, b] = C[b, a] = val
76
+ # guard a strictly-positive diagonal for assets seen at least once
77
+ d = np.diag(C).copy()
78
+ d[d <= 0] = 1e-8
79
+ np.fill_diagonal(C, d)
80
+ return C
81
+
82
+
83
+ class StreamingThurstone:
84
+ """Streaming Thurstone portfolio over a changing universe (river-style).
85
+
86
+ Parameters mirror :class:`allocation.ThurstonePortfolio`. The active universe
87
+ at each step is the set of ids present in the latest observation.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ *,
93
+ calib: str = "diagonal",
94
+ phi: float = 1.0,
95
+ n_paths: int = 1 << 12,
96
+ n_quad: int = 16,
97
+ halflife: float = 60.0,
98
+ seed: int = 42,
99
+ min_obs: int = 20,
100
+ ):
101
+ if not 0.0 <= phi <= 1.0:
102
+ raise ValueError("phi must lie in [0, 1].")
103
+ self.calib = calib
104
+ self.phi = phi
105
+ self.n_paths = 1 << int(np.ceil(np.log2(max(int(n_paths), 2))))
106
+ self.n_quad = n_quad
107
+ self.halflife = halflife
108
+ self.seed = seed
109
+ self.min_obs = min_obs
110
+ self._cov = KeyedEwmaCovariance(halflife=halflife)
111
+ self._seed_bank: dict = {}
112
+ self._weights: dict = {}
113
+ self._base = base_density()
114
+ self._n = 0
115
+
116
+ # --------------------------------------------------------- seed bank
117
+ def _seed_column(self, asset_id) -> np.ndarray:
118
+ col = self._seed_bank.get(asset_id)
119
+ if col is None:
120
+ # stable, per-asset, reproducible across sessions
121
+ h = zlib.crc32(str(asset_id).encode()) & 0xFFFFFFFF
122
+ rng = np.random.default_rng((self.seed, h))
123
+ col = rng.standard_normal(self.n_paths)
124
+ self._seed_bank[asset_id] = col
125
+ return col
126
+
127
+ def _seeds(self, ids) -> np.ndarray:
128
+ return np.column_stack([self._seed_column(k) for k in ids])
129
+
130
+ # --------------------------------------------------------- streaming
131
+ def learn_one(self, x: dict) -> "StreamingThurstone":
132
+ self._cov.learn_one(x)
133
+ self._n += 1
134
+ # only allocate to assets that have warmed up (a reliable variance);
135
+ # this is what keeps a freshly-listed name from hijacking the
136
+ # inverse-variance target with a cold, near-zero variance estimate.
137
+ warm = [k for k in sorted(x.keys()) if self._cov.count.get(k, 0) >= self.min_obs]
138
+ if len(warm) >= 2:
139
+ self._recompute(warm)
140
+ return self
141
+
142
+ def _recompute(self, ids) -> None:
143
+ cov = self._cov.matrix(ids)
144
+ tgt = diagonal_portfolio(cov)
145
+ if self.calib == "market":
146
+ betas = market_betas(cov, weights=tgt)
147
+ C_calib = one_factor_corr(betas)
148
+ ability = calibrate_one_factor(tgt, betas, base=self._base, n_quad=self.n_quad)
149
+ else:
150
+ C_calib = np.eye(len(ids))
151
+ ability = calibrate_diagonal(tgt, base=self._base)
152
+ C_tilt = blend_correlation(C_calib, cov, self.phi)
153
+ w = transport_weights(ability, C_tilt, self._seeds(ids))
154
+ self._weights = dict(zip(ids, w))
155
+
156
+ def predict_one(self, x: dict | None = None) -> dict:
157
+ """Current portfolio as ``{asset_id: weight}`` (a copy)."""
158
+ return dict(self._weights)
159
+
160
+ @property
161
+ def weights(self) -> dict:
162
+ return dict(self._weights)
163
+
164
+
165
+ class StreamingSchur:
166
+ """Streaming Schur-complementary portfolio over a changing universe.
167
+
168
+ River-style ``learn_one({asset: ret})`` / ``predict_one()``. The Fiedler
169
+ seriation is carried as a per-asset dict so its sign (hence the order) stays
170
+ stable as the universe changes. Parameters mirror
171
+ :class:`allocation.SchurComplementary`.
172
+ """
173
+
174
+ def __init__(
175
+ self,
176
+ *,
177
+ gamma: float = 0.5,
178
+ keep_monotonic: bool = True,
179
+ knn: int | None = None,
180
+ halflife: float = 60.0,
181
+ min_obs: int = 20,
182
+ ):
183
+ if not 0.0 <= gamma <= 1.0:
184
+ raise ValueError("gamma must lie in [0, 1].")
185
+ self.gamma = gamma
186
+ self.keep_monotonic = keep_monotonic
187
+ self.knn = knn
188
+ self.halflife = halflife
189
+ self.min_obs = min_obs
190
+ self._cov = KeyedEwmaCovariance(halflife=halflife)
191
+ self._fiedler: dict = {} # asset_id -> Fiedler coordinate (for sign stability)
192
+ self._weights: dict = {}
193
+ self._n = 0
194
+
195
+ def learn_one(self, x: dict) -> "StreamingSchur":
196
+ self._cov.learn_one(x)
197
+ self._n += 1
198
+ warm = [k for k in sorted(x.keys()) if self._cov.count.get(k, 0) >= self.min_obs]
199
+ if len(warm) >= 2:
200
+ self._recompute(warm)
201
+ return self
202
+
203
+ def _recompute(self, ids) -> None:
204
+ cov = self._cov.matrix(ids)
205
+ prev = (
206
+ np.array([self._fiedler.get(k, 0.0) for k in ids])
207
+ if self._fiedler
208
+ else None
209
+ )
210
+ order, v = seriate(cov, previous=prev, knn=self.knn)
211
+ self._fiedler = dict(zip(ids, v))
212
+ if self.keep_monotonic:
213
+ w, _ = compute_monotonic_weights(order, cov, self.gamma)
214
+ else:
215
+ w = compute_weights(order, cov, self.gamma)
216
+ self._weights = dict(zip(ids, w))
217
+
218
+ def predict_one(self, x: dict | None = None) -> dict:
219
+ return dict(self._weights)
220
+
221
+ @property
222
+ def weights(self) -> dict:
223
+ return dict(self._weights)
allocation/moments.py ADDED
@@ -0,0 +1,62 @@
1
+ """Online covariance for the portfolio estimators.
2
+
3
+ A light, numpy-only EWMA covariance is the default so the package has no heavy
4
+ dependencies. Any external online estimator that exposes a ``covariance_``
5
+ attribute and a ``partial_fit`` method (e.g. the ``precise`` covariance skaters)
6
+ can be plugged in instead -- the portfolio estimators only ever read
7
+ ``covariance_``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+ __all__ = ["EwmaCovariance"]
15
+
16
+
17
+ def _halflife_to_alpha(halflife: float) -> float:
18
+ return 1.0 - 0.5 ** (1.0 / max(float(halflife), 1e-9))
19
+
20
+
21
+ class EwmaCovariance:
22
+ """Exponentially-weighted online mean/covariance estimator.
23
+
24
+ Mirrors the minimal surface the portfolio estimators rely on:
25
+ ``partial_fit(X)`` / ``fit(X)`` and the fitted ``covariance_``.
26
+ """
27
+
28
+ def __init__(self, halflife: float = 60.0):
29
+ self.halflife = halflife
30
+ self._mean: np.ndarray | None = None
31
+ self._cov: np.ndarray | None = None
32
+ self.n_samples_ = 0
33
+
34
+ def _rows(self, X) -> np.ndarray:
35
+ X = np.asarray(X, dtype=float)
36
+ return X[None, :] if X.ndim == 1 else X
37
+
38
+ def partial_fit(self, X, y=None) -> "EwmaCovariance":
39
+ alpha = _halflife_to_alpha(self.halflife)
40
+ for x in self._rows(X):
41
+ if self._mean is None:
42
+ n = len(x)
43
+ self._mean = x.copy()
44
+ self._cov = np.zeros((n, n), dtype=float)
45
+ else:
46
+ self._mean = (1 - alpha) * self._mean + alpha * x
47
+ dev = x - self._mean
48
+ self._cov = (1 - alpha) * self._cov + alpha * np.outer(dev, dev)
49
+ self.n_samples_ += 1
50
+ return self
51
+
52
+ def fit(self, X, y=None) -> "EwmaCovariance":
53
+ self._mean = None
54
+ self._cov = None
55
+ self.n_samples_ = 0
56
+ return self.partial_fit(X)
57
+
58
+ @property
59
+ def covariance_(self) -> np.ndarray:
60
+ if self._cov is None:
61
+ raise ValueError("EwmaCovariance has not seen any data yet.")
62
+ return 0.5 * (self._cov + self._cov.T)
allocation/schur.py ADDED
@@ -0,0 +1,101 @@
1
+ """Schur-complementary portfolio as an online estimator.
2
+
3
+ Your Schur Complementary allocation (the ``gamma`` cross-block coupling that
4
+ interpolates HRP at ``gamma=0`` and minimum variance as ``gamma->1``), but with
5
+ the asset order coming from **Fiedler seriation** instead of agglomerative
6
+ linkage. Because the Fiedler order is a smooth function of the covariance, the
7
+ whole allocation is smooth -- so ``partial_fit`` over a drifting covariance gives
8
+ low-turnover updates, the same streaming property the Thurstone estimator has.
9
+
10
+ The seriation sign is carried across updates so the order stays stable; the
11
+ similarity graph can be kNN-sparsified and blended with a prior (sectors /
12
+ fundamentals / LLM) for scale and cold-start placement.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+
19
+ from .base import BaseOnlinePortfolio
20
+ from ._schur.coupling import compute_monotonic_weights, compute_weights
21
+ from ._schur.seriation import seriate
22
+
23
+ __all__ = ["SchurComplementary"]
24
+
25
+
26
+ class SchurComplementary(BaseOnlinePortfolio):
27
+ """Online Schur-complementary portfolio with Fiedler seriation.
28
+
29
+ Parameters
30
+ ----------
31
+ gamma : float in [0, 1], default 0.5
32
+ Cross-block coupling. 0 = HRP (block-diagonal); ->1 = minimum variance.
33
+ knn : int or None, default None
34
+ If set, sparsify the similarity graph to k nearest neighbours (for scale).
35
+ prior : array (n, n) or None, default None
36
+ Optional prior similarity blended into the seriation graph.
37
+ prior_weight : float in [0, 1], default 0.0
38
+ Weight on ``prior`` in the similarity blend.
39
+ covariance_estimator : estimator or None, default None
40
+ Online covariance (``partial_fit`` / ``covariance_``). None uses EWMA.
41
+ halflife : float, default 60.0
42
+ EWMA halflife when ``covariance_estimator is None``.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ gamma: float = 0.5,
49
+ keep_monotonic: bool = True,
50
+ knn: int | None = None,
51
+ prior=None,
52
+ prior_weight: float = 0.0,
53
+ covariance_estimator=None,
54
+ halflife: float = 60.0,
55
+ ):
56
+ super().__init__(covariance_estimator=covariance_estimator, halflife=halflife)
57
+ self.gamma = gamma
58
+ self.keep_monotonic = keep_monotonic
59
+ self.knn = knn
60
+ self.prior = prior
61
+ self.prior_weight = prior_weight
62
+ self._fiedler = None
63
+ self._order = None
64
+ self.effective_gamma_ = None
65
+
66
+ def _seriate(self, cov: np.ndarray):
67
+ order, v = seriate(
68
+ cov,
69
+ previous=self._fiedler,
70
+ knn=self.knn,
71
+ prior=self.prior,
72
+ prior_weight=self.prior_weight,
73
+ )
74
+ self._fiedler = v
75
+ self._order = order
76
+ return order
77
+
78
+ def _allocate(self, order, cov) -> None:
79
+ if self.keep_monotonic:
80
+ self._weights, self.effective_gamma_ = compute_monotonic_weights(
81
+ order, cov, self.gamma
82
+ )
83
+ else:
84
+ self._weights = compute_weights(order, cov, self.gamma)
85
+ self.effective_gamma_ = self.gamma
86
+
87
+ def _cold_start(self, cov: np.ndarray) -> None:
88
+ if not 0.0 <= self.gamma <= 1.0:
89
+ raise ValueError("gamma must lie in [0, 1].")
90
+ self._allocate(self._seriate(cov), cov)
91
+
92
+ def _online_update(self, cov: np.ndarray) -> None:
93
+ self._allocate(self._seriate(cov), cov) # warm-started sign keeps order stable
94
+
95
+ @property
96
+ def order_(self) -> np.ndarray:
97
+ return self._order
98
+
99
+ @property
100
+ def fiedler_(self) -> np.ndarray:
101
+ return self._fiedler
@@ -0,0 +1,142 @@
1
+ """Thurstone portfolio: the ability tilt as an online estimator.
2
+
3
+ Weights are the winning probabilities of a Thurstonian race among assets.
4
+ Abilities are calibrated so that the race under a reference correlation
5
+ ``C_calib`` reproduces a target benchmark; the tilt then re-runs the race under
6
+ the estimated correlation ``C_tilt``. ``partial_fit`` transports a fixed seed
7
+ ensemble to the updated correlation, so weights move smoothly (low turnover).
8
+
9
+ See the paper *Thurstone Portfolios: Long-Only Allocation by Inverting Winning
10
+ Probabilities* for the theory (feasibility, redundancy consistency, the implied
11
+ regularized objective, and the smoothness/turnover bound).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+
18
+ from .base import BaseOnlinePortfolio
19
+ from ._thurstone.ability import base_density
20
+ from ._thurstone.calibrate import calibrate_diagonal, calibrate_one_factor
21
+ from ._thurstone.covariance import market_betas, one_factor_corr
22
+ from ._thurstone.diagonal import diagonal_portfolio
23
+ from ._thurstone.transport import blend_correlation, transport_weights
24
+
25
+ __all__ = ["ThurstonePortfolio"]
26
+
27
+
28
+ def _pow2(n: int) -> int:
29
+ return 1 << int(np.ceil(np.log2(max(int(n), 2))))
30
+
31
+
32
+ def _normalize(w) -> np.ndarray:
33
+ w = np.asarray(w, dtype=float)
34
+ s = w.sum()
35
+ return w / s if s > 0 else np.full(len(w), 1.0 / len(w))
36
+
37
+
38
+ class ThurstonePortfolio(BaseOnlinePortfolio):
39
+ """Long-only Thurstone-portfolio estimator.
40
+
41
+ Parameters
42
+ ----------
43
+ target : {"diagonal", "equal"} or array, default "diagonal"
44
+ Benchmark the calibration reproduces under the reference correlation. An
45
+ array (e.g. capitalization weights) is used as-is.
46
+ calib : {"diagonal", "market"}, default "diagonal"
47
+ Reference correlation ``C_calib``: identity (independent) or a one-factor
48
+ market model fitted from the covariance.
49
+ phi : float in [0, 1], default 1.0
50
+ Correlation scale for the tilt: 0 reproduces the benchmark, 1 uses the
51
+ full estimated correlation.
52
+ n_paths : int, default 16384
53
+ Monte-Carlo seed budget (rounded up to a power of two).
54
+ n_quad : int, default 16
55
+ Gauss--Hermite nodes for one-factor calibration.
56
+ seed : int, default 42
57
+ Seed for the fixed path ensemble.
58
+ covariance : estimator or None, default None
59
+ Online covariance estimator (``partial_fit`` / ``covariance_``). None uses
60
+ a built-in EWMA with ``halflife``.
61
+ halflife : float, default 60.0
62
+ EWMA halflife when ``covariance is None``.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ target="diagonal",
69
+ calib: str = "diagonal",
70
+ phi: float = 1.0,
71
+ n_paths: int = 1 << 14,
72
+ n_quad: int = 16,
73
+ seed: int = 42,
74
+ covariance_estimator=None,
75
+ halflife: float = 60.0,
76
+ ):
77
+ super().__init__(covariance_estimator=covariance_estimator, halflife=halflife)
78
+ self.target = target
79
+ self.calib = calib
80
+ self.phi = phi
81
+ self.n_paths = n_paths
82
+ self.n_quad = n_quad
83
+ self.seed = seed
84
+ # persistent state set in _cold_start
85
+ self._seeds = None
86
+ self._ability = None
87
+ self._C_calib = None
88
+ self._target_w = None
89
+ self._betas = None
90
+
91
+ # ------------------------------------------------------------- helpers
92
+ def _resolve_target(self, cov: np.ndarray) -> np.ndarray:
93
+ n = cov.shape[0]
94
+ if isinstance(self.target, str):
95
+ if self.target == "diagonal":
96
+ return diagonal_portfolio(cov)
97
+ if self.target == "equal":
98
+ return np.full(n, 1.0 / n)
99
+ raise ValueError(f"unknown target {self.target!r}")
100
+ return _normalize(self.target)
101
+
102
+ # ------------------------------------------------------- estimator hooks
103
+ def _cold_start(self, cov: np.ndarray) -> None:
104
+ if not 0.0 <= self.phi <= 1.0:
105
+ raise ValueError("phi must lie in [0, 1].")
106
+ n = cov.shape[0]
107
+ base = base_density()
108
+ tgt = self._resolve_target(cov)
109
+ self._target_w = tgt
110
+
111
+ if self.calib == "diagonal":
112
+ self._betas = np.zeros(n)
113
+ self._C_calib = np.eye(n)
114
+ self._ability = calibrate_diagonal(tgt, base=base)
115
+ elif self.calib == "market":
116
+ b = market_betas(cov, weights=tgt)
117
+ self._betas = b
118
+ self._C_calib = one_factor_corr(b)
119
+ self._ability = calibrate_one_factor(tgt, b, base=base, n_quad=self.n_quad)
120
+ else:
121
+ raise ValueError(f"unknown calib {self.calib!r} (use 'diagonal' or 'market')")
122
+
123
+ rng = np.random.default_rng(self.seed)
124
+ self._seeds = rng.standard_normal((_pow2(self.n_paths), n))
125
+ self._online_update(cov)
126
+
127
+ def _online_update(self, cov: np.ndarray) -> None:
128
+ C_tilt = blend_correlation(self._C_calib, cov, self.phi)
129
+ self._weights = transport_weights(self._ability, C_tilt, self._seeds)
130
+
131
+ # --------------------------------------------------- fitted attributes
132
+ @property
133
+ def ability_(self) -> np.ndarray:
134
+ return self._ability
135
+
136
+ @property
137
+ def betas_(self) -> np.ndarray:
138
+ return self._betas
139
+
140
+ @property
141
+ def target_(self) -> np.ndarray:
142
+ return self._target_w
allocation/universe.py ADDED
@@ -0,0 +1,22 @@
1
+ """Keyed dynamic-universe support (design placeholder).
2
+
3
+ skfolio handles a changing asset set the batch way (NaN handling, pre-selection,
4
+ running on complete subsets). For a streaming estimator over a reconstituting
5
+ universe (index additions/deletions, listings/delistings) we instead want
6
+ per-asset state keyed by asset id, carried across ``partial_fit`` steps -- the
7
+ approach taken by the ``precise`` package (``keyed`` / ``DynamicUniverse``).
8
+
9
+ The common-seed transport composes naturally with this: a continuing asset keeps
10
+ its seed column (so its weight stays smooth), a new asset gets a fresh column,
11
+ and a delisted asset's column is dropped. This module will hold that keyed-state
12
+ machinery; it is not wired into the estimators yet.
13
+
14
+ TODO:
15
+ - KeyedUniverse: maintain id -> column maps and per-asset persistent seed columns.
16
+ - Wrap BaseOnlinePortfolio so fit/partial_fit accept a DataFrame (columns = ids)
17
+ and reconcile the abilities / seeds / covariance state on universe changes.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ __all__: list[str] = []