Coverage for src/basanos/math/_engine_performance.py: 100%
19 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-25 12:05 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-25 12:05 +0000
1"""Performance / parameter-sweep mixin for `BasanosEngine`.
3Provides the Sharpe-ratio sweep helpers (`sharpe_at_shrink`,
4`sharpe_at_window_factors`, `naive_sharpe`) as a reusable mixin so that
5``optimizer.py`` stays focused on the position-solving facade. Each helper
6rebuilds a sibling engine with a modified configuration.
8The sibling is built from ``type(source)`` rather than by importing
9`BasanosEngine`, so this module has **no runtime dependency on
10``optimizer.py``** — the only import of it is under ``TYPE_CHECKING``, the same
11pattern `_config_report` uses. That keeps the dependency edge one-way
12(``optimizer`` → ``_engine_performance``) instead of a cycle papered over by
13function-local imports.
15Classes in this module are **private implementation details**. The public API
16is `BasanosEngine`, which inherits from `_PerformanceMixin`.
17"""
19from __future__ import annotations
21from typing import TYPE_CHECKING, cast
23import polars as pl
25from ._config import SlidingWindowConfig
27if TYPE_CHECKING:
28 from ._config import BasanosConfig
29 from ._engine_protocol import _EngineProtocol
30 from .optimizer import BasanosEngine
33def _rebuilt_sharpe(
34 source: _EngineProtocol,
35 *,
36 prices: pl.DataFrame,
37 mu: pl.DataFrame,
38 cfg: BasanosConfig,
39) -> float:
40 """Return the annualised Sharpe ratio of an engine rebuilt from *source*.
42 Constructs a sibling of ``source`` — same concrete class, supplied
43 ``prices`` / ``mu`` / ``cfg`` — and returns its portfolio Sharpe ratio,
44 or ``float("nan")`` when the ratio cannot be computed.
46 ``type(source)`` is used instead of importing `BasanosEngine`
47 directly: the concrete class is always the one that mixed this helper in, so
48 naming it at runtime would buy nothing and would make ``optimizer.py`` and
49 this module mutually dependent.
51 Args:
52 source: The engine whose class and identity the sibling inherits.
53 prices: Price panel for the rebuilt engine.
54 mu: Expected-return panel for the rebuilt engine.
55 cfg: Configuration for the rebuilt engine.
57 Returns:
58 Annualised Sharpe ratio of the rebuilt portfolio as a ``float``.
59 """
60 engine_cls = cast("type[BasanosEngine]", type(source))
61 engine = engine_cls(prices=prices, mu=mu, cfg=cfg)
62 return float(engine.portfolio.stats.sharpe().get("returns") or float("nan"))
65class _PerformanceMixin:
66 """Mixin providing portfolio-Sharpe sweep helpers for `BasanosEngine`.
68 The consuming class must satisfy `_EngineProtocol` (it uses ``assets``,
69 ``prices``, ``mu``, and ``cfg``).
70 """
72 def sharpe_at_shrink(self: _EngineProtocol, shrink: float) -> float:
73 r"""Return the annualised portfolio Sharpe ratio for the given shrinkage weight.
75 Constructs a new `BasanosEngine` with all parameters identical to
76 ``self`` except that ``cfg.shrink`` is replaced by ``shrink``, then
77 returns the annualised Sharpe ratio of the resulting portfolio.
79 This is the canonical single-argument callable required by the benchmarks
80 specification: ``f(λ) → Sharpe``. Use it to sweep λ across ``[0, 1]``
81 and measure whether correlation adjustment adds value over the
82 signal-proportional baseline (λ = 0) or the unregularised limit (λ = 1).
84 Corner cases:
85 * **λ = 0** — the shrunk matrix equals the identity, so the
86 optimiser treats all assets as uncorrelated and positions are
87 purely signal-proportional (no correlation adjustment).
88 * **λ = 1** — the raw EWMA correlation matrix is used without
89 shrinkage.
91 Args:
92 shrink: Retention weight λ ∈ [0, 1]. See
93 `shrink` for full documentation.
95 Returns:
96 Annualised Sharpe ratio of the portfolio returns as a ``float``.
97 Returns ``float("nan")`` when the Sharpe ratio cannot be computed
98 (e.g. zero-variance returns).
100 Raises:
101 ValidationError: When ``shrink`` is outside [0, 1] (delegated to
102 `BasanosConfig` field validation).
104 Examples:
105 >>> import numpy as np
106 >>> import polars as pl
107 >>> from basanos.math.optimizer import BasanosConfig, BasanosEngine
108 >>> dates = pl.Series("date", list(range(200)))
109 >>> rng = np.random.default_rng(0)
110 >>> prices = pl.DataFrame({"date": dates, "A": rng.lognormal(size=200), "B": rng.lognormal(size=200)})
111 >>> mu = pl.DataFrame({"date": dates, "A": rng.normal(size=200), "B": rng.normal(size=200)})
112 >>> cfg = BasanosConfig(vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6)
113 >>> engine = BasanosEngine(prices=prices, mu=mu, cfg=cfg)
114 >>> s = engine.sharpe_at_shrink(0.5)
115 >>> isinstance(s, float)
116 True
117 """
118 new_cfg = self.cfg.replace(shrink=shrink)
119 return _rebuilt_sharpe(self, prices=self.prices, mu=self.mu, cfg=new_cfg)
121 def sharpe_at_window_factors(self: _EngineProtocol, window: int, n_factors: int) -> float:
122 r"""Return the annualised portfolio Sharpe ratio for the given sliding-window parameters.
124 Constructs a new `BasanosEngine` with ``covariance_mode`` set to
125 ``"sliding_window"`` and the supplied ``window`` / ``n_factors``, keeping
126 all other configuration identical to ``self``.
128 Use this method to sweep ``(W, k)`` and compare the sliding-window
129 estimator against the EWMA baseline (via `sharpe_at_shrink`).
131 Args:
132 window: Rolling window length $W \geq 1$.
133 Rule of thumb: $W \geq 2 \cdot n_{\text{assets}}$.
134 n_factors: Number of latent factors $k \geq 1$.
136 Returns:
137 Annualised Sharpe ratio of the portfolio returns as a ``float``.
138 Returns ``float("nan")`` when the Sharpe ratio cannot be computed
139 (e.g. not enough history to fill the first window).
141 Raises:
142 ValidationError: When ``window`` or ``n_factors`` fail field
143 constraints (delegated to `BasanosConfig`).
145 Examples:
146 >>> import numpy as np
147 >>> import polars as pl
148 >>> from basanos.math.optimizer import BasanosConfig, BasanosEngine
149 >>> dates = pl.Series("date", list(range(200)))
150 >>> rng = np.random.default_rng(0)
151 >>> prices = pl.DataFrame({"date": dates, "A": rng.lognormal(size=200), "B": rng.lognormal(size=200)})
152 >>> mu = pl.DataFrame({"date": dates, "A": rng.normal(size=200), "B": rng.normal(size=200)})
153 >>> cfg = BasanosConfig(vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6)
154 >>> engine = BasanosEngine(prices=prices, mu=mu, cfg=cfg)
155 >>> s = engine.sharpe_at_window_factors(window=40, n_factors=2)
156 >>> isinstance(s, float)
157 True
158 """
159 new_cfg = self.cfg.replace(
160 covariance_config=SlidingWindowConfig(window=window, n_factors=n_factors),
161 )
162 return _rebuilt_sharpe(self, prices=self.prices, mu=self.mu, cfg=new_cfg)
164 @property
165 def naive_sharpe(self: _EngineProtocol) -> float:
166 r"""Sharpe ratio of the naïve equal-weight signal (μ = 1 for every asset/timestamp).
168 Replaces the expected-return signal ``mu`` with a constant matrix of
169 ones, then runs the optimiser with the current configuration and returns
170 the annualised Sharpe ratio of the resulting portfolio.
172 This provides the baseline answer to *"does the signal add value?"*:
173 a real signal should produce a higher Sharpe than the naïve benchmark.
174 Combined with `sharpe_at_shrink`, this yields a three-way
175 comparison:
177 +--------------------+----------------------------------------------+
178 | Benchmark | What it measures |
179 +====================+==============================================+
180 | ``naive_sharpe`` | No signal skill; pure correlation routing |
181 +--------------------+----------------------------------------------+
182 | ``sharpe_at_shrink(0.0)`` | Signal skill, no correlation adj. |
183 +--------------------+----------------------------------------------+
184 | ``sharpe_at_shrink(cfg.shrink)`` | Signal + correlation adj. |
185 +--------------------+----------------------------------------------+
187 Returns:
188 Annualised Sharpe ratio of the equal-weight portfolio as a ``float``.
189 Returns ``float("nan")`` when the Sharpe ratio cannot be computed.
191 Examples:
192 >>> import numpy as np
193 >>> import polars as pl
194 >>> from basanos.math.optimizer import BasanosConfig, BasanosEngine
195 >>> dates = pl.Series("date", list(range(200)))
196 >>> rng = np.random.default_rng(0)
197 >>> prices = pl.DataFrame({"date": dates, "A": rng.lognormal(size=200), "B": rng.lognormal(size=200)})
198 >>> mu = pl.DataFrame({"date": dates, "A": rng.normal(size=200), "B": rng.normal(size=200)})
199 >>> cfg = BasanosConfig(vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6)
200 >>> engine = BasanosEngine(prices=prices, mu=mu, cfg=cfg)
201 >>> s = engine.naive_sharpe
202 >>> isinstance(s, float)
203 True
204 """
205 naive_mu = self.mu.with_columns(pl.lit(1.0).alias(asset) for asset in self.assets)
206 return _rebuilt_sharpe(self, prices=self.prices, mu=naive_mu, cfg=self.cfg)