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/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""allocation -- online (streaming) portfolio construction.
|
|
2
|
+
|
|
3
|
+
A home for online/streaming portfolio estimators that follow scikit-learn /
|
|
4
|
+
skfolio conventions (``fit`` / ``partial_fit`` / ``weights_`` / ``predict``) on
|
|
5
|
+
top of a keyed dynamic-universe layer, so they survive reconstituting universes.
|
|
6
|
+
|
|
7
|
+
Estimators:
|
|
8
|
+
from allocation import ThurstonePortfolio
|
|
9
|
+
w = ThurstonePortfolio(calib="market", phi=1.0).fit(returns).weights_
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .base import BaseOnlinePortfolio
|
|
13
|
+
from .keyed import KeyedEwmaCovariance, StreamingSchur, StreamingThurstone
|
|
14
|
+
from .moments import EwmaCovariance
|
|
15
|
+
from .schur import SchurComplementary
|
|
16
|
+
from .thurstone import ThurstonePortfolio
|
|
17
|
+
|
|
18
|
+
__version__ = "0.0.1"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
# batch / skfolio-style
|
|
22
|
+
"BaseOnlinePortfolio",
|
|
23
|
+
"ThurstonePortfolio",
|
|
24
|
+
"SchurComplementary",
|
|
25
|
+
"EwmaCovariance",
|
|
26
|
+
# streaming / river-style (changing universe)
|
|
27
|
+
"StreamingThurstone",
|
|
28
|
+
"StreamingSchur",
|
|
29
|
+
"KeyedEwmaCovariance",
|
|
30
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Schur-complementary allocation: the gamma cross-block coupling + recursive bisection.
|
|
2
|
+
|
|
3
|
+
Faithful port of the Schur Complementary algorithm (Peter Cotton, 2022; in skfolio
|
|
4
|
+
as ``SchurComplementary``, BSD-3, derived from ``precise``). Given a seriation
|
|
5
|
+
(asset order) and a covariance, recursive median bisection allocates between halves
|
|
6
|
+
in inverse proportion to each half's variance -- but each half's covariance is first
|
|
7
|
+
*augmented* by the Schur complement of the other half, scaled by ``gamma``:
|
|
8
|
+
``gamma = 0`` ignores the cross-block (Hierarchical Risk Parity); ``gamma -> 1`` tends
|
|
9
|
+
to the minimum-variance portfolio.
|
|
10
|
+
|
|
11
|
+
Only the *order* comes from clustering; everything here is a continuous function of
|
|
12
|
+
the covariance, so a smooth order (Fiedler seriation) yields smooth weights.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
__all__ = ["compute_weights", "compute_monotonic_weights", "schur_augmentation"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _inverse_multiply(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
25
|
+
"""a^-1 @ b via a solve (more accurate than forming the inverse)."""
|
|
26
|
+
return np.linalg.solve(a, b)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _multiply_by_inverse(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
30
|
+
"""a @ b^-1."""
|
|
31
|
+
return np.linalg.solve(b.T, a.T).T
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _symmetrize(m: np.ndarray) -> np.ndarray:
|
|
35
|
+
return 0.5 * (m + m.T)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_spd(a: np.ndarray) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
np.linalg.cholesky(a)
|
|
41
|
+
return True
|
|
42
|
+
except np.linalg.LinAlgError:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _nearest_spd(a: np.ndarray) -> np.ndarray:
|
|
47
|
+
a = _symmetrize(a)
|
|
48
|
+
vals, vecs = np.linalg.eigh(a)
|
|
49
|
+
vals = np.clip(vals, 1e-10, None)
|
|
50
|
+
return _symmetrize((vecs * vals) @ vecs.T)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _symmetric_step_up_matrix(n1: int, n2: int) -> np.ndarray:
|
|
54
|
+
"""M with ``M @ ones(n2) == ones(n1)`` (|n1 - n2| <= 1)."""
|
|
55
|
+
if n1 == n2:
|
|
56
|
+
return np.eye(n1)
|
|
57
|
+
if n1 < n2:
|
|
58
|
+
return _symmetric_step_up_matrix(n2, n1).T * n1 / n2
|
|
59
|
+
m = np.zeros((n1, n2))
|
|
60
|
+
j_row = np.ones((1, n2)) / n2
|
|
61
|
+
e = np.eye(n2)
|
|
62
|
+
for j in range(n1):
|
|
63
|
+
mj = np.concatenate([e[:j], j_row, e[j:]], axis=0)
|
|
64
|
+
m += mj / n1
|
|
65
|
+
return m
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def schur_augmentation(a: np.ndarray, b: np.ndarray, d: np.ndarray, gamma: float) -> np.ndarray:
|
|
69
|
+
"""Augment block ``a`` with the gamma-scaled Schur complement of ``d``."""
|
|
70
|
+
n_a, n_d = a.shape[0], d.shape[0]
|
|
71
|
+
if gamma == 0 or n_a == 1 or n_d == 1:
|
|
72
|
+
return a
|
|
73
|
+
a_aug = a - gamma * b @ _inverse_multiply(d, b.T)
|
|
74
|
+
m = _symmetric_step_up_matrix(n1=n_a, n2=n_d)
|
|
75
|
+
r = np.eye(n_a) - gamma * _multiply_by_inverse(b, d) @ m.T
|
|
76
|
+
return _symmetrize(_inverse_multiply(r, a_aug))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _bisection(items):
|
|
80
|
+
for e in items:
|
|
81
|
+
n = len(e)
|
|
82
|
+
if n > 1:
|
|
83
|
+
mid = n // 2
|
|
84
|
+
yield e[:mid], e[mid:]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _naive_portfolio_variance(cov: np.ndarray) -> float:
|
|
88
|
+
w = 1.0 / np.diag(cov)
|
|
89
|
+
w = w / w.sum()
|
|
90
|
+
return float(w @ cov @ w.T)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def compute_weights(
|
|
94
|
+
order: np.ndarray, covariance: np.ndarray, gamma: float, force_spd: bool = True
|
|
95
|
+
) -> np.ndarray:
|
|
96
|
+
"""Schur-complementary weights for a given seriation ``order`` and covariance.
|
|
97
|
+
|
|
98
|
+
Weights are returned in the original asset order (not the seriation order),
|
|
99
|
+
are long-only, and sum to one.
|
|
100
|
+
"""
|
|
101
|
+
cov = np.array(covariance, dtype=float, copy=True)
|
|
102
|
+
n = len(cov)
|
|
103
|
+
weights = np.ones(n)
|
|
104
|
+
items = [np.asarray(order, dtype=int)]
|
|
105
|
+
while items:
|
|
106
|
+
new_items = []
|
|
107
|
+
for left, right in _bisection(items):
|
|
108
|
+
new_items += [left, right]
|
|
109
|
+
a = cov[np.ix_(left, left)]
|
|
110
|
+
d = cov[np.ix_(right, right)]
|
|
111
|
+
if len(left) <= 1 or len(right) <= 1:
|
|
112
|
+
a_aug, d_aug = a, d
|
|
113
|
+
else:
|
|
114
|
+
b = cov[np.ix_(left, right)]
|
|
115
|
+
a_aug = schur_augmentation(a, b, d, gamma=gamma)
|
|
116
|
+
d_aug = schur_augmentation(d, b.T, a, gamma=gamma)
|
|
117
|
+
if force_spd:
|
|
118
|
+
if not _is_spd(a_aug):
|
|
119
|
+
a_aug = _nearest_spd(a_aug)
|
|
120
|
+
if not _is_spd(d_aug):
|
|
121
|
+
d_aug = _nearest_spd(d_aug)
|
|
122
|
+
elif not _is_spd(a_aug) or not _is_spd(d_aug):
|
|
123
|
+
return None # signal infeasible gamma to the monotonic sweep
|
|
124
|
+
cov[np.ix_(left, left)] = a_aug
|
|
125
|
+
cov[np.ix_(right, right)] = d_aug
|
|
126
|
+
lv = _naive_portfolio_variance(a_aug)
|
|
127
|
+
rv = _naive_portfolio_variance(d_aug)
|
|
128
|
+
alpha = 1.0 - lv / (lv + rv)
|
|
129
|
+
weights[left] *= alpha
|
|
130
|
+
weights[right] *= 1.0 - alpha
|
|
131
|
+
items = new_items
|
|
132
|
+
s = weights.sum()
|
|
133
|
+
return weights / s if s > 0 else np.full(n, 1.0 / n)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _binary_search(objective, low_gamma, high_gamma, low_variance, tol=1e-4):
|
|
137
|
+
"""Locate the gamma turning point where portfolio variance stops decreasing."""
|
|
138
|
+
max_iter = math.ceil(math.log2(max(high_gamma - low_gamma, tol) / tol) * 2 + 1)
|
|
139
|
+
is_decreasing = False
|
|
140
|
+
best = None
|
|
141
|
+
for _ in range(max_iter):
|
|
142
|
+
mid = 0.5 * (low_gamma + high_gamma)
|
|
143
|
+
variance, weights = objective(mid)
|
|
144
|
+
variance_h = objective(mid - tol)[0]
|
|
145
|
+
if variance <= low_variance and variance <= variance_h:
|
|
146
|
+
is_decreasing = True
|
|
147
|
+
low_gamma, low_variance, best = mid, variance, weights
|
|
148
|
+
else:
|
|
149
|
+
high_gamma = mid
|
|
150
|
+
if is_decreasing and best is not None and (high_gamma - low_gamma) <= tol:
|
|
151
|
+
return best, low_gamma
|
|
152
|
+
raise RuntimeError("no permissible gamma with monotonically decreasing variance")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def compute_monotonic_weights(
|
|
156
|
+
order: np.ndarray,
|
|
157
|
+
covariance: np.ndarray,
|
|
158
|
+
max_gamma: float,
|
|
159
|
+
step: float = 0.1,
|
|
160
|
+
tol: float = 1e-4,
|
|
161
|
+
) -> tuple[np.ndarray, float]:
|
|
162
|
+
"""Weights at the largest gamma (<= ``max_gamma``) for which variance still falls.
|
|
163
|
+
|
|
164
|
+
Caps gamma at the turning point ``effective_gamma`` so that
|
|
165
|
+
``variance(Schur) <= variance(HRP)`` even for ill-conditioned covariances
|
|
166
|
+
(skfolio's ``keep_monotonic=True`` behaviour). Returns ``(weights, effective_gamma)``.
|
|
167
|
+
"""
|
|
168
|
+
if max_gamma == 0:
|
|
169
|
+
return compute_weights(order, covariance, 0.0, force_spd=True), 0.0
|
|
170
|
+
cov = np.asarray(covariance, dtype=float)
|
|
171
|
+
|
|
172
|
+
def objective(x):
|
|
173
|
+
w = compute_weights(order, cov, x, force_spd=False)
|
|
174
|
+
risk = np.inf if w is None else float(w @ cov @ w.T)
|
|
175
|
+
return risk, w
|
|
176
|
+
|
|
177
|
+
n = int(np.ceil(max_gamma / step)) + 1
|
|
178
|
+
gammas = np.linspace(0.0, max_gamma, n)
|
|
179
|
+
variances = np.full(n, np.nan)
|
|
180
|
+
variance, weights_0 = objective(gammas[0])
|
|
181
|
+
variances[0] = variance
|
|
182
|
+
weights = weights_0
|
|
183
|
+
for i in range(1, n):
|
|
184
|
+
variance, weights = objective(gammas[i])
|
|
185
|
+
variances[i] = variance
|
|
186
|
+
if variance >= variances[i - 1]:
|
|
187
|
+
lo = gammas[0] if i == 1 else gammas[i - 2]
|
|
188
|
+
lo_var = variances[0] if i == 1 else variances[i - 2]
|
|
189
|
+
try:
|
|
190
|
+
return _binary_search(objective, lo, gammas[i], lo_var, tol)
|
|
191
|
+
except RuntimeError:
|
|
192
|
+
return weights_0, 0.0
|
|
193
|
+
# monotonically decreasing up to max_gamma
|
|
194
|
+
if variance <= objective(max_gamma - tol)[0]:
|
|
195
|
+
return weights, max_gamma
|
|
196
|
+
try:
|
|
197
|
+
return _binary_search(objective, gammas[-2], max_gamma, variances[-2], tol)
|
|
198
|
+
except RuntimeError:
|
|
199
|
+
return weights, max_gamma
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Fiedler (spectral) seriation -- a smooth ordering of assets.
|
|
2
|
+
|
|
3
|
+
Standard HRP/Schur take the asset order from agglomerative clustering, whose
|
|
4
|
+
dendrogram reorders discontinuously as the covariance drifts -- the source of
|
|
5
|
+
turnover. We replace it with the Fiedler vector (the eigenvector of the second
|
|
6
|
+
smallest Laplacian eigenvalue of the similarity graph): it varies *continuously*
|
|
7
|
+
with the covariance, so the order changes only when two assets' Fiedler
|
|
8
|
+
coordinates cross. Feeding this order into the Schur recursion makes the whole
|
|
9
|
+
allocation a smooth function of the covariance.
|
|
10
|
+
|
|
11
|
+
Scale: the similarity graph can be sparsified to k nearest neighbours so the
|
|
12
|
+
Fiedler vector is a single eigenpair of a sparse Laplacian (the dense ``eigh``
|
|
13
|
+
here is fine to a few thousand assets; an iterative, warm-started solver is the
|
|
14
|
+
drop-in for larger universes). The graph is a blend of realized correlation and
|
|
15
|
+
an optional prior (sectors / fundamentals / an LLM similarity) -- the seam that
|
|
16
|
+
lets a prior place cold-start names the realized correlation cannot.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from .._thurstone.covariance import cov_to_corr
|
|
24
|
+
|
|
25
|
+
__all__ = ["affinity", "fiedler_vector", "seriate"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def affinity(
|
|
29
|
+
cov: np.ndarray,
|
|
30
|
+
knn: int | None = None,
|
|
31
|
+
prior: np.ndarray | None = None,
|
|
32
|
+
prior_weight: float = 0.0,
|
|
33
|
+
) -> np.ndarray:
|
|
34
|
+
"""Similarity (affinity) graph in [0, 1], optionally kNN-sparsified.
|
|
35
|
+
|
|
36
|
+
Realized similarity is ``(corr + 1) / 2``; blended with an optional ``prior``
|
|
37
|
+
similarity by ``prior_weight``. With ``knn`` set, each row keeps only its k
|
|
38
|
+
largest off-diagonal affinities (symmetrized).
|
|
39
|
+
"""
|
|
40
|
+
A = 0.5 * (cov_to_corr(cov) + 1.0)
|
|
41
|
+
if prior is not None and prior_weight > 0.0:
|
|
42
|
+
A = (1.0 - prior_weight) * A + prior_weight * np.asarray(prior, dtype=float)
|
|
43
|
+
np.fill_diagonal(A, 0.0)
|
|
44
|
+
if knn is not None and knn < A.shape[0] - 1:
|
|
45
|
+
n = A.shape[0]
|
|
46
|
+
mask = np.zeros_like(A, dtype=bool)
|
|
47
|
+
keep = np.argsort(-A, axis=1)[:, :knn]
|
|
48
|
+
rows = np.repeat(np.arange(n), knn)
|
|
49
|
+
mask[rows, keep.ravel()] = True
|
|
50
|
+
mask |= mask.T # symmetric kNN graph
|
|
51
|
+
A = np.where(mask, A, 0.0)
|
|
52
|
+
return A
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fiedler_dense(A: np.ndarray) -> np.ndarray:
|
|
56
|
+
L = np.diag(A.sum(axis=1)) - A
|
|
57
|
+
_, vecs = np.linalg.eigh(L)
|
|
58
|
+
return vecs[:, 1] # 2nd-smallest eigenvalue -> Fiedler vector
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fiedler_sparse(A: np.ndarray, previous: np.ndarray | None) -> np.ndarray:
|
|
62
|
+
"""Fiedler vector via LOBPCG on a sparse Laplacian (only one eigenpair).
|
|
63
|
+
|
|
64
|
+
We deflate the constant null vector (`Y = 1`, the eigenvalue-0 eigenvector of a
|
|
65
|
+
connected Laplacian) and ask for the smallest remaining eigenpair -- the
|
|
66
|
+
Fiedler vector. Warm-started from ``previous``. This is what scales: an
|
|
67
|
+
`O(nnz)` matvec on a kNN-sparsified graph rather than a dense `O(n^3)` eigh.
|
|
68
|
+
"""
|
|
69
|
+
import scipy.sparse as sp
|
|
70
|
+
import scipy.sparse.linalg as spla
|
|
71
|
+
|
|
72
|
+
n = A.shape[0]
|
|
73
|
+
Asp = sp.csr_matrix(A)
|
|
74
|
+
deg = np.asarray(Asp.sum(axis=1)).ravel()
|
|
75
|
+
L = sp.diags(deg) - Asp
|
|
76
|
+
if previous is not None and len(previous) == n:
|
|
77
|
+
X = previous.reshape(n, 1).copy()
|
|
78
|
+
else:
|
|
79
|
+
rng = np.random.default_rng(0)
|
|
80
|
+
X = rng.standard_normal((n, 1))
|
|
81
|
+
Y = np.ones((n, 1)) # deflate the constant (lambda=0) eigenvector
|
|
82
|
+
_, vecs = spla.lobpcg(L, X, Y=Y, largest=False, maxiter=200, tol=1e-7)
|
|
83
|
+
return np.asarray(vecs)[:, 0]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fiedler_vector(
|
|
87
|
+
A: np.ndarray, previous: np.ndarray | None = None, sparse: bool = False
|
|
88
|
+
) -> np.ndarray:
|
|
89
|
+
"""Fiedler vector of the graph Laplacian ``L = D - A``.
|
|
90
|
+
|
|
91
|
+
With ``sparse=True`` an iterative single-eigenpair LOBPCG solve is used
|
|
92
|
+
(scales to large kNN graphs); otherwise a dense ``eigh``. The sign is fixed by
|
|
93
|
+
alignment with ``previous`` -- the Fiedler vector is defined up to sign, and
|
|
94
|
+
without this the order would flip arbitrarily between streaming updates.
|
|
95
|
+
"""
|
|
96
|
+
if sparse and A.shape[0] > 3:
|
|
97
|
+
try:
|
|
98
|
+
v = _fiedler_sparse(A, previous)
|
|
99
|
+
except Exception:
|
|
100
|
+
v = _fiedler_dense(A)
|
|
101
|
+
else:
|
|
102
|
+
v = _fiedler_dense(A)
|
|
103
|
+
if previous is not None and len(previous) == len(v) and float(v @ previous) < 0.0:
|
|
104
|
+
v = -v
|
|
105
|
+
return v
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def seriate(
|
|
109
|
+
cov: np.ndarray,
|
|
110
|
+
previous: np.ndarray | None = None,
|
|
111
|
+
knn: int | None = None,
|
|
112
|
+
prior: np.ndarray | None = None,
|
|
113
|
+
prior_weight: float = 0.0,
|
|
114
|
+
sparse: bool | None = None,
|
|
115
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
116
|
+
"""Return ``(order, fiedler)``: the seriation order and its Fiedler vector.
|
|
117
|
+
|
|
118
|
+
``previous`` is last period's Fiedler vector, used to keep the sign (and hence
|
|
119
|
+
the order) stable across streaming updates. ``sparse`` selects the iterative
|
|
120
|
+
LOBPCG solver; if ``None`` it defaults to sparse whenever ``knn`` is set.
|
|
121
|
+
"""
|
|
122
|
+
A = affinity(cov, knn=knn, prior=prior, prior_weight=prior_weight)
|
|
123
|
+
if sparse is None:
|
|
124
|
+
sparse = knn is not None
|
|
125
|
+
v = fiedler_vector(A, previous=previous, sparse=sparse)
|
|
126
|
+
order = np.argsort(v)
|
|
127
|
+
return order, v
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Bridge to the :mod:`thurstone` calibration maps.
|
|
2
|
+
|
|
3
|
+
These thin wrappers turn portfolio weights (treated as state prices / winning
|
|
4
|
+
probabilities) into latent Thurstone abilities and back, using the lattice
|
|
5
|
+
machinery in the ``thurstone`` package. Smaller ability == stronger competitor,
|
|
6
|
+
because the winner is the *minimum* performance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from thurstone import AbilityCalibrator, Density, UniformLattice
|
|
13
|
+
from thurstone.conventions import STD_L, STD_SCALE, STD_UNIT
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"base_density",
|
|
17
|
+
"state_price_implied_ability",
|
|
18
|
+
"ability_implied_state_prices",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def base_density(L: int = STD_L, unit: float = STD_UNIT, scale: float = STD_SCALE) -> Density:
|
|
23
|
+
"""Standard symmetric performance density on the default lattice."""
|
|
24
|
+
lat = UniformLattice(L, unit)
|
|
25
|
+
return Density.skew_normal(lat, loc=0.0, scale=scale, a=0.0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def state_price_implied_ability(
|
|
29
|
+
weights, *, base: Density | None = None, n_iter: int = 4
|
|
30
|
+
) -> np.ndarray:
|
|
31
|
+
"""Invert weights (as winning probabilities) to abilities, up to a constant."""
|
|
32
|
+
base = base if base is not None else base_density()
|
|
33
|
+
cal = AbilityCalibrator(base=base, n_iter=n_iter)
|
|
34
|
+
a = cal.solve_from_prices([float(w) for w in weights])
|
|
35
|
+
return np.asarray(a, dtype=float)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ability_implied_state_prices(ability, *, base: Density | None = None) -> np.ndarray:
|
|
39
|
+
"""Forward map: abilities to winning probabilities (the field's state prices)."""
|
|
40
|
+
base = base if base is not None else base_density()
|
|
41
|
+
cal = AbilityCalibrator(base=base)
|
|
42
|
+
p = cal.state_prices_from_ability([float(a) for a in ability])
|
|
43
|
+
p = np.asarray(p, dtype=float)
|
|
44
|
+
s = p.sum()
|
|
45
|
+
return p / s if s > 0 else p
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Calibration: back out Thurstone abilities that reproduce target weights.
|
|
2
|
+
|
|
3
|
+
Two engines, selected by the structure of the reference correlation ``C_calib``:
|
|
4
|
+
|
|
5
|
+
* **diagonal** (independent field) -- the exact lattice inverse from
|
|
6
|
+
:mod:`thurstone`. Cheap; this is flavour (i).
|
|
7
|
+
* **one-factor** -- a single common factor with per-asset loadings ``betas``.
|
|
8
|
+
Conditional on the factor the assets are independent, so the race is evaluated
|
|
9
|
+
by Gauss--Hermite quadrature over the factor (``winprobs_one_factor``); the
|
|
10
|
+
inverse is a damped fixed-point on that forward map. This is flavour (ii), and
|
|
11
|
+
the quadrature is exactly the calibration tool.
|
|
12
|
+
|
|
13
|
+
Convention throughout: the *minimum* performance wins, so a **smaller** ability
|
|
14
|
+
means a **stronger** competitor (higher winning probability).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from thurstone import Density, Race
|
|
21
|
+
|
|
22
|
+
from .ability import base_density, state_price_implied_ability
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"winprobs_one_factor",
|
|
26
|
+
"calibrate_diagonal",
|
|
27
|
+
"calibrate_one_factor",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize(w: np.ndarray) -> np.ndarray:
|
|
32
|
+
w = np.asarray(w, dtype=float)
|
|
33
|
+
s = w.sum()
|
|
34
|
+
return w / s if s > 0 else np.full(len(w), 1.0 / len(w))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def winprobs_one_factor(
|
|
38
|
+
ability, betas, *, base: Density | None = None, n_quad: int = 16
|
|
39
|
+
) -> np.ndarray:
|
|
40
|
+
"""Winning probabilities under a one-factor race, by quadrature.
|
|
41
|
+
|
|
42
|
+
Model: ``X_i = a_i + b_i Z + sqrt(1 - b_i^2) eps_i`` with ``Z ~ N(0,1)`` the
|
|
43
|
+
common factor and ``eps_i`` independent. Conditional on ``Z = z`` the field
|
|
44
|
+
is independent, so the exact lattice race applies; we integrate over ``z``
|
|
45
|
+
with Gauss--Hermite (probabilists') quadrature.
|
|
46
|
+
"""
|
|
47
|
+
base = base if base is not None else base_density()
|
|
48
|
+
lat = base.lattice
|
|
49
|
+
a = np.asarray(ability, dtype=float)
|
|
50
|
+
b = np.clip(np.asarray(betas, dtype=float), -0.999, 0.999)
|
|
51
|
+
s = np.sqrt(np.clip(1.0 - b ** 2, 1e-6, 1.0))
|
|
52
|
+
|
|
53
|
+
nodes, qw = np.polynomial.hermite_e.hermegauss(n_quad)
|
|
54
|
+
qw = qw / np.sqrt(2.0 * np.pi) # so weights sum to 1
|
|
55
|
+
|
|
56
|
+
n = len(a)
|
|
57
|
+
acc = np.zeros(n, dtype=float)
|
|
58
|
+
for z, w in zip(nodes, qw):
|
|
59
|
+
densities = [
|
|
60
|
+
Density.skew_normal(lat, loc=float(a[i] + b[i] * z), scale=float(s[i]), a=0.0)
|
|
61
|
+
for i in range(n)
|
|
62
|
+
]
|
|
63
|
+
acc += w * np.asarray(Race(densities).state_prices(), dtype=float)
|
|
64
|
+
return _normalize(np.clip(acc, 0.0, None))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def calibrate_diagonal(target, *, base: Density | None = None, n_iter: int = 4) -> np.ndarray:
|
|
68
|
+
"""Abilities reproducing ``target`` under an independent field (flavour i).
|
|
69
|
+
|
|
70
|
+
Exact lattice inverse via the :mod:`thurstone` calibrator.
|
|
71
|
+
"""
|
|
72
|
+
return state_price_implied_ability(_normalize(target), base=base, n_iter=n_iter)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def calibrate_one_factor(
|
|
76
|
+
target,
|
|
77
|
+
betas,
|
|
78
|
+
*,
|
|
79
|
+
base: Density | None = None,
|
|
80
|
+
n_quad: int = 16,
|
|
81
|
+
n_iter: int = 60,
|
|
82
|
+
step: float = 0.5,
|
|
83
|
+
tol: float = 1e-4,
|
|
84
|
+
) -> np.ndarray:
|
|
85
|
+
"""Abilities reproducing ``target`` under a one-factor race (flavour ii).
|
|
86
|
+
|
|
87
|
+
Damped fixed-point on the quadrature forward map. Because winning
|
|
88
|
+
probability is monotone *decreasing* in ability (min wins), we nudge
|
|
89
|
+
``a_i`` up when the model over-prices asset ``i`` and down when it
|
|
90
|
+
under-prices it, on a log scale, re-centering each step (abilities are only
|
|
91
|
+
identified up to a constant).
|
|
92
|
+
"""
|
|
93
|
+
target = _normalize(target)
|
|
94
|
+
base = base if base is not None else base_density()
|
|
95
|
+
log_t = np.log(np.clip(target, 1e-12, None))
|
|
96
|
+
|
|
97
|
+
a = calibrate_diagonal(target, base=base) # warm start (independent inverse)
|
|
98
|
+
for _ in range(n_iter):
|
|
99
|
+
p = winprobs_one_factor(a, betas, base=base, n_quad=n_quad)
|
|
100
|
+
if np.max(np.abs(p - target)) < tol:
|
|
101
|
+
break
|
|
102
|
+
log_p = np.log(np.clip(p, 1e-12, None))
|
|
103
|
+
a = a + step * (log_p - log_t) # p decreasing in a -> this is a descent step
|
|
104
|
+
a = a - np.median(a)
|
|
105
|
+
return a
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Covariance / correlation utilities for the ability tilt.
|
|
2
|
+
|
|
3
|
+
Nothing here knows about Thurstone; these are the matrix-hygiene helpers the
|
|
4
|
+
calibration and tilt steps need: turn a covariance estimate into a valid
|
|
5
|
+
correlation matrix, scale correlation, extract one-factor (market) loadings, and
|
|
6
|
+
reconstruct the implied one-factor correlation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"cov_to_corr",
|
|
15
|
+
"scale_off_diagonal",
|
|
16
|
+
"nearest_correlation",
|
|
17
|
+
"market_betas",
|
|
18
|
+
"one_factor_corr",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cov_to_corr(cov: np.ndarray) -> np.ndarray:
|
|
23
|
+
"""Correlation matrix from a covariance matrix."""
|
|
24
|
+
cov = np.asarray(cov, dtype=float)
|
|
25
|
+
d = np.sqrt(np.clip(np.diag(cov), 1e-300, None))
|
|
26
|
+
corr = cov / np.outer(d, d)
|
|
27
|
+
np.fill_diagonal(corr, 1.0)
|
|
28
|
+
return np.clip(corr, -1.0, 1.0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def scale_off_diagonal(corr: np.ndarray, phi: float) -> np.ndarray:
|
|
32
|
+
"""Scale every off-diagonal correlation by ``phi`` (0 -> identity)."""
|
|
33
|
+
c = np.array(corr, dtype=float, copy=True)
|
|
34
|
+
off = ~np.eye(c.shape[0], dtype=bool)
|
|
35
|
+
c[off] *= phi
|
|
36
|
+
return c
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def nearest_correlation(corr: np.ndarray, eps: float = 1e-8) -> np.ndarray:
|
|
40
|
+
"""Project a symmetric matrix to the nearest valid correlation matrix.
|
|
41
|
+
|
|
42
|
+
Eigenvalue clipping plus diagonal renormalisation: guarantees
|
|
43
|
+
positive-definiteness and unit diagonal without requiring the input to be
|
|
44
|
+
well-conditioned. Used before sampling.
|
|
45
|
+
"""
|
|
46
|
+
c = np.asarray(corr, dtype=float)
|
|
47
|
+
c = 0.5 * (c + c.T)
|
|
48
|
+
vals, vecs = np.linalg.eigh(c)
|
|
49
|
+
vals = np.clip(vals, eps, None)
|
|
50
|
+
c2 = (vecs * vals) @ vecs.T
|
|
51
|
+
d = np.sqrt(np.clip(np.diag(c2), 1e-300, None))
|
|
52
|
+
c2 = c2 / np.outer(d, d)
|
|
53
|
+
np.fill_diagonal(c2, 1.0)
|
|
54
|
+
return np.clip(c2, -1.0, 1.0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def market_betas(
|
|
58
|
+
cov: np.ndarray, weights: np.ndarray | None = None, b_max: float = 0.99
|
|
59
|
+
) -> np.ndarray:
|
|
60
|
+
"""One-factor loadings: each asset's correlation with the market portfolio.
|
|
61
|
+
|
|
62
|
+
The "market" is the ``weights``-weighted portfolio of the assets (the index
|
|
63
|
+
we calibrate to, by default). The loading ``b_i = corr(asset_i, market)`` is
|
|
64
|
+
the natural single-factor coefficient: under ``X_i = a_i + b_i Z + ...`` the
|
|
65
|
+
common factor ``Z`` is the (standardised) market. Returns values in
|
|
66
|
+
``[-b_max, b_max]``.
|
|
67
|
+
"""
|
|
68
|
+
cov = np.asarray(cov, dtype=float)
|
|
69
|
+
n = cov.shape[0]
|
|
70
|
+
w = np.full(n, 1.0 / n) if weights is None else np.asarray(weights, dtype=float)
|
|
71
|
+
w = w / w.sum()
|
|
72
|
+
sig = np.sqrt(np.clip(np.diag(cov), 1e-300, None))
|
|
73
|
+
cov_im = cov @ w # covariance of each asset with the market portfolio
|
|
74
|
+
var_m = float(w @ cov @ w)
|
|
75
|
+
sig_m = np.sqrt(max(var_m, 1e-300))
|
|
76
|
+
b = cov_im / (sig * sig_m)
|
|
77
|
+
return np.clip(b, -b_max, b_max)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def one_factor_corr(betas: np.ndarray) -> np.ndarray:
|
|
81
|
+
"""Correlation implied by a single common factor with the given loadings.
|
|
82
|
+
|
|
83
|
+
``C_ij = b_i b_j`` off-diagonal, ``1`` on the diagonal. This is the reference
|
|
84
|
+
correlation ``C_calib`` used by the one-factor calibration; the tilt then
|
|
85
|
+
re-races under the *actual* correlation, so the deviation from the target is
|
|
86
|
+
driven by how far the real correlation departs from this one-factor model.
|
|
87
|
+
"""
|
|
88
|
+
b = np.asarray(betas, dtype=float)
|
|
89
|
+
c = np.outer(b, b)
|
|
90
|
+
np.fill_diagonal(c, 1.0)
|
|
91
|
+
return np.clip(c, -1.0, 1.0)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The baseline (variance-only) portfolio the ability tilt starts from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
__all__ = ["diagonal_portfolio"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def diagonal_portfolio(cov: np.ndarray) -> np.ndarray:
|
|
11
|
+
"""Long-only inverse-variance weights.
|
|
12
|
+
|
|
13
|
+
This is the minimum-variance portfolio when correlations are ignored: each
|
|
14
|
+
asset is weighted by the reciprocal of its variance, normalised to sum to
|
|
15
|
+
one. It is the starting point the tilt then ``polishes`` by reintroducing
|
|
16
|
+
correlation through a Thurstonian race.
|
|
17
|
+
"""
|
|
18
|
+
cov = np.asarray(cov, dtype=float)
|
|
19
|
+
var = np.diag(cov).astype(float)
|
|
20
|
+
inv = np.where(var > 0, 1.0 / np.where(var > 0, var, 1.0), 0.0)
|
|
21
|
+
total = inv.sum()
|
|
22
|
+
if total <= 0:
|
|
23
|
+
# Degenerate covariance: fall back to equal weight.
|
|
24
|
+
n = cov.shape[0]
|
|
25
|
+
return np.full(n, 1.0 / n)
|
|
26
|
+
return inv / total
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Common-seed transport: realize the correlated race with fixed seeds.
|
|
2
|
+
|
|
3
|
+
The portfolio weights are the win frequencies of ``N(ability, C_tilt)``. We
|
|
4
|
+
evaluate them by Monte Carlo with a *fixed* ensemble of standard-normal seeds and
|
|
5
|
+
the symmetric square root of the correlation, so that as the correlation drifts
|
|
6
|
+
the weights move smoothly (low turnover): see the smoothness theorem in the
|
|
7
|
+
accompanying paper.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .covariance import cov_to_corr, nearest_correlation
|
|
15
|
+
|
|
16
|
+
__all__ = ["symmetric_sqrt", "blend_correlation", "transport_weights"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def symmetric_sqrt(C: np.ndarray) -> np.ndarray:
|
|
20
|
+
"""Symmetric (PCA) square root of a correlation matrix.
|
|
21
|
+
|
|
22
|
+
Smoother in ``C`` than a Cholesky factor (continuous except at eigenvalue
|
|
23
|
+
crossings), which is what keeps the recoloured paths -- and hence the
|
|
24
|
+
weights -- smooth as ``C`` drifts.
|
|
25
|
+
"""
|
|
26
|
+
vals, vecs = np.linalg.eigh(C)
|
|
27
|
+
return (vecs * np.sqrt(np.clip(vals, 1e-12, None))) @ vecs.T
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def blend_correlation(C_calib: np.ndarray, cov: np.ndarray, phi: float) -> np.ndarray:
|
|
31
|
+
"""Tilt correlation ``C_tilt = nearest_corr((1-phi) C_calib + phi corr(cov))``.
|
|
32
|
+
|
|
33
|
+
``phi = 0`` recovers the reference (and so reproduces the target); ``phi = 1``
|
|
34
|
+
uses the full estimated correlation.
|
|
35
|
+
"""
|
|
36
|
+
Ct = (1.0 - phi) * np.asarray(C_calib, dtype=float) + phi * cov_to_corr(cov)
|
|
37
|
+
return nearest_correlation(Ct)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def transport_weights(ability: np.ndarray, C_tilt: np.ndarray, seeds: np.ndarray) -> np.ndarray:
|
|
41
|
+
"""Win frequencies of ``N(ability, C_tilt)`` using the fixed ``seeds``.
|
|
42
|
+
|
|
43
|
+
``seeds`` has shape ``(M, n)``; the same seeds across calls is what makes the
|
|
44
|
+
weights move smoothly with ``C_tilt`` (turnover tracks correlation change,
|
|
45
|
+
not sampling noise). The minimum performance wins.
|
|
46
|
+
"""
|
|
47
|
+
a = np.asarray(ability, dtype=float)
|
|
48
|
+
S = symmetric_sqrt(C_tilt)
|
|
49
|
+
X = a + seeds @ S # rows ~ N(a, C_tilt)
|
|
50
|
+
winners = np.argmin(X, axis=1)
|
|
51
|
+
counts = np.bincount(winners, minlength=len(a)).astype(float)
|
|
52
|
+
total = counts.sum()
|
|
53
|
+
return counts / total if total > 0 else np.full(len(a), 1.0 / len(a))
|