Coverage for src/jquantstats/_portfolio_transform.py: 100%

67 statements  

« prev     ^ index     » next       coverage.py v7.17.1, created at 2026-11-03 15:52 +0000

1"""Range/lag/smoothing transforms and correlation mixin for Portfolio. 

2 

3`PortfolioTransformMixin` groups the methods that derive a *new* Portfolio 

4from an existing one (`truncate`, `lag`, `smoothed_holding`) plus the 

5`correlation` utility. New portfolios are built through 

6``type(self).from_cash_position`` so the transforms inherit the standard 

7construction path and return the concrete ``Self`` type. 

8""" 

9 

10from __future__ import annotations 

11 

12from datetime import date, datetime 

13from typing import TYPE_CHECKING, Self, cast 

14 

15import polars as pl 

16import polars.selectors as cs 

17 

18from ._portfolio_base import _PortfolioMembers 

19 

20if TYPE_CHECKING: 

21 from ._cost_model import CostModel 

22from ._truncate import resolve_bounds 

23 

24 

25class PortfolioTransformMixin(_PortfolioMembers): 

26 """Mixin providing range/lag/smoothing transforms and correlation for Portfolio.""" 

27 

28 if TYPE_CHECKING: 

29 

30 @classmethod 

31 def from_cash_position( 

32 cls, 

33 prices: pl.DataFrame, 

34 cash_position: pl.DataFrame, 

35 aum: float, 

36 cost_per_unit: float = 0.0, 

37 cost_bps: float = 0.0, 

38 cost_model: CostModel | None = None, 

39 annual_fee: float = 0.0, 

40 compounding: bool = False, 

41 realized_costs: pl.DataFrame | None = None, 

42 ) -> Self: 

43 """Create a Portfolio directly from cash positions aligned with prices.""" 

44 ... 

45 

46 # ── Shared construction helpers ──────────────────────────────────────────── 

47 

48 def _rebuild( 

49 self, 

50 cash_position: pl.DataFrame, 

51 prices: pl.DataFrame | None = None, 

52 aum: float | None = None, 

53 realized_costs: pl.DataFrame | None = None, 

54 ) -> Self: 

55 """Build a new Portfolio of the same concrete type from derived frames. 

56 

57 Every transform in this mixin ends the same way: hand the derived 

58 frames back to ``from_cash_position`` while carrying ``aum``, both 

59 cost parameters, the management fee and the ``compounding`` flag 

60 across unchanged. Centralising that here keeps a new construction 

61 parameter from having to be threaded through each transform 

62 individually. 

63 

64 Realised costs are *not* carried by default: they were paid on the 

65 original trades, and a transform that changes the positions trades 

66 differently. Only `truncate`, which keeps the trades, passes them on. 

67 

68 Args: 

69 cash_position: The derived cash-position frame. 

70 prices: The derived price frame; defaults to the current prices, 

71 which the lag and smoothing transforms leave untouched. 

72 aum: Starting capital of the new portfolio; defaults to the 

73 current ``aum``. 

74 realized_costs: Realised costs of the new portfolio; defaults to 

75 None. 

76 

77 Returns: 

78 A new Portfolio of the same concrete type. 

79 """ 

80 return type(self).from_cash_position( 

81 prices=self.prices if prices is None else prices, 

82 cash_position=cash_position, 

83 aum=self.aum if aum is None else aum, 

84 cost_per_unit=self.cost_per_unit, 

85 cost_bps=self.cost_bps, 

86 annual_fee=self.annual_fee, 

87 compounding=self.compounding, 

88 realized_costs=realized_costs, 

89 ) 

90 

91 @property 

92 def _numeric_assets(self) -> list[str]: 

93 """Names of the numeric asset columns in the cash-position frame. 

94 

95 Excludes ``'date'`` and any non-numeric column, so column-wise 

96 transforms touch only the asset series. 

97 

98 Returns: 

99 The numeric asset column names, in frame order. 

100 """ 

101 return [c for c in self.cashposition.columns if c != "date" and self.cashposition[c].dtype.is_numeric()] 

102 

103 @staticmethod 

104 def _date_range_mask( 

105 start: date | datetime | str | int | None, 

106 end: date | datetime | str | int | None, 

107 ) -> pl.Expr: 

