Coverage for src/basanos/math/_engine_solve_base.py: 100%

66 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-04 07:53 +0000

1"""Foundational types and stateless solve primitives for `_SolveMixin`. 

2 

3Holds the `SolveStatus` enum, the `MatrixBundle` / `WarmupState` carriers, the 

4`MatrixYield` / `SolveYield` aliases, and `_SolvePrimitivesMixin` — the pure 

5(``@staticmethod``) helpers that carry no dependency on the linear-algebra 

6backend. Splitting them out of ``_engine_solve`` keeps the solve-orchestration 

7module focused on the generators that drive `BasanosEngine`. 

8 

9All public names are re-exported from ``_engine_solve`` so existing imports 

10(``from basanos.math._engine_solve import MatrixBundle, SolveStatus, ...``) and 

11the ``solve`` / ``inv_a_norm`` patch targets that live there are unchanged. 

12""" 

13 

14from __future__ import annotations 

15 

16import dataclasses 

17import datetime 

18import logging 

19from enum import StrEnum 

20from typing import TypeAlias, cast 

21 

22import numpy as np 

23 

24# Solve-step warnings are emitted under the ``_engine_solve`` logger name (not 

25# this module's ``__name__``) so the logging contract — log filters and the JSON 

26# round-trip in the diagnostics — stays stable regardless of how the solve 

27# helpers are split across private modules. 

28_logger = logging.getLogger("basanos.math._engine_solve") 

29 

30 

31class SolveStatus(StrEnum): 

32 """Solver outcome labels for each timestamp. 

33 

34 Since `SolveStatus` inherits from `str` via ``StrEnum``, 

35 values compare equal to their string equivalents (e.g. 

36 ``SolveStatus.VALID == "valid"``), preserving backward compatibility 

37 with code that matches on string literals. 

38 

39 Attributes: 

40 WARMUP: Insufficient history for the sliding-window covariance mode. 

41 ZERO_SIGNAL: The expected-return vector was all-zero; positions zeroed. 

42 DEGENERATE: Normalisation denominator was non-finite, solve failed, or 

43 no asset had a finite price; positions zeroed for safety. 

44 VALID: Linear system solved successfully; positions are non-trivially 

45 non-zero. 

46 """ 

47 

48 WARMUP = "warmup" 

49 ZERO_SIGNAL = "zero_signal" 

50 DEGENERATE = "degenerate" 

51 VALID = "valid" 

52 

53 

54@dataclasses.dataclass(frozen=True) 

55class MatrixBundle: 

56 """Container for the covariance matrix and any mode-specific auxiliary state. 

57 

58 Wrapping the covariance matrix in a dataclass decouples 

59 `_compute_position` from the raw array so that future 

60 covariance modes (e.g. DCC-GARCH, RMT-cleaned) can carry additional fields 

61 through the same interface without changing the method signature. 

62 

63 Attributes: 

64 matrix: The ``(n_active, n_active)`` covariance sub-matrix for the 

65 active assets at a given timestamp. 

66 """ 

67 

68 matrix: np.ndarray 

69 

70 

71#: Yield type for `_iter_matrices`: 

72#: ``(i, t, mask, bundle)`` where ``bundle`` is ``None`` during warmup/no-data. 

73MatrixYield: TypeAlias = tuple[int, datetime.date, np.ndarray, MatrixBundle | None] 

74 

75#: Yield type for `_iter_solve`: 

76#: ``(i, t, mask, pos_or_none, status)`` where ``pos_or_none`` is ``None`` only for warmup rows. 

77SolveYield: TypeAlias = tuple[int, datetime.date, np.ndarray, np.ndarray | None, SolveStatus] 

78 

79 

80@dataclasses.dataclass(frozen=True) 

81class WarmupState: 

