Coverage for src/basanos/math/_engine_performance.py: 100%
21 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-04 07:53 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-04 07:53 +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, so it constructs a new
7`BasanosEngine` via a deferred import (avoiding a circular import at module
8load time).
10Classes in this module are **private implementation details**. The public API
11is `BasanosEngine`, which inherits from `_PerformanceMixin`.
12"""
14from __future__ import annotations
16from typing import TYPE_CHECKING
18import polars as pl
20from ._config import SlidingWindowConfig
22if TYPE_CHECKING:
23 from ._engine_protocol import _EngineProtocol
26class _PerformanceMixin:
27 """Mixin providing portfolio-Sharpe sweep helpers for `BasanosEngine`.
29 The consuming class must satisfy `_EngineProtocol` (it uses ``assets``,
30 ``prices``, ``mu``, and ``cfg``).
31 """
33 def sharpe_at_shrink(self: _EngineProtocol, shrink: float) -> float:
34 r"""Return the annualised portfolio Sharpe ratio for the given shrinkage weight.
36 Constructs a new `BasanosEngine` with all parameters identical to
37 ``self`` except that ``cfg.shrink`` is replaced by ``shrink``, then
38 returns the annualised Sharpe ratio of the resulting portfolio.
40 This is the canonical single-argument callable required by the benchmarks
41 specification: ``f(λ) → Sharpe``. Use it to sweep λ across ``[0, 1]``
42 and measure whether correlation adjustment adds value over the
43 signal-proportional baseline (λ = 0) or the unregularised limit (λ = 1).
45 Corner cases:
46 * **λ = 0** — the shrunk matrix equals the identity, so the
47 optimiser treats all assets as uncorrelated and positions are
48 purely signal-proportional (no correlation adjustment).
49 * **λ = 1** — the raw EWMA correlation matrix is used without
50 shrinkage.
52 Args:
53 shrink: Retention weight λ ∈ [0, 1]. See
54 `shrink` for full documentation.
56 Returns:
57 Annualised Sharpe ratio of the portfolio returns as a ``float``.
58 Returns ``float("nan")`` when the Sharpe ratio cannot be computed
59 (e.g. zero-variance returns).
61 Raises:
62 ValidationError: When ``shrink`` is outside [0, 1] (delegated to
63 `BasanosConfig` field validation).
65 Examples:
66 >>> import numpy as np
67 >>> import polars as pl
68 >>> from basanos.math.optimizer import BasanosConfig, BasanosEngine
69 >>> dates = pl.Series("date", list(range(200)))
70 >>> rng = np.random.default_rng(0)
71 >>> prices = pl.DataFrame({"date": dates, "A": rng.lognormal(size=200), "B": rng.lognormal(size=200)})
72 >>> mu = pl.DataFrame({"date": dates, "A": rng.normal(size=200), "B": rng.normal(size=200)})
73 >>> cfg = BasanosConfig(vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6)
74 >>> engine = BasanosEngine(prices=prices, mu=mu, cfg=cfg)
75 >>> s = engine.sharpe_at_shrink(0.5)
76 >>> isinstance(s, float)
77 True
78 """
79 from .optimizer import BasanosEngine # deferred to avoid a circular import
81 new_cfg = self.cfg.replace(shrink=shrink)
82 engine = BasanosEngine(prices=self.prices, mu=self.mu, cfg=new_cfg)
83 return float(engine.portfolio.stats.sharpe().get("returns") or float("nan"))
85 def sharpe_at_window_factors(self: _EngineProtocol, window: int, n_factors: int) -> float:
86 r"""Return the annualised portfolio Sharpe ratio for the given sliding-window parameters.
88 Constructs a new `BasanosEngine` with ``covariance_mode`` set to
89 ``"sliding_window"`` and the supplied ``window`` / ``n_factors``, keeping
90 all other configuration identical to ``self``.
92 Use this method to sweep ``(W, k)`` and compare the sliding-window
93 estimator against the EWMA baseline (via `sharpe_at_shrink`).
95 Args:
96 window: Rolling window length $W \geq 1$.
97 Rule of thumb: $W \geq 2 \cdot n_{\text{assets}}$.
98 n_factors: Number of latent factors $k \geq 1$.
100 Returns:
101 Annualised Sharpe ratio of the portfolio returns as a ``float``.
102 Returns ``float("nan")`` when the Sharpe ratio cannot be computed
103 (e.g. not enough history to fill the first window).
105 Raises:
106 ValidationError: When ``window`` or ``n_factors`` fail field
107 constraints (delegated to `BasanosConfig`).
109 Examples:
110 >>> import numpy as np
111 >>> import polars as pl
112 >>> from basanos.math.optimizer import BasanosConfig, BasanosEngine
113 >>> dates = pl.Series("date", list(range(200)))
114 >>> rng = np.random.default_rng(0)
115 >>> prices = pl.DataFrame({"date": dates, "A": rng.lognormal(size=200), "B": rng.lognormal(size=200)})
116 >>> mu = pl.DataFrame({"date": dates, "A": rng.normal(size=200), "B": rng.normal(size=200)})
117 >>> cfg = BasanosConfig(vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6)
118 >>> engine = BasanosEngine(prices=prices, mu=mu, cfg=cfg)
119 >>> s = engine.sharpe_at_window_factors(window=40, n_factors=2)
120 >>> isinstance(s, float)
121 True
122 """
123 from .optimizer import BasanosEngine # deferred to avoid a circular import
125 new_cfg = self.cfg.replace(
126 covariance_config=SlidingWindowConfig(window=window, n_factors=n_factors),
127 )
128 engine = BasanosEngine(prices=self.prices, mu=self.mu, cfg=new_cfg)
129 return float(engine.portfolio.stats.sharpe().get("returns") or float("nan"))
131 @property
132 def naive_sharpe(self: _EngineProtocol) -> float:
133 r"""Sharpe ratio of the naïve equal-weight signal (μ = 1 for every asset/timestamp).
135 Replaces the expected-return signal ``mu`` with a constant matrix of
136 ones, then runs the optimiser with the current configuration and returns
137 the annualised Sharpe ratio of the resulting portfolio.
139 This provides the baseline answer to *"does the signal add value?"*:
140 a real signal should produce a higher Sharpe than the naïve benchmark.
141 Combined with `sharpe_at_shrink`, this yields a three-way
142 comparison:
144 +--------------------+----------------------------------------------+
145 | Benchmark | What it measures |
146 +====================+==============================================+
147 | ``naive_sharpe`` | No signal skill; pure correlation routing |
148 +--------------------+----------------------------------------------+
149 | ``sharpe_at_shrink(0.0)`` | Signal skill, no correlation adj. |
150 +--------------------+----------------------------------------------+
151 | ``sharpe_at_shrink(cfg.shrink)`` | Signal + correlation adj. |
152 +--------------------+----------------------------------------------+
154 Returns:
155 Annualised Sharpe ratio of the equal-weight portfolio as a ``float``.
156 Returns ``float("nan")`` when the Sharpe ratio cannot be computed.
158 Examples:
159 >>> import numpy as np
160 >>> import polars as pl
161 >>> from basanos.math.optimizer import BasanosConfig, BasanosEngine
162 >>> dates = pl.Series("date", list(range(200)))
163 >>> rng = np.random.default_rng(0)
164 >>> prices = pl.DataFrame({"date": dates, "A": rng.lognormal(size=200), "B": rng.lognormal(size=200)})
165 >>> mu = pl.DataFrame({"date": dates, "A": rng.normal(size=200), "B": rng.normal(size=200)})
166 >>> cfg = BasanosConfig(vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6)
167 >>> engine = BasanosEngine(prices=prices, mu=mu, cfg=cfg)
168 >>> s = engine.naive_sharpe
169 >>> isinstance(s, float)
170 True
171 """
172 from .optimizer import BasanosEngine # deferred to avoid a circular import
174 naive_mu = self.mu.with_columns(pl.lit(1.0).alias(asset) for asset in self.assets)
175 engine = BasanosEngine(prices=self.prices, mu=naive_mu, cfg=self.cfg)
176 return float(engine.portfolio.stats.sharpe().get("returns") or float("nan"))