108 """Build the inclusive ``[start, end]`` filter over the ``'date'`` column. 

109 

110 Args: 

111 start: Optional inclusive lower bound; no lower bound when None. 

112 end: Optional inclusive upper bound; no upper bound when None. 

113 

114 Returns: 

115 A boolean Polars expression, ``lit(True)`` when both bounds are None. 

116 """ 

117 cond = pl.lit(True) 

118 if start is not None: 

119 cond = cond & (pl.col("date") >= pl.lit(start)) 

120 if end is not None: 

121 cond = cond & (pl.col("date") <= pl.lit(end)) 

122 return cond 

123 

124 @staticmethod 

125 def _row_slice_bounds(start: int | None, end: int | None, height: int) -> tuple[int, int]: 

126 """Resolve integer row bounds into an ``(offset, length)`` slice. 

127 

128 Bounds arrive already validated by `resolve_bounds`, so this only 

129 substitutes the open-ended defaults. 

130 

131 Args: 

132 start: Optional inclusive lower row index; 0 when None. 

133 end: Optional inclusive upper row index; the last row when None. 

134 height: Row count of the frame being sliced. 

135 

136 Returns: 

137 The ``(offset, length)`` pair to pass to ``DataFrame.slice``. 

138 ``length`` is clamped at 0 so an inverted range yields an empty 

139 frame rather than a negative slice. 

140 """ 

141 row_start = start if start is not None else 0 

142 row_end = end + 1 if end is not None else height 

143 return row_start, max(0, row_end - row_start) 

144 

145 # ── Transforms ───────────────────────────────────────────────────────────── 

146 

147 def truncate( 

148 self, 

149 start: date | datetime | str | int | None = None, 

150 end: date | datetime | str | int | None = None, 

151 ) -> Self: 

152 """Return a new Portfolio truncated to the inclusive [start, end] range. 

153 

154 **The bound type picks the axis.** A ``date``, ``datetime`` or ISO-8601 

155 string is compared against the ``'date'`` column; an ``int`` is a 0-based 

156 row index and slices positionally. Row indices work on a dated portfolio 

157 too — ``truncate(start=10)`` drops the first ten rows — but the two kinds 

158 cannot be combined in one call. 

159 

160 A portfolio with no ``'date'`` column accepts row indices only. 

161 

162 In all cases the ``aum`` value is preserved. 

163 

164 Args: 

165 start: Optional inclusive lower bound. A ``date``/``datetime``, an 

166 ISO-8601 string, or an ``int`` row index; ``int`` only when there 

167 is no ``'date'`` column. 

168 end: Optional inclusive upper bound. Same type rules as ``start``, 

169 and must address the same axis. 

170 

171 Returns: 

172 A new Portfolio instance with prices and cash positions filtered 

173 to the specified range. 

174 

175 Raises: 

176 IntegerIndexBoundError: When the portfolio has no ``'date'`` column 

177 and a bound is not an ``int``. 

178 InvalidTruncateBoundError: When a bound is of an unsupported type, 

179 or is a string that is not ISO-8601. 

180 MixedTruncateBoundsError: When one bound is a row index and the 

181 other a date. 

182 """ 

183 mode, lower, upper = resolve_bounds(start, end, temporal="date" in self.prices.columns) 

184 

185 rows = self.prices.with_row_index("_row") 

186 if mode == "dates": 

187 rows = rows.filter(self._date_range_mask(lower, upper)) 

188 else: 

189 # "none" resolves to a full-width slice, so it needs no separate branch. 

190 # The casts record what resolve_bounds guarantees for these modes but 

191 # cannot express in its return type. 

192 offset, length = self._row_slice_bounds( 

193 cast("int | None", lower), cast("int | None", upper), self.prices.height 

194 ) 

195 rows = rows.slice(offset, length) 

196 kept = rows["_row"] 

197 pr = self.prices[kept] 

198 cp = self.cashposition[kept] 

199 if kept.is_empty(): 

200 return self._rebuild(prices=pr, cash_position=cp) 

201 

202 # A compounding book sized its positions off the NAV it had reached, so 

203 # the window starts from that NAV rather than from the original aum. 

