allocation 0.0.1__tar.gz
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-0.0.1/LICENSE +21 -0
- allocation-0.0.1/PKG-INFO +89 -0
- allocation-0.0.1/README.md +68 -0
- allocation-0.0.1/allocation/__init__.py +30 -0
- allocation-0.0.1/allocation/_schur/__init__.py +0 -0
- allocation-0.0.1/allocation/_schur/coupling.py +199 -0
- allocation-0.0.1/allocation/_schur/seriation.py +127 -0
- allocation-0.0.1/allocation/_thurstone/__init__.py +0 -0
- allocation-0.0.1/allocation/_thurstone/ability.py +45 -0
- allocation-0.0.1/allocation/_thurstone/calibrate.py +105 -0
- allocation-0.0.1/allocation/_thurstone/covariance.py +91 -0
- allocation-0.0.1/allocation/_thurstone/diagonal.py +26 -0
- allocation-0.0.1/allocation/_thurstone/transport.py +53 -0
- allocation-0.0.1/allocation/base.py +141 -0
- allocation-0.0.1/allocation/keyed.py +223 -0
- allocation-0.0.1/allocation/moments.py +62 -0
- allocation-0.0.1/allocation/schur.py +101 -0
- allocation-0.0.1/allocation/thurstone.py +142 -0
- allocation-0.0.1/allocation/universe.py +22 -0
- allocation-0.0.1/allocation.egg-info/PKG-INFO +89 -0
- allocation-0.0.1/allocation.egg-info/SOURCES.txt +29 -0
- allocation-0.0.1/allocation.egg-info/dependency_links.txt +1 -0
- allocation-0.0.1/allocation.egg-info/requires.txt +12 -0
- allocation-0.0.1/allocation.egg-info/top_level.txt +1 -0
- allocation-0.0.1/pyproject.toml +32 -0
- allocation-0.0.1/setup.cfg +4 -0
- allocation-0.0.1/tests/test_schur.py +112 -0
- allocation-0.0.1/tests/test_schur_vs_skfolio.py +61 -0
- allocation-0.0.1/tests/test_skfolio.py +25 -0
- allocation-0.0.1/tests/test_streaming.py +70 -0
- allocation-0.0.1/tests/test_thurstone.py +56 -0
allocation-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter Cotton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: allocation
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Online (streaming) portfolio construction: scikit-learn / skfolio-compatible estimators over a keyed dynamic universe
|
|
5
|
+
Author-email: Peter Cotton <peter.cotton@microprediction.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/microprediction/allocation
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy>=1.23
|
|
12
|
+
Requires-Dist: thurstone>=0.1.0
|
|
13
|
+
Provides-Extra: scale
|
|
14
|
+
Requires-Dist: scipy>=1.10; extra == "scale"
|
|
15
|
+
Provides-Extra: skfolio
|
|
16
|
+
Requires-Dist: skfolio>=0.5; extra == "skfolio"
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
19
|
+
Requires-Dist: scipy>=1.10; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# allocation
|
|
23
|
+
|
|
24
|
+
**Online (streaming) portfolio construction.** A home for portfolio methods as
|
|
25
|
+
scikit-learn / [skfolio](https://skfolio.org)-compatible estimators
|
|
26
|
+
(`fit` / `partial_fit` / `predict` / `weights_`), on top of a keyed
|
|
27
|
+
dynamic-universe layer so they survive reconstituting universes.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from allocation import ThurstonePortfolio
|
|
31
|
+
|
|
32
|
+
est = ThurstonePortfolio(calib="market", phi=1.0)
|
|
33
|
+
est.fit(returns) # returns: (n_obs, n_assets)
|
|
34
|
+
est.weights_ # long-only weights on the simplex
|
|
35
|
+
|
|
36
|
+
for batch in stream: # streaming / rebalancing
|
|
37
|
+
est.partial_fit(batch) # transports a fixed seed ensemble -> low turnover
|
|
38
|
+
w = est.weights_
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`score(X)` returns the portfolio's Sharpe ratio (skfolio's scoring convention),
|
|
42
|
+
and `to_portfolio(X)` wraps the fitted weights as a skfolio `Portfolio`
|
|
43
|
+
(`pip install allocation[skfolio]`) so the output drops into skfolio's metrics,
|
|
44
|
+
`Population`, and cross-validation.
|
|
45
|
+
|
|
46
|
+
## Why a separate package
|
|
47
|
+
|
|
48
|
+
skfolio is the scikit-learn-native portfolio library, but it is *batch*: a
|
|
49
|
+
changing universe is handled by NaN/incomplete-data mechanics, not by carrying
|
|
50
|
+
per-asset state across streaming updates. `allocation` is the **online**
|
|
51
|
+
complement — estimators with `partial_fit`, a keyed dynamic universe (in the
|
|
52
|
+
spirit of [`precise`](https://github.com/microprediction/precise)), and turnover
|
|
53
|
+
control by construction — while staying API-compatible so each estimator can also
|
|
54
|
+
be contributed upstream to skfolio.
|
|
55
|
+
|
|
56
|
+
## Methods
|
|
57
|
+
|
|
58
|
+
| Estimator | Status | Notes |
|
|
59
|
+
|-----------|--------|-------|
|
|
60
|
+
| `ThurstonePortfolio` | working | Ability tilt: weights are winning probabilities of a correlated race; calibrate to a benchmark under a reference correlation, tilt under the estimate; smooth common-seed transport for `partial_fit`. Built on [`thurstone`](https://github.com/microprediction/thurstone). |
|
|
61
|
+
| `SchurComplementary` | planned | Online version of the Schur-complementary construction (the batch version is in skfolio). |
|
|
62
|
+
| baselines | planned | Online equal / cap / minimum-variance / risk-parity for benchmarking. |
|
|
63
|
+
|
|
64
|
+
## Design
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
allocation/
|
|
68
|
+
base.py # BaseOnlinePortfolio: fit / partial_fit / predict / weights_
|
|
69
|
+
moments.py # EwmaCovariance (default); any partial_fit/covariance_ estimator plugs in
|
|
70
|
+
universe.py # keyed dynamic-universe state (planned)
|
|
71
|
+
thurstone.py # ThurstonePortfolio
|
|
72
|
+
_thurstone/ # calibration + transport engine
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Covariance is pluggable: the default is a light EWMA, but any online estimator
|
|
76
|
+
exposing `partial_fit` and `covariance_` (e.g. a `precise` skater) can be passed
|
|
77
|
+
via `covariance=`.
|
|
78
|
+
|
|
79
|
+
## Status
|
|
80
|
+
|
|
81
|
+
Early. The Thurstone estimator and the streaming transport work and are tested;
|
|
82
|
+
the keyed dynamic-universe layer and the Schur estimator are next. The theory
|
|
83
|
+
behind the Thurstone method (feasibility, redundancy consistency, the implied
|
|
84
|
+
regularized objective, smoothness/turnover) is written up in the accompanying
|
|
85
|
+
paper.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT © Peter Cotton
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# allocation
|
|
2
|
+
|
|
3
|
+
**Online (streaming) portfolio construction.** A home for portfolio methods as
|
|
4
|
+
scikit-learn / [skfolio](https://skfolio.org)-compatible estimators
|
|
5
|
+
(`fit` / `partial_fit` / `predict` / `weights_`), on top of a keyed
|
|
6
|
+
dynamic-universe layer so they survive reconstituting universes.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from allocation import ThurstonePortfolio
|
|
10
|
+
|
|
11
|
+
est = ThurstonePortfolio(calib="market", phi=1.0)
|
|
12
|
+
est.fit(returns) # returns: (n_obs, n_assets)
|
|
13
|
+
est.weights_ # long-only weights on the simplex
|
|
14
|
+
|
|
15
|
+
for batch in stream: # streaming / rebalancing
|
|
16
|
+
est.partial_fit(batch) # transports a fixed seed ensemble -> low turnover
|
|
17
|
+
w = est.weights_
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`score(X)` returns the portfolio's Sharpe ratio (skfolio's scoring convention),
|
|
21
|
+
and `to_portfolio(X)` wraps the fitted weights as a skfolio `Portfolio`
|
|
22
|
+
(`pip install allocation[skfolio]`) so the output drops into skfolio's metrics,
|
|
23
|
+
`Population`, and cross-validation.
|
|
24
|
+
|
|
25
|
+
## Why a separate package
|
|
26
|
+
|
|
27
|
+
skfolio is the scikit-learn-native portfolio library, but it is *batch*: a
|
|
28
|
+
changing universe is handled by NaN/incomplete-data mechanics, not by carrying
|
|
29
|
+
per-asset state across streaming updates. `allocation` is the **online**
|
|
30
|
+
complement — estimators with `partial_fit`, a keyed dynamic universe (in the
|
|
31
|
+
spirit of [`precise`](https://github.com/microprediction/precise)), and turnover
|
|
32
|
+
control by construction — while staying API-compatible so each estimator can also
|
|
33
|
+
be contributed upstream to skfolio.
|
|
34
|
+
|
|
35
|
+
## Methods
|
|
36
|
+
|
|
37
|
+
| Estimator | Status | Notes |
|
|
38
|
+
|-----------|--------|-------|
|
|
39
|
+
| `ThurstonePortfolio` | working | Ability tilt: weights are winning probabilities of a correlated race; calibrate to a benchmark under a reference correlation, tilt under the estimate; smooth common-seed transport for `partial_fit`. Built on [`thurstone`](https://github.com/microprediction/thurstone). |
|
|
40
|
+
| `SchurComplementary` | planned | Online version of the Schur-complementary construction (the batch version is in skfolio). |
|
|
41
|
+
| baselines | planned | Online equal / cap / minimum-variance / risk-parity for benchmarking. |
|
|
42
|
+
|
|
43
|
+
## Design
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
allocation/
|
|
47
|
+
base.py # BaseOnlinePortfolio: fit / partial_fit / predict / weights_
|
|
48
|
+
moments.py # EwmaCovariance (default); any partial_fit/covariance_ estimator plugs in
|
|
49
|
+
universe.py # keyed dynamic-universe state (planned)
|
|
50
|
+
thurstone.py # ThurstonePortfolio
|
|
51
|
+
_thurstone/ # calibration + transport engine
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Covariance is pluggable: the default is a light EWMA, but any online estimator
|
|
55
|
+
exposing `partial_fit` and `covariance_` (e.g. a `precise` skater) can be passed
|
|
56
|
+
via `covariance=`.
|
|
57
|
+
|
|
58
|
+
## Status
|
|
59
|
+
|
|
60
|
+
Early. The Thurstone estimator and the streaming transport work and are tested;
|
|
61
|
+
the keyed dynamic-universe layer and the Schur estimator are next. The theory
|
|
62
|
+
behind the Thurstone method (feasibility, redundancy consistency, the implied
|
|
63
|
+
regularized objective, smoothness/turnover) is written up in the accompanying
|
|
64
|
+
paper.
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT © Peter Cotton
|
|
@@ -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
|