numeraire 0.2.0__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.
- numeraire/__init__.py +118 -0
- numeraire/adapters/__init__.py +6 -0
- numeraire/adapters/skfolio_adapter.py +105 -0
- numeraire/baselines/__init__.py +39 -0
- numeraire/baselines/forecast.py +64 -0
- numeraire/baselines/weights.py +218 -0
- numeraire/comparison.py +216 -0
- numeraire/core/__init__.py +13 -0
- numeraire/core/_ingest.py +63 -0
- numeraire/core/capabilities.py +25 -0
- numeraire/core/data.py +823 -0
- numeraire/core/engine.py +610 -0
- numeraire/core/evaluators.py +348 -0
- numeraire/core/protocols.py +120 -0
- numeraire/core/registry.py +32 -0
- numeraire/core/schema.py +40 -0
- numeraire/core/simulate.py +231 -0
- numeraire/core/sorts.py +101 -0
- numeraire/core/splitter.py +113 -0
- numeraire/core/stats.py +385 -0
- numeraire/methods/__init__.py +5 -0
- numeraire/py.typed +0 -0
- numeraire/reference.py +225 -0
- numeraire/testing.py +401 -0
- numeraire-0.2.0.dist-info/METADATA +105 -0
- numeraire-0.2.0.dist-info/RECORD +29 -0
- numeraire-0.2.0.dist-info/WHEEL +4 -0
- numeraire-0.2.0.dist-info/entry_points.txt +9 -0
- numeraire-0.2.0.dist-info/licenses/LICENSE +28 -0
numeraire/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""numeraire — a research framework for empirical asset pricing.
|
|
2
|
+
|
|
3
|
+
The reference unit against which methods are measured. The spine (``numeraire.core``)
|
|
4
|
+
is method-agnostic and depended upon by everything; methods and adapters depend on it,
|
|
5
|
+
never the reverse (enforced by import-linter, see pyproject ``[tool.importlinter]``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from numeraire.core import capabilities
|
|
9
|
+
from numeraire.core.data import CrossSectionView, TimeSeriesView
|
|
10
|
+
from numeraire.core.engine import (
|
|
11
|
+
ForecastOutput,
|
|
12
|
+
PanelWeightsOutput,
|
|
13
|
+
PricingOutput,
|
|
14
|
+
WeightsOutput,
|
|
15
|
+
config_hash,
|
|
16
|
+
pricing_in_sample,
|
|
17
|
+
walk_forward,
|
|
18
|
+
walk_forward_forecast,
|
|
19
|
+
walk_forward_panel,
|
|
20
|
+
walk_forward_pricing,
|
|
21
|
+
)
|
|
22
|
+
from numeraire.core.evaluators import (
|
|
23
|
+
AlphaEvaluator,
|
|
24
|
+
AverageAbsAlphaEvaluator,
|
|
25
|
+
CEQEvaluator,
|
|
26
|
+
ClarkWestEvaluator,
|
|
27
|
+
CrossSectionalR2Evaluator,
|
|
28
|
+
MeanReturnEvaluator,
|
|
29
|
+
OOSR2Evaluator,
|
|
30
|
+
SharpeEvaluator,
|
|
31
|
+
SquaredErrorDiffEvaluator,
|
|
32
|
+
StrategyReturnEvaluator,
|
|
33
|
+
)
|
|
34
|
+
from numeraire.core.protocols import (
|
|
35
|
+
DataView,
|
|
36
|
+
Estimator,
|
|
37
|
+
Evaluator,
|
|
38
|
+
Model,
|
|
39
|
+
Splitter,
|
|
40
|
+
SupportsForecast,
|
|
41
|
+
SupportsPricing,
|
|
42
|
+
SupportsWeights,
|
|
43
|
+
)
|
|
44
|
+
from numeraire.core.registry import (
|
|
45
|
+
available_evaluators,
|
|
46
|
+
get_evaluator,
|
|
47
|
+
register_evaluator,
|
|
48
|
+
)
|
|
49
|
+
from numeraire.core.schema import RESULT_COLUMNS, validate_result
|
|
50
|
+
from numeraire.core.simulate import RebalanceSchedule, SimulationResult, simulate_weights
|
|
51
|
+
from numeraire.core.sorts import SortResult, make_sorts
|
|
52
|
+
from numeraire.core.splitter import WalkForwardSplitter, validation_split
|
|
53
|
+
from numeraire.core.stats import (
|
|
54
|
+
adjust_tests,
|
|
55
|
+
alpha_regression,
|
|
56
|
+
certainty_equivalent,
|
|
57
|
+
clark_west,
|
|
58
|
+
grs_test,
|
|
59
|
+
newey_west_lrv,
|
|
60
|
+
performance_fee,
|
|
61
|
+
return_loss,
|
|
62
|
+
sharpe_diff_test,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"RESULT_COLUMNS",
|
|
67
|
+
"AlphaEvaluator",
|
|
68
|
+
"AverageAbsAlphaEvaluator",
|
|
69
|
+
"CEQEvaluator",
|
|
70
|
+
"ClarkWestEvaluator",
|
|
71
|
+
"CrossSectionView",
|
|
72
|
+
"CrossSectionalR2Evaluator",
|
|
73
|
+
"DataView",
|
|
74
|
+
"Estimator",
|
|
75
|
+
"Evaluator",
|
|
76
|
+
"ForecastOutput",
|
|
77
|
+
"MeanReturnEvaluator",
|
|
78
|
+
"Model",
|
|
79
|
+
"OOSR2Evaluator",
|
|
80
|
+
"PanelWeightsOutput",
|
|
81
|
+
"PricingOutput",
|
|
82
|
+
"RebalanceSchedule",
|
|
83
|
+
"SharpeEvaluator",
|
|
84
|
+
"SimulationResult",
|
|
85
|
+
"SortResult",
|
|
86
|
+
"Splitter",
|
|
87
|
+
"SquaredErrorDiffEvaluator",
|
|
88
|
+
"StrategyReturnEvaluator",
|
|
89
|
+
"SupportsForecast",
|
|
90
|
+
"SupportsPricing",
|
|
91
|
+
"SupportsWeights",
|
|
92
|
+
"TimeSeriesView",
|
|
93
|
+
"WalkForwardSplitter",
|
|
94
|
+
"WeightsOutput",
|
|
95
|
+
"adjust_tests",
|
|
96
|
+
"alpha_regression",
|
|
97
|
+
"available_evaluators",
|
|
98
|
+
"capabilities",
|
|
99
|
+
"certainty_equivalent",
|
|
100
|
+
"clark_west",
|
|
101
|
+
"config_hash",
|
|
102
|
+
"get_evaluator",
|
|
103
|
+
"grs_test",
|
|
104
|
+
"make_sorts",
|
|
105
|
+
"newey_west_lrv",
|
|
106
|
+
"performance_fee",
|
|
107
|
+
"pricing_in_sample",
|
|
108
|
+
"register_evaluator",
|
|
109
|
+
"return_loss",
|
|
110
|
+
"sharpe_diff_test",
|
|
111
|
+
"simulate_weights",
|
|
112
|
+
"validate_result",
|
|
113
|
+
"validation_split",
|
|
114
|
+
"walk_forward",
|
|
115
|
+
"walk_forward_forecast",
|
|
116
|
+
"walk_forward_panel",
|
|
117
|
+
"walk_forward_pricing",
|
|
118
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""numeraire.adapters — thin wrappers making reference libraries conform to core protocols.
|
|
2
|
+
|
|
3
|
+
Glue, not spine — each adapter imports its reference library only at module top level, so installing
|
|
4
|
+
core alone never requires it. Reference-library methods (e.g. IPCA via ``ipca``) ship in extension
|
|
5
|
+
packages such as ``numeraire-zoo``, which declare those heavy dependencies themselves.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""skfolio adapter — wrap a skfolio portfolio optimizer as a numeraire ``to_weights`` estimator.
|
|
2
|
+
|
|
3
|
+
`skfolio <https://skfolio.org>`_ (BSD-3) provides mean-risk / hierarchical-risk-parity /
|
|
4
|
+
risk-budgeting optimizers as scikit-learn estimators. This adapter makes one conform to the
|
|
5
|
+
numeraire ``Estimator`` / ``Model`` protocol so it plugs into the walk-forward engine as a peer of
|
|
6
|
+
any native method — **without** adopting skfolio's own cross-validation or walk-forward machinery
|
|
7
|
+
(numeraire owns the out-of-sample loop).
|
|
8
|
+
|
|
9
|
+
The contract (why this stays leak-free):
|
|
10
|
+
|
|
11
|
+
- ``fit(view)`` fits the skfolio estimator on ``view.returns_frame()`` — the exact training window
|
|
12
|
+
the engine hands it — and stores the fitted ``weights_``.
|
|
13
|
+
- ``to_weights(view)`` **broadcasts** those fitted weights across the view's calendar. It never
|
|
14
|
+
calls ``estimator.predict(X_test)``: skfolio's ``predict`` scores a weight vector *on the returns
|
|
15
|
+
it is given*, so feeding it the test window would pour realized test returns into the position —
|
|
16
|
+
a structural look-ahead. Weights come only from ``.weights_`` (a function of the fit window).
|
|
17
|
+
|
|
18
|
+
Through ``walk_forward`` the estimator is re-fit at each origin on that origin's PIT window and the
|
|
19
|
+
resulting weights are applied to the next period, so the broadcast is per-origin and point-in-time.
|
|
20
|
+
The optional ``window`` caps the lookback to the most recent ``window`` rows of whatever the engine
|
|
21
|
+
hands ``fit`` (e.g. a rolling estimation window under an expanding split).
|
|
22
|
+
|
|
23
|
+
skfolio is an **optional** dependency (the ``[skfolio]`` extra); it is imported lazily inside
|
|
24
|
+
``fit`` so this module imports with or without it installed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
import pandas as pd
|
|
33
|
+
|
|
34
|
+
from numeraire.core import capabilities
|
|
35
|
+
from numeraire.core.data import TimeSeriesView
|
|
36
|
+
from numeraire.core.protocols import DataView
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _as_tsv(view: DataView) -> TimeSeriesView:
|
|
40
|
+
if not isinstance(view, TimeSeriesView):
|
|
41
|
+
raise TypeError("the skfolio adapter runs on a TimeSeriesView (asset returns block)")
|
|
42
|
+
return view
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _SkfolioModel:
|
|
46
|
+
"""A fitted skfolio portfolio: a single optimal weight vector, broadcast across a calendar."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, weights: pd.Series, meta: dict[str, Any]) -> None:
|
|
49
|
+
self._weights = weights # index = fitted asset labels
|
|
50
|
+
self.meta = meta
|
|
51
|
+
|
|
52
|
+
def capabilities(self) -> set[str]:
|
|
53
|
+
return {capabilities.TO_WEIGHTS}
|
|
54
|
+
|
|
55
|
+
def to_weights(self, view: DataView) -> pd.DataFrame:
|
|
56
|
+
tsv = _as_tsv(view)
|
|
57
|
+
assets = [str(a) for a in tsv.assets]
|
|
58
|
+
w = self._weights.reindex(assets).to_numpy(dtype=np.float64)
|
|
59
|
+
if np.isnan(w).any():
|
|
60
|
+
missing = [a for a, x in zip(assets, w, strict=True) if np.isnan(x)]
|
|
61
|
+
raise ValueError(f"fitted skfolio weights do not cover view assets {missing}")
|
|
62
|
+
vals = np.repeat(w[None, :], len(tsv.calendar), axis=0)
|
|
63
|
+
return pd.DataFrame(vals, index=tsv.calendar, columns=tsv.assets)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SkfolioWeights:
|
|
67
|
+
"""Adapt a skfolio optimizer to numeraire ``to_weights``.
|
|
68
|
+
|
|
69
|
+
``estimator`` is a skfolio estimator instance (e.g. ``MeanRisk()``, ``RiskBudgeting()``,
|
|
70
|
+
``HierarchicalRiskParity()``); it is cloned per fit so each origin gets a fresh optimization.
|
|
71
|
+
When ``None``, a default ``skfolio.optimization.MeanRisk`` is used. ``window`` optionally caps
|
|
72
|
+
the estimation lookback to the most recent rows of the fit view.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, estimator: Any | None = None, *, window: int | None = None) -> None:
|
|
76
|
+
self.estimator = estimator
|
|
77
|
+
self.window = window
|
|
78
|
+
|
|
79
|
+
def fit(self, view: DataView) -> _SkfolioModel:
|
|
80
|
+
import skfolio
|
|
81
|
+
from sklearn.base import clone
|
|
82
|
+
|
|
83
|
+
tsv = _as_tsv(view)
|
|
84
|
+
if self.estimator is None:
|
|
85
|
+
from skfolio.optimization import MeanRisk
|
|
86
|
+
|
|
87
|
+
est = MeanRisk()
|
|
88
|
+
else:
|
|
89
|
+
est = clone(self.estimator)
|
|
90
|
+
returns = tsv.returns_frame()
|
|
91
|
+
if self.window is not None:
|
|
92
|
+
returns = returns.tail(self.window)
|
|
93
|
+
est.fit(returns)
|
|
94
|
+
weights = pd.Series(
|
|
95
|
+
np.asarray(est.weights_, dtype=np.float64).ravel(),
|
|
96
|
+
index=[str(a) for a in tsv.assets],
|
|
97
|
+
)
|
|
98
|
+
meta = {
|
|
99
|
+
"adapter": "skfolio",
|
|
100
|
+
"skfolio_version": skfolio.__version__,
|
|
101
|
+
"estimator": type(est).__name__,
|
|
102
|
+
"solver": getattr(est, "solver", None),
|
|
103
|
+
"n_train": len(returns),
|
|
104
|
+
}
|
|
105
|
+
return _SkfolioModel(weights, meta)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Universal benchmarks bundled in ``numeraire`` — the reference rules every method is compared to.
|
|
2
|
+
|
|
3
|
+
Four estimators, all framework citizens (they pass ``numeraire.testing.check_estimator``) and all
|
|
4
|
+
registered via the ``numeraire.methods`` entry-point group (dogfooding the open discovery):
|
|
5
|
+
|
|
6
|
+
- :class:`EqualWeight` — 1/N ``to_weights`` (the naive benchmark).
|
|
7
|
+
- :class:`MinVariance` — global minimum-variance ``to_weights`` (sample covariance + window).
|
|
8
|
+
- :class:`MeanVariance` — plug-in mean-variance ``to_weights`` (``S^-1 mu``, explicit norm).
|
|
9
|
+
- :class:`HistoricalMean` — prevailing-historical-mean ``to_forecast`` (Goyal-Welch benchmark).
|
|
10
|
+
|
|
11
|
+
The pure weight functions (:func:`equal_weights`, :func:`minimum_variance_weights`,
|
|
12
|
+
:func:`mean_variance_weights`) are the single source of truth for these formulae — method packages
|
|
13
|
+
(e.g. the naive-diversification reproduction) build on them rather than re-deriving the algebra.
|
|
14
|
+
|
|
15
|
+
Serious constrained optimizers are **not** re-implemented here; they arrive through the optional
|
|
16
|
+
skfolio adapter (``numeraire.adapters``). These baselines are the always-available floor.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from numeraire.baselines.forecast import HistoricalMean
|
|
22
|
+
from numeraire.baselines.weights import (
|
|
23
|
+
EqualWeight,
|
|
24
|
+
MeanVariance,
|
|
25
|
+
MinVariance,
|
|
26
|
+
equal_weights,
|
|
27
|
+
mean_variance_weights,
|
|
28
|
+
minimum_variance_weights,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"EqualWeight",
|
|
33
|
+
"HistoricalMean",
|
|
34
|
+
"MeanVariance",
|
|
35
|
+
"MinVariance",
|
|
36
|
+
"equal_weights",
|
|
37
|
+
"mean_variance_weights",
|
|
38
|
+
"minimum_variance_weights",
|
|
39
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The historical-mean forecast baseline — the Goyal-Welch OOS reference.
|
|
2
|
+
|
|
3
|
+
The prevailing (expanding) historical mean of the returns block is the benchmark every predictive
|
|
4
|
+
regression is scored against: Goyal-Welch (2008) show it is a stubbornly hard forecast to beat out
|
|
5
|
+
of sample, and the OOS R^2 in :class:`~numeraire.core.evaluators.OOSR2Evaluator` measures MSE
|
|
6
|
+
improvement *relative to it*. The walk-forward forecast engine already computes exactly this
|
|
7
|
+
benchmark column for free at each origin (``train.returns_frame().mean(axis=0)``); this estimator
|
|
8
|
+
exposes the very same quantity as a first-class ``to_forecast`` citizen, so it can be compared,
|
|
9
|
+
registered and run through the engine like any other method (its OOS R^2 against the engine
|
|
10
|
+
benchmark is ~0 by construction — it *is* the benchmark).
|
|
11
|
+
|
|
12
|
+
``window`` gives the rolling variant (last ``k`` observations); ``None`` (default) is the expanding
|
|
13
|
+
prevailing mean, matching the engine's benchmark convention.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from numeraire.core import capabilities
|
|
21
|
+
from numeraire.core.data import TimeSeriesView
|
|
22
|
+
from numeraire.core.protocols import DataView
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _as_tsv(view: DataView) -> TimeSeriesView:
|
|
26
|
+
if not isinstance(view, TimeSeriesView):
|
|
27
|
+
raise TypeError("HistoricalMean requires a TimeSeriesView (asset-returns block)")
|
|
28
|
+
return view
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _HistoricalMeanModel:
|
|
32
|
+
"""Fitted historical-mean model: forecasts the per-asset sample mean over the fit window."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, window: int | None) -> None:
|
|
35
|
+
self._window = window
|
|
36
|
+
|
|
37
|
+
def capabilities(self) -> set[str]:
|
|
38
|
+
return {capabilities.TO_FORECAST}
|
|
39
|
+
|
|
40
|
+
def forecast(self, view: DataView) -> pd.Series:
|
|
41
|
+
rets = _as_tsv(view).returns_frame()
|
|
42
|
+
if self._window is not None:
|
|
43
|
+
rets = rets.tail(self._window)
|
|
44
|
+
return rets.mean() # per-asset prevailing mean; index = view.assets
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HistoricalMean:
|
|
48
|
+
"""The prevailing-historical-mean forecaster (Goyal-Welch benchmark).
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
window:
|
|
53
|
+
Trailing window (in calendar steps) for the mean; ``None`` (default) is the expanding
|
|
54
|
+
prevailing mean — the same quantity the walk-forward engine uses as its OOS R^2 benchmark.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, window: int | None = None) -> None:
|
|
58
|
+
if window is not None and window < 1:
|
|
59
|
+
raise ValueError("window must be >= 1")
|
|
60
|
+
self.window = window
|
|
61
|
+
|
|
62
|
+
def fit(self, view: DataView) -> _HistoricalMeanModel:
|
|
63
|
+
_as_tsv(view)
|
|
64
|
+
return _HistoricalMeanModel(self.window)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Universal weight baselines: the 1/N, minimum-variance and mean-variance rules.
|
|
2
|
+
|
|
3
|
+
These three are the reference points every ``to_weights`` method is measured against — the naive
|
|
4
|
+
benchmark (1/N) plus the two textbook sample-plug-in optimizers. They are bundled in ``numeraire``
|
|
5
|
+
itself (not a method package) because they are method-agnostic and every comparison needs them.
|
|
6
|
+
|
|
7
|
+
Weight functions (the single source of truth; extensions build on these rather than re-deriving):
|
|
8
|
+
|
|
9
|
+
- :func:`equal_weights` — ``w = 1/N`` (needs no estimation; the naive benchmark).
|
|
10
|
+
- :func:`minimum_variance_weights` — global minimum-variance ``w = S^-1 1 / (1' S^-1 1)`` (the
|
|
11
|
+
first-order condition of ``min w'Sw`` s.t. ``1'w = 1``; always well defined for invertible ``S``).
|
|
12
|
+
- :func:`mean_variance_weights` — the plug-in tangency direction ``w ∝ S^-1 mu``, with the
|
|
13
|
+
normalization made **explicit** (``"budget"`` divides by ``1' S^-1 mu`` so weights sum to one;
|
|
14
|
+
``"none"`` leaves the raw proportional direction). The ``"budget"`` divisor passes through zero
|
|
15
|
+
when the tangency portfolio is nearly cash-neutral — the origin of sample mean-variance's famous
|
|
16
|
+
weight/turnover explosion, which is a property of the rule, not a bug here.
|
|
17
|
+
|
|
18
|
+
The estimators (:class:`EqualWeight`, :class:`MinVariance`, :class:`MeanVariance`) are point-in-time
|
|
19
|
+
citizens: each ``to_weights(view)`` rebalances at every date on ``view.calendar``, estimating the
|
|
20
|
+
sample moments from that date's own trailing window (``window`` caps it to a rolling estimate; the
|
|
21
|
+
default expands from the start). They window internally, so look-ahead is structurally impossible.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
import pandas as pd
|
|
31
|
+
from numpy.typing import NDArray
|
|
32
|
+
|
|
33
|
+
from numeraire.core import capabilities
|
|
34
|
+
from numeraire.core.data import TimeSeriesView
|
|
35
|
+
from numeraire.core.protocols import DataView
|
|
36
|
+
|
|
37
|
+
Float = NDArray[np.float64]
|
|
38
|
+
Normalization = Literal["budget", "none"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------- weight functions
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def equal_weights(n: int) -> Float:
|
|
45
|
+
"""The naive 1/N portfolio: ``w_i = 1/n`` (needs no moment estimation)."""
|
|
46
|
+
if n < 1:
|
|
47
|
+
raise ValueError(f"need at least one asset; got n={n}")
|
|
48
|
+
return np.ones(n, dtype=np.float64) / n
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def minimum_variance_weights(cov: Float) -> Float:
|
|
52
|
+
"""Global minimum-variance weights ``S^-1 1 / (1' S^-1 1)`` — the FOC of ``min w'Sw, 1'w=1``."""
|
|
53
|
+
n = len(cov)
|
|
54
|
+
x = np.linalg.solve(cov, np.ones(n, dtype=np.float64))
|
|
55
|
+
return x / x.sum()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def mean_variance_weights(
|
|
59
|
+
mu: Float, cov: Float, *, normalization: Normalization = "budget"
|
|
60
|
+
) -> Float:
|
|
61
|
+
"""Plug-in mean-variance (tangency) weights ``∝ S^-1 mu``, normalization made explicit.
|
|
62
|
+
|
|
63
|
+
``normalization``:
|
|
64
|
+
|
|
65
|
+
- ``"budget"`` (default): divide by ``1' S^-1 mu`` so the weights sum to one — the
|
|
66
|
+
DeMiguel-Garlappi-Uppal convention. The divisor passes through zero for a near-cash-neutral
|
|
67
|
+
tangency portfolio, which is why sample mean-variance weights and turnover explode.
|
|
68
|
+
- ``"none"``: the raw proportional direction ``S^-1 mu`` (no budget rescaling).
|
|
69
|
+
"""
|
|
70
|
+
x = np.linalg.solve(cov, mu)
|
|
71
|
+
if normalization == "budget":
|
|
72
|
+
return x / x.sum()
|
|
73
|
+
if normalization == "none":
|
|
74
|
+
return x
|
|
75
|
+
raise ValueError(f"normalization must be 'budget' or 'none'; got {normalization!r}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------------------------------- engine citizens
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _as_tsv(view: DataView) -> TimeSeriesView:
|
|
82
|
+
if not isinstance(view, TimeSeriesView):
|
|
83
|
+
raise TypeError("baseline weight rules run on a TimeSeriesView (asset-returns block)")
|
|
84
|
+
return view
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _rolling_weights(
|
|
88
|
+
view: TimeSeriesView, weight_fn: Callable[[Float], Float], *, window: int | None, min_obs: int
|
|
89
|
+
) -> pd.DataFrame:
|
|
90
|
+
"""Rebalance at every calendar date from that date's trailing window (PIT: windows internally).
|
|
91
|
+
|
|
92
|
+
A date is skipped (warm-up) until at least ``min_obs`` past observations are available, and
|
|
93
|
+
``weight_fn`` gets the last ``window`` rows (all history if ``window`` is ``None``); it maps a
|
|
94
|
+
``(T x N)`` block of past returns to an ``N``-vector of weights.
|
|
95
|
+
"""
|
|
96
|
+
assets = view.assets
|
|
97
|
+
rows: list[Float] = []
|
|
98
|
+
idx: list[pd.Timestamp] = []
|
|
99
|
+
for t in view.calendar:
|
|
100
|
+
hist = view.window(t).returns_frame().to_numpy(dtype=np.float64)
|
|
101
|
+
if len(hist) < min_obs:
|
|
102
|
+
continue # warm-up
|
|
103
|
+
block = hist if window is None else hist[-window:]
|
|
104
|
+
if len(block) < min_obs:
|
|
105
|
+
continue
|
|
106
|
+
rows.append(weight_fn(block))
|
|
107
|
+
idx.append(t)
|
|
108
|
+
if not rows:
|
|
109
|
+
return pd.DataFrame(columns=assets)
|
|
110
|
+
return pd.DataFrame(np.vstack(rows), index=pd.DatetimeIndex(idx), columns=assets)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _WeightModel:
|
|
114
|
+
"""Fitted (parameter-free, closed-form) baseline weight model over any view's calendar."""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self, weight_fn: Callable[[Float], Float], *, window: int | None, min_obs: int
|
|
118
|
+
) -> None:
|
|
119
|
+
self._weight_fn = weight_fn
|
|
120
|
+
self._window = window
|
|
121
|
+
self._min_obs = min_obs
|
|
122
|
+
|
|
123
|
+
def capabilities(self) -> set[str]:
|
|
124
|
+
return {capabilities.TO_WEIGHTS}
|
|
125
|
+
|
|
126
|
+
def to_weights(self, view: DataView) -> pd.DataFrame:
|
|
127
|
+
return _rolling_weights(
|
|
128
|
+
_as_tsv(view), self._weight_fn, window=self._window, min_obs=self._min_obs
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class EqualWeight:
|
|
133
|
+
"""The 1/N benchmark estimator: rebalances to equal weights over ``view.assets`` each period."""
|
|
134
|
+
|
|
135
|
+
def fit(self, view: DataView) -> _WeightModel:
|
|
136
|
+
_as_tsv(view)
|
|
137
|
+
return _WeightModel(lambda b: equal_weights(b.shape[1]), window=None, min_obs=1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_min_obs(explicit: int | None, n_assets: int) -> int:
|
|
141
|
+
"""Warm-up length: an explicit floor, else one more row than assets (invertible covariance)."""
|
|
142
|
+
return explicit if explicit is not None else n_assets + 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class MinVariance:
|
|
146
|
+
"""Global minimum-variance estimator: sample covariance from the (optionally windowed) view.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
window:
|
|
151
|
+
Trailing window (in calendar steps) for the sample covariance; ``None`` (default) expands
|
|
152
|
+
from the start. A rolling cap mirrors the skfolio adapter's estimation window.
|
|
153
|
+
min_obs:
|
|
154
|
+
Minimum observations before the first rebalance; ``None`` (default) requires strictly more
|
|
155
|
+
rows than assets, so the sample covariance is non-singular.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self, *, window: int | None = None, min_obs: int | None = None) -> None:
|
|
159
|
+
if window is not None and window < 2:
|
|
160
|
+
raise ValueError("window must be >= 2 (a covariance needs at least two rows)")
|
|
161
|
+
if min_obs is not None and min_obs < 2:
|
|
162
|
+
raise ValueError("min_obs must be >= 2")
|
|
163
|
+
self.window = window
|
|
164
|
+
self.min_obs = min_obs
|
|
165
|
+
|
|
166
|
+
def fit(self, view: DataView) -> _WeightModel:
|
|
167
|
+
tsv = _as_tsv(view)
|
|
168
|
+
|
|
169
|
+
def _fn(block: Float) -> Float:
|
|
170
|
+
return minimum_variance_weights(np.cov(block, rowvar=False))
|
|
171
|
+
|
|
172
|
+
return _WeightModel(
|
|
173
|
+
_fn, window=self.window, min_obs=_resolve_min_obs(self.min_obs, len(tsv.assets))
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class MeanVariance:
|
|
178
|
+
"""Plug-in mean-variance estimator: sample ``mu``/``S`` → ``S^-1 mu``, normalization explicit.
|
|
179
|
+
|
|
180
|
+
Parameters
|
|
181
|
+
----------
|
|
182
|
+
normalization:
|
|
183
|
+
``"budget"`` (default) divides by ``1' S^-1 mu`` (weights sum to one; DGU convention);
|
|
184
|
+
``"none"`` returns the raw proportional direction ``S^-1 mu``.
|
|
185
|
+
window, min_obs:
|
|
186
|
+
As for :class:`MinVariance` (rolling estimation window / warm-up; default warm-up is one
|
|
187
|
+
row more than the asset count so the sample covariance is invertible).
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self,
|
|
192
|
+
*,
|
|
193
|
+
normalization: Normalization = "budget",
|
|
194
|
+
window: int | None = None,
|
|
195
|
+
min_obs: int | None = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
if normalization not in ("budget", "none"):
|
|
198
|
+
raise ValueError(f"normalization must be 'budget' or 'none'; got {normalization!r}")
|
|
199
|
+
if window is not None and window < 2:
|
|
200
|
+
raise ValueError("window must be >= 2 (moments need at least two rows)")
|
|
201
|
+
if min_obs is not None and min_obs < 2:
|
|
202
|
+
raise ValueError("min_obs must be >= 2")
|
|
203
|
+
self.normalization: Normalization = normalization
|
|
204
|
+
self.window = window
|
|
205
|
+
self.min_obs = min_obs
|
|
206
|
+
|
|
207
|
+
def fit(self, view: DataView) -> _WeightModel:
|
|
208
|
+
tsv = _as_tsv(view)
|
|
209
|
+
norm = self.normalization
|
|
210
|
+
|
|
211
|
+
def _fn(block: Float) -> Float:
|
|
212
|
+
mu = block.mean(axis=0)
|
|
213
|
+
cov = np.cov(block, rowvar=False)
|
|
214
|
+
return mean_variance_weights(mu, cov, normalization=norm)
|
|
215
|
+
|
|
216
|
+
return _WeightModel(
|
|
217
|
+
_fn, window=self.window, min_obs=_resolve_min_obs(self.min_obs, len(tsv.assets))
|
|
218
|
+
)
|