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/__init__.py +30 -0
- allocation/_schur/__init__.py +0 -0
- allocation/_schur/coupling.py +199 -0
- allocation/_schur/seriation.py +127 -0
- allocation/_thurstone/__init__.py +0 -0
- allocation/_thurstone/ability.py +45 -0
- allocation/_thurstone/calibrate.py +105 -0
- allocation/_thurstone/covariance.py +91 -0
- allocation/_thurstone/diagonal.py +26 -0
- allocation/_thurstone/transport.py +53 -0
- allocation/base.py +141 -0
- allocation/keyed.py +223 -0
- allocation/moments.py +62 -0
- allocation/schur.py +101 -0
- allocation/thurstone.py +142 -0
- allocation/universe.py +22 -0
- allocation-0.0.1.dist-info/METADATA +89 -0
- allocation-0.0.1.dist-info/RECORD +21 -0
- allocation-0.0.1.dist-info/WHEEL +5 -0
- allocation-0.0.1.dist-info/licenses/LICENSE +21 -0
- allocation-0.0.1.dist-info/top_level.txt +1 -0
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
|
allocation/thurstone.py
ADDED
|
@@ -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] = []
|