Coverage for src/tinycta/_kernel.py: 100%

37 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-09-15 05:23 +0000

1"""Pure-NumPy numeric kernel for the correlation-aware position optimizer. 

2 

3Every function here operates on NumPy arrays only — no Polars, no :class:`~tinycta.config.Config` 

4— so the Polars-facing orchestration in :mod:`tinycta.engine` stays a thin adapter that 

5prepares arrays, delegates the timestamp walk to :func:`forward_walk`, and writes the result 

6back into a DataFrame. 

7 

8This module is on the core import path (``tinycta.engine`` imports it at module level), so it 

9must import nothing outside ``[project].dependencies``. In particular it does **not** log: 

10``loguru`` ships only with the optional ``hyper`` extra, and importing it here would break 

11``pip install tinycta`` for every user who never asked for that extra. Degenerate cases are 

12made observable through return values instead — see :func:`_risk_position`. 

13""" 

14 

15from __future__ import annotations 

16 

17from collections.abc import Hashable 

18 

19import numpy as np 

20 

21from .linalg import inv_a_norm as _inv_a_norm 

22from .linalg import solve as _solve 

23from .signal import shrink2id as _shrink2id 

24 

25 

26def _denominator_is_degenerate(denom: float) -> bool: 

27 """Return True when the correlation-norm denominator is effectively zero. 

28 

29 ``1e-12`` is an arbitrary epsilon floor: any nearby threshold value or a 

30 ``<`` vs ``<=`` boundary behaves identically for realistic denominators, so 

31 this comparison is intentionally excluded from mutation. 

32 """ 

33 return denom <= 1e-12 # pragma: no mutate 

34 

35 

36def _risk_position(corr: np.ndarray, mu_row: np.ndarray, mask: np.ndarray, shrink: float) -> np.ndarray: 

37 """Solve the shrunk correlation system for one timestamp's tradable assets. 

38 

39 Shrinks ``corr`` towards the identity by ``shrink`` (via 

40 :func:`~tinycta.signal.shrink2id`), restricts it to the masked assets, solves 

41 for the expected returns ``mu_row`` and normalises by ``inv_a_norm`` so the raw 

42 risk position has unit norm under the correlation metric. Returns zeros when the 

43 normaliser is non-finite/degenerate or ``mu_row`` is all-zero. 

44 

45 Args: 

46 corr: Full EWMA correlation matrix for the timestamp. 

47 mu_row: Expected returns for every asset at the timestamp (NaNs tolerated). 

48 mask: Boolean mask of currently-tradable assets. 

49 shrink: Identity-shrinkage weight in ``[0, 1]``. 

50 

51 Returns: 

52 np.ndarray: The normalised risk position over the masked assets. 

53 

54 Example: 

55 >>> import numpy as np 

56 >>> from tinycta._kernel import _risk_position 

57 >>> both = np.array([True, True]) 

58 

59 With an identity correlation the solve is trivial: the asset carrying the 

60 expected return takes the whole (unit-norm) position and the other takes none. 

61 

62 >>> _risk_position(np.eye(2), np.array([1.0, 0.0]), both, shrink=1.0) 

63 array([1., 0.]) 

64 

65 A correlated pair tilts against the correlation — holding the second asset 

66 short hedges the first, and the norm stays unit under the shrunk metric: 

67 

68 >>> corr = np.array([[1.0, 0.8], [0.8, 1.0]]) 

69 >>> _risk_position(corr, np.array([1.0, 0.0]), both, shrink=0.5).round(4) 

70 array([ 1.0911, -0.4364]) 

71 

72 An all-zero ``mu`` is degenerate and falls back to zeros rather than 

73 dividing by a vanishing normaliser: 

74 

75 >>> _risk_position(np.eye(2), np.array([0.0, 0.0]), both, shrink=1.0) 

76 array([0., 0.]) 

77 

78 ``NaN`` expected returns are tolerated — they are zeroed, not propagated: 

79 

80 >>> _risk_position(np.eye(2), np.array([1.0, np.nan]), both, shrink=1.0) 

81 array([1., 0.]) 

82 

83 The result spans only the masked assets, so an untradable asset is absent 

84 from the output rather than present as a zero: 

85 

86 >>> _risk_position(corr, np.array([1.0, 2.0]), np.array([True, False]), shrink=1.0) 

87 array([1.]) 

88 """ 

89 matrix = _shrink2id(corr, lamb=shrink)[np.ix_(mask, mask)] 

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

91 denom = _inv_a_norm(expected_mu, matrix) 

92 if denom is None or not np.isfinite(denom) or _denominator_is_degenerate(denom) or np.allclose(expected_mu, 0.0): 

93 return np.zeros_like(expected_mu) 

94 return _solve(matrix, expected_mu) / denom 

95 

96 

97def _update_profit_variance( 

98 profit_variance: float, 

99 cash_pos_prev: np.ndarray, 

100 returns_row: np.ndarray, 

101 ret_mask: np.ndarray, 

102 lamb: float, 

103) -> float: 