82 """Final state produced by a full batch solve; consumed by `from_warmup`. 

83 

84 Returned by `warmup_state` and used by 

85 `from_warmup` to initialise the streaming state without 

86 coupling to the private `_iter_solve` generator. 

87 

88 Attributes: 

89 prev_cash_pos: Cash positions at the last warmup row, shape 

90 ``(n_assets,)``. ``NaN`` for assets that were still in their 

91 own warmup period. 

92 """ 

93 

94 prev_cash_pos: np.ndarray 

95 

96 

97class _SolvePrimitivesMixin: 

98 """Stateless solve helpers shared by `_SolveMixin`. 

99 

100 Every method here is a ``@staticmethod`` that carries no dependency on the 

101 linear-algebra backend, so it can be reasoned about (and reused) in 

102 isolation from the per-timestamp solve generators. 

103 """ 

104 

105 @staticmethod 

106 def _compute_mask(prices_row: np.ndarray) -> np.ndarray: 

107 """Return boolean mask indicating which assets have finite prices in the given row.""" 

108 mask: np.ndarray = np.isfinite(prices_row) 

109 return mask 

110 

111 @staticmethod 

112 def _check_signal(mu: np.ndarray, mask: np.ndarray) -> SolveStatus | None: 

113 """Return ``ZERO_SIGNAL`` when the masked expected-return vector is all-zero. 

114 

115 Returns ``None`` when the signal is non-trivially non-zero, indicating 

116 that the caller should proceed to the linear solve. 

117 """ 

118 if np.allclose(np.nan_to_num(mu[mask]), 0.0): 

119 return SolveStatus.ZERO_SIGNAL 

120 return None 

121 

122 @staticmethod 

123 def _scale_to_cash(pos: np.ndarray, vola_active: np.ndarray) -> np.ndarray: 

124 """Convert raw solver positions to cash-adjusted positions. 

125 

126 Divides *pos* by *vola_active* (volatility for the active asset subset) 

127 to get cash positions. ``np.errstate(invalid="ignore")`` is applied 

128 internally so NaN volatility values propagate quietly. 

129 """ 

130 with np.errstate(invalid="ignore"): 

131 return cast("np.ndarray", pos / vola_active) 

132 

133 @staticmethod 

134 def _row_early_check( 

135 i: int, 

136 t: datetime.date, 

137 mask: np.ndarray, 

138 mu_row: np.ndarray, 

139 ) -> tuple[np.ndarray, SolveYield | None]: 

140 """Validate the price mask and expected-return signal for a single row. 

141 

142 Returns an ``(expected_mu, early_yield)`` pair. When ``early_yield`` 

143 is not ``None``, the caller should ``yield early_yield; continue`` 

144 immediately — the row is either degenerate (empty mask) or has an 

145 all-zero signal. When ``early_yield`` is ``None`` the row is ready 

146 for the mode-specific solve step. 

147 

148 Args: 

149 i: Row index. 

150 t: Timestamp. 

151 mask: Boolean array of shape ``(n_assets,)`` indicating finite prices. 

152 mu_row: Expected-return row of shape ``(n_assets,)``. 

153 

154 Returns: 

155 tuple: ``(expected_mu, early_yield)`` where ``expected_mu`` is 

156 ``np.nan_to_num(mu_row[mask])`` and ``early_yield`` is either a 

157 complete `SolveYield` tuple (when the caller should yield 

158 and continue) or ``None`` (when the caller should proceed to solve). 

159 """ 

160 if not mask.any(): 

161 return np.zeros(0), (i, t, mask, np.zeros(0), SolveStatus.DEGENERATE) 

162 expected_mu = np.nan_to_num(mu_row[mask]) 

163 sig_status = _SolvePrimitivesMixin._check_signal(mu_row, mask) 

164 if sig_status is not None: 

165 return expected_mu, (i, t, mask, np.zeros_like(expected_mu), sig_status) 

166 return expected_mu, None 

167 

168 @staticmethod 

169 def _denom_guard_yield( 

170 i: int, 

171 t: datetime.date, 

172 mask: np.ndarray, 

173 expected_mu: np.ndarray, 

174 pos_raw: np.ndarray, 

175 denom: float, 

176 denom_tol: float, 

177 ) -> SolveYield: 

