allocation 0.0.1__tar.gz → 0.0.2__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.
Files changed (79) hide show
  1. allocation-0.0.2/PKG-INFO +248 -0
  2. allocation-0.0.2/README.md +224 -0
  3. allocation-0.0.2/allocation/__init__.py +91 -0
  4. allocation-0.0.2/allocation/_schur/bridge.py +561 -0
  5. {allocation-0.0.1 → allocation-0.0.2}/allocation/_schur/coupling.py +60 -14
  6. {allocation-0.0.1 → allocation-0.0.2}/allocation/_schur/seriation.py +5 -3
  7. allocation-0.0.2/allocation/_thurstone/ability.py +127 -0
  8. allocation-0.0.2/allocation/_thurstone/calibrate.py +99 -0
  9. {allocation-0.0.1 → allocation-0.0.2}/allocation/_thurstone/covariance.py +49 -0
  10. allocation-0.0.2/allocation/_thurstone/diagonal.py +37 -0
  11. allocation-0.0.2/allocation/_thurstone/factor.py +63 -0
  12. allocation-0.0.2/allocation/_thurstone/transport.py +289 -0
  13. allocation-0.0.2/allocation/backtest.py +302 -0
  14. {allocation-0.0.1 → allocation-0.0.2}/allocation/base.py +14 -0
  15. allocation-0.0.2/allocation/baselines.py +153 -0
  16. allocation-0.0.2/allocation/bridge.py +249 -0
  17. allocation-0.0.2/allocation/constraints.py +342 -0
  18. allocation-0.0.2/allocation/convex.py +260 -0
  19. allocation-0.0.2/allocation/costs.py +116 -0
  20. allocation-0.0.2/allocation/diagnostics.py +80 -0
  21. allocation-0.0.2/allocation/factor.py +154 -0
  22. allocation-0.0.2/allocation/keyed.py +561 -0
  23. allocation-0.0.2/allocation/moments.py +192 -0
  24. {allocation-0.0.1 → allocation-0.0.2}/allocation/schur.py +57 -11
  25. allocation-0.0.2/allocation/thurstone.py +275 -0
  26. allocation-0.0.2/allocation/tilt.py +215 -0
  27. allocation-0.0.2/allocation.egg-info/PKG-INFO +248 -0
  28. allocation-0.0.2/allocation.egg-info/SOURCES.txt +65 -0
  29. {allocation-0.0.1 → allocation-0.0.2}/allocation.egg-info/requires.txt +4 -1
  30. {allocation-0.0.1 → allocation-0.0.2}/pyproject.toml +6 -4
  31. allocation-0.0.2/tests/test_backtest.py +101 -0
  32. allocation-0.0.2/tests/test_baselines.py +153 -0
  33. allocation-0.0.2/tests/test_bridge.py +353 -0
  34. allocation-0.0.2/tests/test_bridge_properties.py +304 -0
  35. allocation-0.0.2/tests/test_constraints.py +122 -0
  36. allocation-0.0.2/tests/test_convex.py +180 -0
  37. allocation-0.0.2/tests/test_costs.py +103 -0
  38. allocation-0.0.2/tests/test_diagnostics.py +43 -0
  39. allocation-0.0.2/tests/test_diagonal_zero_variance.py +29 -0
  40. allocation-0.0.2/tests/test_downside.py +61 -0
  41. allocation-0.0.2/tests/test_factor.py +94 -0
  42. allocation-0.0.2/tests/test_issue_49.py +56 -0
  43. allocation-0.0.2/tests/test_issue_50.py +66 -0
  44. allocation-0.0.2/tests/test_issue_51.py +72 -0
  45. allocation-0.0.2/tests/test_issue_58.py +43 -0
  46. allocation-0.0.2/tests/test_issue_60.py +70 -0
  47. allocation-0.0.2/tests/test_issue_79.py +52 -0
  48. allocation-0.0.2/tests/test_issue_85.py +78 -0
  49. allocation-0.0.2/tests/test_issue_89.py +38 -0
  50. allocation-0.0.2/tests/test_issue_91.py +77 -0
  51. {allocation-0.0.1 → allocation-0.0.2}/tests/test_schur.py +33 -0
  52. allocation-0.0.2/tests/test_thurstone_factor.py +82 -0
  53. allocation-0.0.2/tests/test_thurstone_factors.py +108 -0
  54. allocation-0.0.2/tests/test_thurstone_samplers.py +252 -0
  55. allocation-0.0.2/tests/test_tilt_public.py +168 -0
  56. allocation-0.0.2/tests/test_winning_contract.py +71 -0
  57. allocation-0.0.1/PKG-INFO +0 -89
  58. allocation-0.0.1/README.md +0 -68
  59. allocation-0.0.1/allocation/__init__.py +0 -30
  60. allocation-0.0.1/allocation/_thurstone/ability.py +0 -45
  61. allocation-0.0.1/allocation/_thurstone/calibrate.py +0 -105
  62. allocation-0.0.1/allocation/_thurstone/diagonal.py +0 -26
  63. allocation-0.0.1/allocation/_thurstone/transport.py +0 -53
  64. allocation-0.0.1/allocation/keyed.py +0 -223
  65. allocation-0.0.1/allocation/moments.py +0 -62
  66. allocation-0.0.1/allocation/thurstone.py +0 -142
  67. allocation-0.0.1/allocation.egg-info/PKG-INFO +0 -89
  68. allocation-0.0.1/allocation.egg-info/SOURCES.txt +0 -29
  69. {allocation-0.0.1 → allocation-0.0.2}/LICENSE +0 -0
  70. {allocation-0.0.1 → allocation-0.0.2}/allocation/_schur/__init__.py +0 -0
  71. {allocation-0.0.1 → allocation-0.0.2}/allocation/_thurstone/__init__.py +0 -0
  72. {allocation-0.0.1 → allocation-0.0.2}/allocation/universe.py +0 -0
  73. {allocation-0.0.1 → allocation-0.0.2}/allocation.egg-info/dependency_links.txt +0 -0
  74. {allocation-0.0.1 → allocation-0.0.2}/allocation.egg-info/top_level.txt +0 -0
  75. {allocation-0.0.1 → allocation-0.0.2}/setup.cfg +0 -0
  76. {allocation-0.0.1 → allocation-0.0.2}/tests/test_schur_vs_skfolio.py +0 -0
  77. {allocation-0.0.1 → allocation-0.0.2}/tests/test_skfolio.py +0 -0
  78. {allocation-0.0.1 → allocation-0.0.2}/tests/test_streaming.py +0 -0
  79. {allocation-0.0.1 → allocation-0.0.2}/tests/test_thurstone.py +0 -0
