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 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))