178 """Apply the normalisation-denominator guard and return the appropriate yield tuple. 

179 

180 Emits a `WARNING` and returns a 

181 `DEGENERATE` yield when *denom* is non-finite or at 

182 or below *denom_tol*; otherwise returns a `VALID` 

183 yield with normalised positions ``pos_raw / denom``. 

184 

185 Args: 

186 i: Row index. 

187 t: Timestamp. 

188 mask: Boolean asset mask of shape ``(n_assets,)``. 

189 expected_mu: Masked expected-return vector of shape ``(n_active,)``. 

190 pos_raw: Raw (pre-normalisation) position vector of shape ``(n_active,)``. 

191 denom: Computed normalisation denominator. 

192 denom_tol: Tolerance threshold below which *denom* is treated as degenerate. 

193 

194 Returns: 

195 SolveYield: Either a degenerate or valid ``(i, t, mask, pos, status)`` tuple. 

196 """ 

197 n_active = len(expected_mu) 

198 if not np.isfinite(denom) or denom <= denom_tol: 

199 _logger.warning( 

200 "Positions zeroed at t=%s: normalisation denominator is degenerate " 

201 "(denom=%s, denom_tol=%s). Check signal magnitude and covariance matrix.", 

202 t, 

203 denom, 

204 denom_tol, 

205 extra={ 

206 "context": { 

207 "t": str(t), 

208 "denom": denom, 

209 "denom_tol": denom_tol, 

210 } 

211 }, 

212 ) 

213 return i, t, mask, np.zeros(n_active), SolveStatus.DEGENERATE 

214 return i, t, mask, pos_raw / denom, SolveStatus.VALID 

215 

216 @staticmethod 

217 def _apply_turnover_constraint( 

218 new_cash: np.ndarray, 

219 prev_cash: np.ndarray, 

220 max_turnover: float, 

221 ) -> np.ndarray: 

222 """Cap the L1 norm of the position change to *max_turnover*. 

223 

224 When ``sum(|new_cash - prev_cash|) > max_turnover``, the delta is 

225 scaled back proportionally toward *prev_cash* so that the constraint 

226 is exactly met. When the constraint is already satisfied the input is 

227 returned unchanged. 

228 

229 Args: 

230 new_cash: Proposed cash positions after the solve step, shape 

231 ``(n_active,)`` — ``NaN`` values treated as zero. 

232 prev_cash: Cash positions at the previous step, shape 

233 ``(n_active,)`` — ``NaN`` values treated as zero. 

234 max_turnover: Maximum allowed L1 norm of the position change. 

235 

236 Returns: 

237 np.ndarray: The (possibly scaled) new cash positions. 

238 """ 

239 curr = np.nan_to_num(new_cash, nan=0.0) 

240 prev = np.nan_to_num(prev_cash, nan=0.0) 

241 delta = curr - prev 

242 total_delta = float(np.sum(np.abs(delta))) 

243 if total_delta > max_turnover: 

244 scale = max_turnover / total_delta 

245 return cast("np.ndarray", prev + delta * scale) 

246 return new_cash 

247 

248 @staticmethod 

249 def _sliding_warmup_or_degenerate( 

250 i: int, 

251 t: datetime.date, 

252 mask: np.ndarray, 

253 win_w: int, 

254 ) -> SolveYield: 

255 """Classify a no-matrix sliding-window row as WARMUP or DEGENERATE. 

256 

257 Distinguishes an insufficient-history warm-up row (mask non-empty but 

258 fewer than ``win_w`` rows seen) from a genuine no-data / model-failure 

259 row, which is zeroed and marked degenerate. 

260 """ 

261 if mask.any() and i + 1 < win_w: 

262 return i, t, mask, None, SolveStatus.WARMUP 

263 return i, t, mask, np.zeros(int(mask.sum())), SolveStatus.DEGENERATE