104 """EWMA-update the running profit-variance estimate with one period's P&L. 

105 

106 Realised profit is the previous cash position dotted with the current returns 

107 over the jointly-finite assets; the variance decays towards the new squared 

108 profit by ``1 - lamb``. 

109 

110 Args: 

111 profit_variance: Previous running profit-variance estimate. 

112 cash_pos_prev: Previous timestamp's cash position (NaNs tolerated). 

113 returns_row: Current timestamp's simple returns (NaNs tolerated). 

114 ret_mask: Boolean mask of assets finite in both rows. 

115 lamb: EWMA decay factor. 

116 

117 Returns: 

118 float: The updated profit-variance estimate. 

119 

120 Example: 

121 >>> import numpy as np 

122 >>> from tinycta._kernel import _update_profit_variance 

123 >>> both = np.array([True, True]) 

124 

125 A position of 2.0 against a 10% return realises a profit of 0.2, so the 

126 estimate decays towards ``0.2 ** 2`` by ``1 - lamb``: 

127 

128 >>> _update_profit_variance(1.0, np.array([2.0, 0.0]), np.array([0.1, 0.0]), both, lamb=0.99) 

129 0.9904 

130 

131 A flat book realises nothing, so the estimate simply decays: 

132 

133 >>> _update_profit_variance(1.0, np.array([0.0, 0.0]), np.array([0.1, 0.2]), both, lamb=0.99) 

134 0.99 

135 

136 ``NaN`` on either side is zeroed rather than poisoning the estimate — here 

137 the second asset contributes nothing and the result matches the first case: 

138 

139 >>> _update_profit_variance(1.0, np.array([2.0, np.nan]), np.array([0.1, 0.5]), both, lamb=0.99) 

140 0.9904 

141 """ 

142 lhs = np.nan_to_num(cash_pos_prev[ret_mask], nan=0.0) 

143 rhs = np.nan_to_num(returns_row[ret_mask], nan=0.0) 

144 profit = lhs @ rhs 

145 return float(lamb * profit_variance + (1 - lamb) * profit**2) 

146 

147 

148def forward_walk( 

149 cor: dict[Hashable, np.ndarray], 

150 prices_num: np.ndarray, 

151 returns_num: np.ndarray, 

152 mu: np.ndarray, 

153 vola_np: np.ndarray, 

154 risk_pos_np: np.ndarray, 

155 cash_pos_np: np.ndarray, 

156 row_of: dict[Hashable, int], 

157 shrink: float, 

158) -> None: 

159 """Walk forward through the post-warmup timestamps, filling positions in place. 

160 

161 Mutates ``risk_pos_np`` and ``cash_pos_np`` row-by-row. At each timestamp the 

162 previous period's realised P&L EWMA-updates a running profit-variance estimate 

163 (decay ``lamb=0.99``), which scales the freshly-solved risk position before it is 

164 divided by per-asset volatility to yield the cash position. 

165 

166 Args: 

167 cor: Per-timestamp correlation matrices, keyed by ``date`` value. 

168 prices_num: Asset prices as a ``(rows, assets)`` array (NaNs tolerated). 

169 returns_num: Simple returns aligned to ``prices_num``. 

170 mu: Expected returns aligned to ``prices_num``. 

171 vola_np: Per-asset EWMA volatility aligned to ``prices_num``. 

172 risk_pos_np: Output risk-position buffer, mutated in place. 

173 cash_pos_np: Output cash-position buffer, mutated in place. 

174 row_of: Map from a ``cor`` key back to its row index. 

175 shrink: Identity-shrinkage weight in ``[0, 1]`` passed to :func:`_risk_position`. 

176 

177 Example: 

178 >>> import numpy as np 

179 >>> from tinycta._kernel import forward_walk 

180 >>> prices = np.array([[100.0, 50.0], [101.0, 50.5], [102.0, 50.0]]) 

181 >>> returns = np.zeros_like(prices) 

182 >>> returns[1:] = prices[1:] / prices[:-1] - 1.0 

183 >>> mu = np.array([[1.0, 0.0], [1.0, 0.0], [1.0, 0.0]]) 

184 >>> vola = np.full((3, 2), 0.1) 

185 >>> risk_pos = np.full((3, 2), np.nan) 

186 >>> cash_pos = np.full((3, 2), np.nan) 

187 

188 Only rows named by ``cor`` are walked; here the first row is warmup and is 

189 left untouched. The function returns nothing and writes into the buffers: 

190 

191 >>> forward_walk( 

192 ... {1: np.eye(2), 2: np.eye(2)}, 

193 ... prices, returns, mu, vola, risk_pos, cash_pos, 

194 ... row_of={1: 1, 2: 2}, 

195 ... shrink=1.0, 

196 ... ) is None 

197 True 

198 >>> risk_pos[0] 

199 array([nan, nan]) 

200 

201 The first walked row starts from a profit variance of 1.0, so its risk 

202 position is the raw solve, and the cash position divides it by volatility: 

203 

204 >>> risk_pos[1] 

205 array([1., 0.]) 

206 >>> cash_pos[1] 

207 array([10., 0.]) 

208 

209 By the next row the realised P&L has updated the profit-variance estimate, 

210 which rescales the position: 

211 

212 >>> risk_pos[2].round(4) 

213 array([1.01, 0. ]) 

214 """ 

215 profit_variance = 1.0 

216 lamb = 0.99 

217 

218 prev_row: int | None = None 

219 for t in cor: 

220 row = row_of[t] 

221 mask = np.isfinite(prices_num[row]) 

222 

223 if prev_row is not None: 

224 ret_mask = np.isfinite(returns_num[row]) & mask 

225 if ret_mask.any(): 

226 cash_pos_np[prev_row] = risk_pos_np[prev_row] / vola_np[prev_row] 

227 profit_variance = _update_profit_variance( 

228 profit_variance, cash_pos_np[prev_row], returns_num[row], ret_mask, lamb 

229 ) 

230 

231 if mask.any(): 

232 pos = _risk_position(cor[t], mu[row], mask, shrink) 

233 risk_pos_np[row, mask] = pos / profit_variance 

234 cash_pos_np[row, mask] = risk_pos_np[row, mask] / vola_np[row, mask] 

235 

236 prev_row = row