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

1"""Performance / parameter-sweep mixin for `BasanosEngine`. 

2 

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. 

7 

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. 

14 

15Classes in this module are **private implementation details**. The public API 

16is `BasanosEngine`, which inherits from `_PerformanceMixin`. 

17""" 

18 

19from __future__ import annotations 

20 

21from typing import TYPE_CHECKING, cast 

22 

23import polars as pl 

24 

25from ._config import SlidingWindowConfig 

26 

27if TYPE_CHECKING: 

28 from ._config import BasanosConfig 

29 from ._engine_protocol import _EngineProtocol 

30 from .optimizer import BasanosEngine 

31 

32 

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*. 

41 

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. 

45 

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. 

50 

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. 

56 

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")) 

63 

64 

65class _PerformanceMixin: 

66 """Mixin providing portfolio-Sharpe sweep helpers for `BasanosEngine`. 

67 

68 The consuming class must satisfy `_EngineProtocol` (it uses ``assets``, 

69 ``prices``, ``mu``, and ``cfg``). 

70 """ 

71 

72 def sharpe_at_shrink(self: _EngineProtocol, shrink: float) -> float: 

73 r"""Return the annualised portfolio Sharpe ratio for the given shrinkage weight. 

74 

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. 

78 

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). 

83 

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. 

90 

91 Args: 

92 shrink: Retention weight λ ∈ [0, 1]. See 

93 `shrink` for full documentation. 

94 

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). 

99 

100 Raises: 

101 ValidationError: When ``shrink`` is outside [0, 1] (delegated to 

102 `BasanosConfig` field validation). 

103 

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) 

120 

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. 

123 

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``. 

127 

128 Use this method to sweep ``(W, k)`` and compare the sliding-window 

129 estimator against the EWMA baseline (via `sharpe_at_shrink`). 

130 

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$. 

135 

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). 

140 

141 Raises: 

142 ValidationError: When ``window`` or ``n_factors`` fail field 

143 constraints (delegated to `BasanosConfig`). 

144 

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) 

163 

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). 

167 

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. 

171 

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: 

176 

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 +--------------------+----------------------------------------------+ 

186 

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. 

190 

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)