204 aum = float(self.nav_accumulated["NAV_accumulated"][kept[0]]) if self.compounding else self.aum 

205 costs = None 

206 if self.realized_costs is not None: 

207 # The first kept row's trades happened before the window opens: they 

208 # are already inside its starting capital. 

209 is_first = pl.int_range(pl.len()) == 0 

210 costs = self.realized_costs[kept] 

211 costs = costs.with_columns( 

212 pl.when(is_first).then(0.0).otherwise(pl.col(c)).alias(c) for c in self.assets if c in costs.columns 

213 ) 

214 return self._rebuild(prices=pr, cash_position=cp, aum=aum, realized_costs=costs) 

215 

216 def lag(self, n: int) -> Self: 

217 """Return a new Portfolio with cash positions lagged by ``n`` steps. 

218 

219 This method shifts the numeric asset columns in the cashposition 

220 DataFrame by ``n`` rows, preserving the ``'date'`` column and any 

221 non-numeric columns unchanged. Positive ``n`` delays weights (moves 

222 them down); negative ``n`` leads them (moves them up); ``n == 0`` 

223 returns the current portfolio unchanged. 

224 

225 Notes: 

226 Missing values introduced by the shift are left as nulls; 

227 downstream profit computation already guards and treats nulls as 

228 zero when multiplying by returns. 

229 

230 Args: 

231 n: Number of rows to shift (can be negative, zero, or positive). 

232 

233 Returns: 

234 A new Portfolio instance with lagged cash positions and the same 

235 prices/AUM as the original. 

236 """ 

237 if not isinstance(n, int): 

238 raise TypeError 

239 if n == 0: 

240 return self 

241 

242 cp_lagged = self.cashposition.with_columns(pl.col(c).shift(n) for c in self._numeric_assets) 

243 return self._rebuild(cash_position=cp_lagged) 

244 

245 def smoothed_holding(self, n: int) -> Self: 

246 """Return a new Portfolio with cash positions smoothed by a rolling mean. 

247 

248 Applies a trailing window average over the last ``n`` steps for each 

249 numeric asset column (excluding ``'date'``). The window length is 

250 ``n + 1`` so that: 

251 

252 - n=0 returns the original weights (no smoothing), 

253 - n=1 averages the current and previous weights, 

254 - n=k averages the current and last k weights. 

255 

256 Args: 

257 n: Non-negative integer specifying how many previous steps to 

258 include. 

259 

260 Returns: 

261 A new Portfolio with smoothed cash positions and the same 

262 prices/AUM. 

263 """ 

264 if not isinstance(n, int): 

265 raise TypeError(f"n must be an integer, got {type(n).__name__}") # noqa: TRY003 

266 if n < 0: 

267 raise ValueError(f"n must be a non-negative integer, got {n}") # noqa: TRY003 

268 if n == 0: 

269 return self 

270 

271 window = n + 1 

272 cp_smoothed = self.cashposition.with_columns( 

273 pl.col(c).rolling_mean(window_size=window, min_samples=1).alias(c) for c in self._numeric_assets 

274 ) 

275 return self._rebuild(cash_position=cp_smoothed) 

276 

277 # ── Utility ──────────────────────────────────────────────────────────────── 

278 

279 def correlation(self, frame: pl.DataFrame, name: str = "portfolio") -> pl.DataFrame: 

280 """Compute a correlation matrix of asset returns plus the portfolio. 

281 

282 Computes percentage changes for all numeric columns in ``frame``, 

283 appends the portfolio profit series under the provided ``name``, and 

284 returns the Pearson correlation matrix across all numeric columns. 

285 

286 Args: 

287 frame: A Polars DataFrame containing at least the asset price 

288 columns (and a date column which will be ignored if 

289 non-numeric). 

290 name: The column name to use when adding the portfolio profit 

291 series to the input frame. 

292 

293 Returns: 

294 A square Polars DataFrame where each cell is the correlation 

295 between a pair of series (values in [-1, 1]). 

296 """ 

297 p = frame.with_columns(cs.by_dtype(pl.Float32, pl.Float64).pct_change()) 

298 p = p.with_columns(pl.Series(name, self.profit["profit"])) 

299 corr_matrix = p.select(cs.numeric()).fill_null(0.0).corr() 

300 return corr_matrix