@@ -0,0 +1,248 @@
1
+ Metadata-Version: 2.4
2
+ Name: allocation
3
+ Version: 0.0.2
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://allocation.microprediction.org
8
+ Project-URL: Source, https://github.com/microprediction/allocation
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: numpy>=1.23
13
+ Requires-Dist: winning>=1.5.0
14
+ Provides-Extra: fast
15
+ Requires-Dist: winning[fast]>=1.5.0; extra == "fast"
16
+ Provides-Extra: scale
17
+ Requires-Dist: scipy>=1.10; extra == "scale"
18
+ Provides-Extra: skfolio
19
+ Requires-Dist: skfolio>=0.5; extra == "skfolio"
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7.0; extra == "test"
22
+ Requires-Dist: scipy>=1.10; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # allocation
26
+
27
+ **Online (streaming) portfolio construction.** A home for portfolio methods as
28
+ scikit-learn / [skfolio](https://skfolio.org)-compatible estimators
29
+ (`fit` / `partial_fit` / `predict` / `weights_`), on top of a keyed
30
+ dynamic-universe layer so they survive reconstituting universes.
31
+
32
+ 📖 **[allocation.microprediction.org](https://allocation.microprediction.org)**
33
+ &nbsp;·&nbsp; 🤖 **[SKILL.md](SKILL.md)** — when to reach for `allocation` (for LLMs / code review)
34
+
35
+ ```python
36
+ from allocation import ThurstonePortfolio
37
+
38
+ est = ThurstonePortfolio(calib="market", phi=1.0)
39
+ est.fit(returns) # returns: (n_obs, n_assets)
40
+ est.weights_ # long-only weights on the simplex
41
+
42
+ for batch in stream: # streaming / rebalancing
43
+ est.partial_fit(batch) # transports a fixed seed ensemble -> low turnover
44
+ w = est.weights_
45
+ ```
46
+
47
+ `score(X)` returns the portfolio's Sharpe ratio (skfolio's scoring convention),
48
+ and `to_portfolio(X)` wraps the fitted weights as a skfolio `Portfolio`
49
+ (`pip install allocation[skfolio]`) so the output drops into skfolio's metrics,
50
+ `Population`, and cross-validation.
51
+
52
+ ## Why a separate package
53
+
54
+ Actually Hugo (mostly) and I did move a *batch* version of schur into skfolio and maybe more things here
55
+ will be upstreamed into skfolio or river (Max Halford). Hugo made some improvements that are *not*
56
+ replicated in the streaming version of schur here, and the streaming versions of schur and HRP here also
57
+ deal with changing universes and weight stability.
58
+
59
+ To be clear
60
+ - `allocation` is the **online** complement in that sense — estimators with `partial_fit`
61
+ - It can also use a keyed dynamic universe (in the
62
+ spirit of [`precise`](https://github.com/microprediction/precise) but also river-ml) while staying API-compatible to both.
63
+
64
+ This is all MIT-licensed and anyone is welcome to take anything here.
65
+
66
+ ## Methods
67
+
68
+ | Estimator | Status | Notes |
69
+ |-----------|--------|-------|
70
+ | `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`. Calibration and racing run through [`winning`](https://github.com/microprediction/winning). |
71
+ | `SchurComplementary` | working | The **collapsed encoding** of the HRP bridge, matching skfolio to machine precision: HRP at `gamma=0`, moving *toward* min-variance as `gamma→1` when the min-variance portfolio is long-only, without reaching it (`SchurBridge` is the exact one). Over a smooth **Fiedler seriation** instead of a dendrogram, so `partial_fit` is low-turnover. |
72
+ | `SchurBridge` | working | **Bridges named by their endpoints, one engine underneath**: `hrp_to_min_variance(gamma)`, `hmv_to_min_variance`, `herc_to_min_variance(gamma, eta, n_clusters)`, `nco_to_min_variance(gamma, n_clusters)`, `inverse_variance_to_min_variance(eta)`, each exact at both ends with the default sibling conditioning; `companion='vol'`/`'mean'` lands on maximum diversification / tangency instead. See below. |
73
+ | `HierarchicalRiskParity` | working | Dynamic HRP — the `gamma=0` special case of the Schur construction (recursive-bisection risk parity over the Fiedler order); named for recognisability. |
74
+ | `RiskParity` | working | Equal-risk-contribution (ERC); interior convex solution, solved by coordinate descent **warm-started from the previous weights** so updates stay smooth. |
75
+ | `EqualWeight`, `InverseVariance` | working | Smooth baselines for benchmarking (`1/n`; `w ∝ 1/σ²`). |
76
+ | `MinimumVariance`, `MaximumDiversification` | working | Closed-form `Σ⁻¹1` / `Σ⁻¹σ` with optional `shrinkage` for conditioning. Unconstrained (signed) so they stay smooth — a long-only QP would kink. |
77
+
78
+ Each has a river-style streaming twin for a *changing* universe — `StreamingThurstone`, `StreamingSchur`, `StreamingSchurBridge`, `StreamingHRP`, `StreamingRiskParity`, `StreamingEqualWeight`, `StreamingInverseVariance`, `StreamingMinimumVariance`, `StreamingMaximumDiversification`, `StreamingMaximumDecorrelation`, `StreamingMeanVariance` — with `learn_one({id: ret})` / `predict_one() → {id: weight}`.
79
+
80
+ **On smoothness.** Each method is written so that `partial_fit` over a drifting covariance moves weights only as much as the covariance moved. It helps to see `weights = allocator(cov_estimator(data))` as a product of two factors:
81
+
82
+ - For the **closed-form linear allocators** (`MinimumVariance`, `MaximumDiversification`, `InverseVariance`) the allocator factor is already a smooth (rational) function of `Σ`, so weight-smoothness *is* covariance-smoothness — pair them with a smooth online covariance (the default EWMA, a shrinkage estimator, or a `precise` skater) and you're done. Caveats: keep `Σ` well-conditioned (use `shrinkage`, since `Σ⁻¹` swings near a vanishing eigenvalue) and avoid hard long-only QPs (they kink at the zero bound — the smooth long-only min-variance is `SchurComplementary` as `gamma→1`).
83
+ - The package's distinctive work is the **other** family, where the allocator factor itself is rough no matter how smooth `Σ` is. Three sources, three primitives: sampling noise → common-seed transport (Thurstone); combinatorial ordering → Fiedler seriation (Schur/HRP); active-set kinks → keep the solution interior (ERC is interior by construction).
84
+
85
+ ### Bridges, named by their endpoints
86
+
87
+ Schur conditioning is a technique; what matters is what it connects. Block
88
+ inversion writes the global direction `Σ⁻¹u` cluster by cluster: each cluster's
89
+ block is a minimum-variance direction on its covariance *conditional on everything
90
+ else*, with budget the inverse of that conditional variance. Every hierarchical
91
+ or clustered heuristic drops conditioning somewhere. `SchurBridge` puts it back by
92
+ degrees with two dials, `gamma` (cluster on the outside) and `eta` (asset on its
93
+ cluster mates), and a bridge earns its name only if **both ends are exact**:
94
+
95
+ ```python
96
+ from allocation import SchurBridge
97
+
98
+ SchurBridge.hrp_to_min_variance(gamma=0.5) # HRP -> min-variance, one dial
99
+ SchurBridge.hmv_to_min_variance(gamma=0.5) # hierarchical min-var (Cotton 2024) -> min-variance
100
+ SchurBridge.herc_to_min_variance(gamma=0.5, eta=0.5, n_clusters=8) # HERC -> min-variance, the square
101
+ SchurBridge.nco_to_min_variance(gamma=0.5, n_clusters=8) # NCO -> min-variance
102
+ SchurBridge.inverse_variance_to_min_variance(eta=0.5) # inverse variance -> min-variance (Stevens' path)
103
+ SchurBridge.hrp_to_min_variance(0.5, companion="vol") # ... -> maximum diversification
104
+ SchurBridge.hrp_to_min_variance(0.5, companion="mean") # ... -> tangency
105
+ est.endpoints_ # e.g. ('HRP', 'minimum variance')
106
+ ```
107
+
108
+ The HRP bridge needs a split rule that moves with the dial: the variance of the
109
+ child's `eta`-damped naive portfolio on its conditioned pair (`split='dial'`). HRP's
110
+ own naive split (`split='hrp'`, the rule inside `SchurComplementary` and skfolio)
111
+ starts at HRP but does not reach minimum variance; the min-variance split
112
+ (`split='minvar'`) reaches it but starts at hierarchical minimum variance. Which
113
+ published "Schur" is which is tabulated on the
114
+ [taxonomy page](https://schur.microprediction.org/taxonomy.html).
115
+
116
+ Two facts make the bridge the streaming choice. Everything after the partition is a
117
+ closed-form, continuous function of the covariance. And at the far end the weights
118
+ do not depend on the partition at all, so the turnover caused by a cluster
119
+ membership change shrinks to zero as the dials approach it (checked in
120
+ `experiments/bridge_churn_and_scale.py`): the dials that damp estimation noise damp
121
+ reclustering churn. The partition is the Fiedler order, which changes only when two
122
+ coordinates cross, bisected
123
+ (`n_clusters=None`) or cut into contiguous blocks (`n_clusters=k`), or a fixed label
124
+ vector (`clusters=`) such as sectors.
125
+
126
+ `conditioning` picks the path: `'siblings'` composes down the bisection tree
127
+ (exact, one half-size solve at the root), `'all'` conditions on every other asset
128
+ (exact, expensive), and `'factor'` conditions on one factor-mimicking portfolio per
129
+ other cluster, exact when cross-cluster dependence runs through one latent factor
130
+ per cluster and costing only cluster-sized solves; on a generic covariance it is
131
+ approximate at the far end, so the named constructors default to `'siblings'`. `long_only=True` caps `eta` at
132
+ each cluster's closed-form long-only frontier, and raises rather than return a
133
+ short position when the companion or outer layer would still produce one. The exact-arithmetic theory behind
134
+ the corners is in the papers at [schur.microprediction.org](https://schur.microprediction.org).
135
+
136
+ ### Very large universes (e.g. Russell 3000)
137
+
138
+ Where the covariance is rank-deficient, every inversion-based allocator (min-variance,
139
+ max-diversification, …) is *undefined* (`is_singular()` flags it; `strict=True` refuses).
140
+ The robust large-universe methods are the ones that never invert `Σ`: inverse-variance,
141
+ HRP/Schur, and the **Thurstone tilt** — benchmark-anchored, no inversion, low turnover.
142
+ `ThurstonePortfolio(factors=k)` runs the tilt with a `k`-factor (low-rank) correlation and
143
+ an `O(M·n·k)` race instead of the dense `O(M·n²)+O(n³)` one, so it scales to thousands of
144
+ names (a few seconds per rebalance at n=3000 with the default path budget; fewer paths
145
+ trade accuracy for speed).
146
+
147
+ To make *minimum-variance itself* well-posed at scale, `FactorMinimumVariance` /
148
+ `FactorMaximumDiversification` invert a factor covariance `Σ = BBᵀ + diag(ψ)` via the
149
+ **Woodbury identity** (`O(n·k²)`, condition number bounded by an idiosyncratic floor, so the
150
+ inverse always exists). Supply a factor covariance (`loadings_` / `idiosyncratic_`, e.g. an
151
+ adapted `precise.FactorCovariance`) for the `O(n·k)` path, or let it factor a dense covariance.
152
+ And `SchurComplementary(ridge=…)` regularizes the cross-block solve so `gamma>0` stays
153
+ well-posed (and self-tempers toward HRP) when blocks are rank-deficient.
154
+
155
+ ### Trading costs
156
+
157
+ `TurnoverPenalty(estimator, cost=λ)` wraps any estimator with a quadratic trading
158
+ cost (market-impact model). Minimising `‖w − w*‖² + λ‖w − w_prev‖²` gives the smooth,
159
+ budget-preserving blend `w_t = α w* + (1−α) w_prev` with `α = 1/(1+λ)`, so each step's
160
+ turnover is scaled exactly by `α`. This explicit damping composes with the implicit
161
+ turnover control (smooth target) and has a streaming twin `StreamingTurnoverPenalty`.
162
+
163
+ ### Linear constraints
164
+
165
+ `BoxConstrained(estimator, lower=, upper=, groups=, group_caps=)` imposes per-asset
166
+ bounds and (disjoint) group caps on any estimator via a **log-barrier**, not a QP.
167
+ The barrier's domain *is* the constraint set, so the result is feasible for
168
+ any `tau>0` (strictly, except for coordinates that a bound or cap pins exactly) —
169
+ feasibility never depends on tuning. Infeasible or malformed constraints raise. The operator stays C¹ (no
170
+ active-set kinks), so turnover stays low. Streaming twin: `StreamingBoxConstrained`.
171
+
172
+ ## Comparing methods
173
+
174
+ `allocation.backtest` is a numpy-only walk-forward harness: it trades each
175
+ estimator forward (form weights → earn next period → update) and tabulates
176
+ out-of-sample Sharpe, **turnover**, concentration, and Sharpe net of a
177
+ proportional cost. Turnover and cost are charged on the trades needed to return
178
+ drifted holdings to each new target.
179
+
180
+ ```python
181
+ from allocation import SchurComplementary, TurnoverPenalty, MinimumVariance
182
+ from allocation.backtest import compare, format_table, make_panel
183
+
184
+ panel = make_panel() # synthetic; or any (n_obs, n_assets) array
185
+ print(format_table(compare({
186
+ "schur": lambda: SchurComplementary(gamma=0.5),
187
+ "schur+λ": lambda: TurnoverPenalty(SchurComplementary(gamma=0.5), cost=3.0),
188
+ "minvar": lambda: MinimumVariance(shrinkage=0.1),
189
+ }, panel)))
190
+ ```
191
+
192
+ No data is shipped (to avoid bloat); `compare()` takes any array, and
193
+ `load_returns_csv(url)` can pull a raw CSV from a data repo at runtime.
194
+
195
+ For a robust, universe-independent comparison, `compare_random_subsets(...)` runs
196
+ a Monte-Carlo over random name-subsets and windows, reporting each method's mean
197
+ ± sd net Sharpe, mean turnover, and **win rate** (fraction of trials it had the
198
+ best net Sharpe) — averaging out the single-backtest dependence on which names
199
+ and period you happened to pick.
200
+
201
+ ## Design
202
+
203
+ ```
204
+ allocation/
205
+ base.py # BaseOnlinePortfolio: fit / partial_fit / predict / weights_
206
+ moments.py # EwmaCovariance (default); any partial_fit/covariance_ estimator plugs in
207
+ baselines.py # EqualWeight / InverseVariance / RiskParity (ERC)
208
+ convex.py # MinimumVariance / MaximumDiversification (closed-form, signed)
209
+ thurstone.py # ThurstonePortfolio
210
+ _thurstone/ # calibration + transport (thin over winning)
211
+ schur.py # SchurComplementary / HierarchicalRiskParity
212
+ bridge.py # SchurBridge: the pair-form engine the other allocators are corners of
213
+ _schur/ # Fiedler seriation + Schur coupling engine + the bridge (pairs, dials, knots)
214
+ keyed.py # river-style streaming twins over a changing universe
215
+ universe.py # keyed dynamic-universe state for the batch estimators (planned)
216
+ ```
217
+
218
+ Covariance is pluggable: the default is a light EWMA, but any online estimator
219
+ exposing `partial_fit` and `covariance_` (e.g. a `precise` skater) can be passed
220
+ via `covariance=`.
221
+
222
+ ## Papers
223
+
224
+ The two novel methods are written up as working papers.
225
+
226
+ - **Thurstone Portfolios: Allocation as Winning Probability** — long-only
227
+ allocation as the winning probabilities of a correlated race: free of the
228
+ duplication paradox, tail-consistent when the race is driven by a
229
+ downside-dependent simulation, with an implied regularized objective and a
230
+ smoothness/turnover bound. Draft in
231
+ [`papers/thurstone-portfolios/`](papers/thurstone-portfolios), built on
232
+ [`thurstone`](https://github.com/microprediction/thurstone).
233
+ - **Schur-complementary allocation** — robust, inversion-light allocation along a
234
+ spectral (Fiedler) seriation; background and bibliography at
235
+ [schur.microprediction.org](https://schur.microprediction.org).
236
+
237
+ ## Status
238
+
239
+ Early. The Thurstone, Schur/HRP, and risk-parity estimators all work and are
240
+ tested, batch and streaming; the keyed dynamic-universe layer for the *batch*
241
+ estimators (so `partial_fit` survives a changing asset count, as the streaming
242
+ twins already do) is next. The theory behind the Thurstone method (feasibility,
243
+ redundancy consistency, the implied regularized objective, smoothness/turnover)
244
+ is written up in the accompanying paper.
245
+
246
+ ## License
247
+
248
+ MIT © Peter Cotton
@@ -0,0 +1,224 @@
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
+ 📖 **[allocation.microprediction.org](https://allocation.microprediction.org)**
9
+ &nbsp;·&nbsp; 🤖 **[SKILL.md](SKILL.md)** — when to reach for `allocation` (for LLMs / code review)
10
+
11
+ ```python
12
+ from allocation import ThurstonePortfolio
13
+
14
+ est = ThurstonePortfolio(calib="market", phi=1.0)
15
+ est.fit(returns) # returns: (n_obs, n_assets)
16
+ est.weights_ # long-only weights on the simplex
17
+
18
+ for batch in stream: # streaming / rebalancing
19
+ est.partial_fit(batch) # transports a fixed seed ensemble -> low turnover
20
+ w = est.weights_
21
+ ```
22
+
23
+ `score(X)` returns the portfolio's Sharpe ratio (skfolio's scoring convention),
24
+ and `to_portfolio(X)` wraps the fitted weights as a skfolio `Portfolio`
25
+ (`pip install allocation[skfolio]`) so the output drops into skfolio's metrics,
26
+ `Population`, and cross-validation.
27
+
28
+ ## Why a separate package
29
+
30
+ Actually Hugo (mostly) and I did move a *batch* version of schur into skfolio and maybe more things here
31
+ will be upstreamed into skfolio or river (Max Halford). Hugo made some improvements that are *not*
32
+ replicated in the streaming version of schur here, and the streaming versions of schur and HRP here also
33
+ deal with changing universes and weight stability.
34
+
35
+ To be clear
36
+ - `allocation` is the **online** complement in that sense — estimators with `partial_fit`
37
+ - It can also use a keyed dynamic universe (in the
38
+ spirit of [`precise`](https://github.com/microprediction/precise) but also river-ml) while staying API-compatible to both.
39
+
40
+ This is all MIT-licensed and anyone is welcome to take anything here.
41
+
42
+ ## Methods
43
+
44
+ | Estimator | Status | Notes |
45
+ |-----------|--------|-------|
46
+ | `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`. Calibration and racing run through [`winning`](https://github.com/microprediction/winning). |
47
+ | `SchurComplementary` | working | The **collapsed encoding** of the HRP bridge, matching skfolio to machine precision: HRP at `gamma=0`, moving *toward* min-variance as `gamma→1` when the min-variance portfolio is long-only, without reaching it (`SchurBridge` is the exact one). Over a smooth **Fiedler seriation** instead of a dendrogram, so `partial_fit` is low-turnover. |
48
+ | `SchurBridge` | working | **Bridges named by their endpoints, one engine underneath**: `hrp_to_min_variance(gamma)`, `hmv_to_min_variance`, `herc_to_min_variance(gamma, eta, n_clusters)`, `nco_to_min_variance(gamma, n_clusters)`, `inverse_variance_to_min_variance(eta)`, each exact at both ends with the default sibling conditioning; `companion='vol'`/`'mean'` lands on maximum diversification / tangency instead. See below. |
49
+ | `HierarchicalRiskParity` | working | Dynamic HRP — the `gamma=0` special case of the Schur construction (recursive-bisection risk parity over the Fiedler order); named for recognisability. |
50
+ | `RiskParity` | working | Equal-risk-contribution (ERC); interior convex solution, solved by coordinate descent **warm-started from the previous weights** so updates stay smooth. |
51
+ | `EqualWeight`, `InverseVariance` | working | Smooth baselines for benchmarking (`1/n`; `w ∝ 1/σ²`). |
52
+ | `MinimumVariance`, `MaximumDiversification` | working | Closed-form `Σ⁻¹1` / `Σ⁻¹σ` with optional `shrinkage` for conditioning. Unconstrained (signed) so they stay smooth — a long-only QP would kink. |
53
+
54
+ Each has a river-style streaming twin for a *changing* universe — `StreamingThurstone`, `StreamingSchur`, `StreamingSchurBridge`, `StreamingHRP`, `StreamingRiskParity`, `StreamingEqualWeight`, `StreamingInverseVariance`, `StreamingMinimumVariance`, `StreamingMaximumDiversification`, `StreamingMaximumDecorrelation`, `StreamingMeanVariance` — with `learn_one({id: ret})` / `predict_one() → {id: weight}`.
55
+
56
+ **On smoothness.** Each method is written so that `partial_fit` over a drifting covariance moves weights only as much as the covariance moved. It helps to see `weights = allocator(cov_estimator(data))` as a product of two factors:
57
+
58
+ - For the **closed-form linear allocators** (`MinimumVariance`, `MaximumDiversification`, `InverseVariance`) the allocator factor is already a smooth (rational) function of `Σ`, so weight-smoothness *is* covariance-smoothness — pair them with a smooth online covariance (the default EWMA, a shrinkage estimator, or a `precise` skater) and you're done. Caveats: keep `Σ` well-conditioned (use `shrinkage`, since `Σ⁻¹` swings near a vanishing eigenvalue) and avoid hard long-only QPs (they kink at the zero bound — the smooth long-only min-variance is `SchurComplementary` as `gamma→1`).
59
+ - The package's distinctive work is the **other** family, where the allocator factor itself is rough no matter how smooth `Σ` is. Three sources, three primitives: sampling noise → common-seed transport (Thurstone); combinatorial ordering → Fiedler seriation (Schur/HRP); active-set kinks → keep the solution interior (ERC is interior by construction).
60
+
61
+ ### Bridges, named by their endpoints
62
+
63
+ Schur conditioning is a technique; what matters is what it connects. Block
64
+ inversion writes the global direction `Σ⁻¹u` cluster by cluster: each cluster's
65
+ block is a minimum-variance direction on its covariance *conditional on everything
66
+ else*, with budget the inverse of that conditional variance. Every hierarchical
67
+ or clustered heuristic drops conditioning somewhere. `SchurBridge` puts it back by
68
+ degrees with two dials, `gamma` (cluster on the outside) and `eta` (asset on its
69
+ cluster mates), and a bridge earns its name only if **both ends are exact**:
70
+
71
+ ```python
72
+ from allocation import SchurBridge
73
+
74
+ SchurBridge.hrp_to_min_variance(gamma=0.5) # HRP -> min-variance, one dial
75
+ SchurBridge.hmv_to_min_variance(gamma=0.5) # hierarchical min-var (Cotton 2024) -> min-variance
76
+ SchurBridge.herc_to_min_variance(gamma=0.5, eta=0.5, n_clusters=8) # HERC -> min-variance, the square
77
+ SchurBridge.nco_to_min_variance(gamma=0.5, n_clusters=8) # NCO -> min-variance
78
+ SchurBridge.inverse_variance_to_min_variance(eta=0.5) # inverse variance -> min-variance (Stevens' path)
79
+ SchurBridge.hrp_to_min_variance(0.5, companion="vol") # ... -> maximum diversification
80
+ SchurBridge.hrp_to_min_variance(0.5, companion="mean") # ... -> tangency
81
+ est.endpoints_ # e.g. ('HRP', 'minimum variance')
82
+ ```
83
+
84
+ The HRP bridge needs a split rule that moves with the dial: the variance of the
85
+ child's `eta`-damped naive portfolio on its conditioned pair (`split='dial'`). HRP's
86
+ own naive split (`split='hrp'`, the rule inside `SchurComplementary` and skfolio)
87
+ starts at HRP but does not reach minimum variance; the min-variance split
88
+ (`split='minvar'`) reaches it but starts at hierarchical minimum variance. Which
89
+ published "Schur" is which is tabulated on the
90
+ [taxonomy page](https://schur.microprediction.org/taxonomy.html).
91
+
92
+ Two facts make the bridge the streaming choice. Everything after the partition is a
93
+ closed-form, continuous function of the covariance. And at the far end the weights
94
+ do not depend on the partition at all, so the turnover caused by a cluster
95
+ membership change shrinks to zero as the dials approach it (checked in
96
+ `experiments/bridge_churn_and_scale.py`): the dials that damp estimation noise damp
97
+ reclustering churn. The partition is the Fiedler order, which changes only when two
98
+ coordinates cross, bisected
99
+ (`n_clusters=None`) or cut into contiguous blocks (`n_clusters=k`), or a fixed label
100
+ vector (`clusters=`) such as sectors.
101
+
102
+ `conditioning` picks the path: `'siblings'` composes down the bisection tree
103
+ (exact, one half-size solve at the root), `'all'` conditions on every other asset
104
+ (exact, expensive), and `'factor'` conditions on one factor-mimicking portfolio per
105
+ other cluster, exact when cross-cluster dependence runs through one latent factor
106
+ per cluster and costing only cluster-sized solves; on a generic covariance it is
107
+ approximate at the far end, so the named constructors default to `'siblings'`. `long_only=True` caps `eta` at
108
+ each cluster's closed-form long-only frontier, and raises rather than return a
109
+ short position when the companion or outer layer would still produce one. The exact-arithmetic theory behind
110
+ the corners is in the papers at [schur.microprediction.org](https://schur.microprediction.org).
111
+
112
+ ### Very large universes (e.g. Russell 3000)
113
+
114
+ Where the covariance is rank-deficient, every inversion-based allocator (min-variance,
115
+ max-diversification, …) is *undefined* (`is_singular()` flags it; `strict=True` refuses).
116
+ The robust large-universe methods are the ones that never invert `Σ`: inverse-variance,
117
+ HRP/Schur, and the **Thurstone tilt** — benchmark-anchored, no inversion, low turnover.
118
+ `ThurstonePortfolio(factors=k)` runs the tilt with a `k`-factor (low-rank) correlation and
119
+ an `O(M·n·k)` race instead of the dense `O(M·n²)+O(n³)` one, so it scales to thousands of
120
+ names (a few seconds per rebalance at n=3000 with the default path budget; fewer paths
121
+ trade accuracy for speed).
122
+
123
+ To make *minimum-variance itself* well-posed at scale, `FactorMinimumVariance` /
124
+ `FactorMaximumDiversification` invert a factor covariance `Σ = BBᵀ + diag(ψ)` via the
125
+ **Woodbury identity** (`O(n·k²)`, condition number bounded by an idiosyncratic floor, so the
126
+ inverse always exists). Supply a factor covariance (`loadings_` / `idiosyncratic_`, e.g. an
127
+ adapted `precise.FactorCovariance`) for the `O(n·k)` path, or let it factor a dense covariance.
128
+ And `SchurComplementary(ridge=…)` regularizes the cross-block solve so `gamma>0` stays
129
+ well-posed (and self-tempers toward HRP) when blocks are rank-deficient.
130
+
131
+ ### Trading costs
132
+
133
+ `TurnoverPenalty(estimator, cost=λ)` wraps any estimator with a quadratic trading
134
+ cost (market-impact model). Minimising `‖w − w*‖² + λ‖w − w_prev‖²` gives the smooth,
135
+ budget-preserving blend `w_t = α w* + (1−α) w_prev` with `α = 1/(1+λ)`, so each step's
136
+ turnover is scaled exactly by `α`. This explicit damping composes with the implicit
137
+ turnover control (smooth target) and has a streaming twin `StreamingTurnoverPenalty`.
138
+
139
+ ### Linear constraints
140
+
141
+ `BoxConstrained(estimator, lower=, upper=, groups=, group_caps=)` imposes per-asset
142
+ bounds and (disjoint) group caps on any estimator via a **log-barrier**, not a QP.
143
+ The barrier's domain *is* the constraint set, so the result is feasible for
144
+ any `tau>0` (strictly, except for coordinates that a bound or cap pins exactly) —
145
+ feasibility never depends on tuning. Infeasible or malformed constraints raise. The operator stays C¹ (no
146
+ active-set kinks), so turnover stays low. Streaming twin: `StreamingBoxConstrained`.
147
+
148
+ ## Comparing methods
149
+
150
+ `allocation.backtest` is a numpy-only walk-forward harness: it trades each
151
+ estimator forward (form weights → earn next period → update) and tabulates
152
+ out-of-sample Sharpe, **turnover**, concentration, and Sharpe net of a
153
+ proportional cost. Turnover and cost are charged on the trades needed to return
154
+ drifted holdings to each new target.
155
+
156
+ ```python
157
+ from allocation import SchurComplementary, TurnoverPenalty, MinimumVariance
158
+ from allocation.backtest import compare, format_table, make_panel
159
+
160
+ panel = make_panel() # synthetic; or any (n_obs, n_assets) array
161
+ print(format_table(compare({
162
+ "schur": lambda: SchurComplementary(gamma=0.5),
163
+ "schur+λ": lambda: TurnoverPenalty(SchurComplementary(gamma=0.5), cost=3.0),
164
+ "minvar": lambda: MinimumVariance(shrinkage=0.1),
165
+ }, panel)))
166
+ ```
167
+
168
+ No data is shipped (to avoid bloat); `compare()` takes any array, and
169
+ `load_returns_csv(url)` can pull a raw CSV from a data repo at runtime.
170
+
171
+ For a robust, universe-independent comparison, `compare_random_subsets(...)` runs
172
+ a Monte-Carlo over random name-subsets and windows, reporting each method's mean
173
+ ± sd net Sharpe, mean turnover, and **win rate** (fraction of trials it had the
174
+ best net Sharpe) — averaging out the single-backtest dependence on which names
175
+ and period you happened to pick.
176
+
177
+ ## Design
178
+
179
+ ```
180
+ allocation/
181
+ base.py # BaseOnlinePortfolio: fit / partial_fit / predict / weights_
182
+ moments.py # EwmaCovariance (default); any partial_fit/covariance_ estimator plugs in
183
+ baselines.py # EqualWeight / InverseVariance / RiskParity (ERC)
184
+ convex.py # MinimumVariance / MaximumDiversification (closed-form, signed)
185
+ thurstone.py # ThurstonePortfolio
186
+ _thurstone/ # calibration + transport (thin over winning)
187
+ schur.py # SchurComplementary / HierarchicalRiskParity
188
+ bridge.py # SchurBridge: the pair-form engine the other allocators are corners of
189
+ _schur/ # Fiedler seriation + Schur coupling engine + the bridge (pairs, dials, knots)
190
+ keyed.py # river-style streaming twins over a changing universe
191
+ universe.py # keyed dynamic-universe state for the batch estimators (planned)
192
+ ```
193
+
194
+ Covariance is pluggable: the default is a light EWMA, but any online estimator
195
+ exposing `partial_fit` and `covariance_` (e.g. a `precise` skater) can be passed
196
+ via `covariance=`.
197
+
198
+ ## Papers
199
+
200
+ The two novel methods are written up as working papers.
201
+
202
+ - **Thurstone Portfolios: Allocation as Winning Probability** — long-only
203
+ allocation as the winning probabilities of a correlated race: free of the
204
+ duplication paradox, tail-consistent when the race is driven by a
205
+ downside-dependent simulation, with an implied regularized objective and a
206
+ smoothness/turnover bound. Draft in
207
+ [`papers/thurstone-portfolios/`](papers/thurstone-portfolios), built on
208
+ [`thurstone`](https://github.com/microprediction/thurstone).
209
+ - **Schur-complementary allocation** — robust, inversion-light allocation along a
210
+ spectral (Fiedler) seriation; background and bibliography at
211
+ [schur.microprediction.org](https://schur.microprediction.org).
212
+
213
+ ## Status
214
+
215
+ Early. The Thurstone, Schur/HRP, and risk-parity estimators all work and are
216
+ tested, batch and streaming; the keyed dynamic-universe layer for the *batch*
217
+ estimators (so `partial_fit` survives a changing asset count, as the streaming
218
+ twins already do) is next. The theory behind the Thurstone method (feasibility,
219
+ redundancy consistency, the implied regularized objective, smoothness/turnover)
220
+ is written up in the accompanying paper.
221
+
222
+ ## License
223
+
224
+ MIT © Peter Cotton
@@ -0,0 +1,91 @@
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 .bridge import SchurBridge
14
+ from .baselines import EqualWeight, InverseVariance, RiskParity
15
+ from .constraints import BoxConstrained, StreamingBoxConstrained
16
+ from .convex import (
17
+ MaximumDecorrelation,
18
+ MaximumDiversification,
19
+ MeanVariance,
20
+ MinimumVariance,
21
+ )
22
+ from .diagnostics import negative_weight_diagnostic
23
+ from .factor import FactorMaximumDiversification, FactorMinimumVariance
24
+ from .costs import StreamingTurnoverPenalty, TurnoverPenalty
25
+ from .keyed import (
26
+ KeyedEwmaCovariance,
27
+ StreamingEqualWeight,
28
+ StreamingHRP,
29
+ StreamingInverseVariance,
30
+ StreamingMaximumDecorrelation,
31
+ StreamingMaximumDiversification,
32
+ StreamingMeanVariance,
33
+ StreamingMinimumVariance,
34
+ StreamingRiskParity,
35
+ StreamingSchur,
36
+ StreamingSchurBridge,
37
+ StreamingThurstone,
38
+ )
39
+ from .moments import DownsideSemicovariance, EwmaCovariance
40
+ from .schur import HierarchicalRiskParity, SchurComplementary
41
+ from .thurstone import ThurstonePortfolio
42
+ from .tilt import (
43
+ abilities_from_weights,
44
+ blend_correlation,
45
+ tilt_weights,
46
+ weights_from_abilities,
47
+ )
48
+
49
+ __version__ = "0.0.2"
50
+
51
+ __all__ = [
52
+ "abilities_from_weights",
53
+ "weights_from_abilities",
54
+ "tilt_weights",
55
+ "blend_correlation",
56
+ # batch / skfolio-style
57
+ "BaseOnlinePortfolio",
58
+ "ThurstonePortfolio",
59
+ "SchurComplementary",
60
+ "SchurBridge",
61
+ "HierarchicalRiskParity",
62
+ "EqualWeight",
63
+ "InverseVariance",
64
+ "RiskParity",
65
+ "MinimumVariance",
66
+ "MaximumDiversification",
67
+ "MaximumDecorrelation",
68
+ "MeanVariance",
69
+ "FactorMinimumVariance",
70
+ "FactorMaximumDiversification",
71
+ "negative_weight_diagnostic",
72
+ "TurnoverPenalty",
73
+ "BoxConstrained",
74
+ "EwmaCovariance",
75
+ "DownsideSemicovariance",
76
+ # streaming / river-style (changing universe)
77
+ "StreamingThurstone",
78
+ "StreamingSchur",
79
+ "StreamingSchurBridge",
80
+ "StreamingHRP",
81
+ "StreamingEqualWeight",
82
+ "StreamingInverseVariance",
83
+ "StreamingRiskParity",
84
+ "StreamingMinimumVariance",
85
+ "StreamingMaximumDiversification",
86
+ "StreamingMaximumDecorrelation",
87
+ "StreamingMeanVariance",
88
+ "StreamingTurnoverPenalty",
89
+ "StreamingBoxConstrained",
90
+ "KeyedEwmaCovariance",
91
+ ]