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

61 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04: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 

14 

15import polars as pl 

16import polars.selectors as cs 

17 

18from ._portfolio_base import _PortfolioMembers 

19from .exceptions import IntegerIndexBoundError 

20 

21 

22class PortfolioTransformMixin(_PortfolioMembers): 

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

24 

25 if TYPE_CHECKING: 

26 

27 @classmethod 

28 def from_cash_position( 

29 cls, 

30 prices: pl.DataFrame, 

31 cash_position: pl.DataFrame, 

32 aum: float, 

33 cost_per_unit: float = 0.0, 

34 cost_bps: float = 0.0, 

35 ) -> Self: 

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

37 ... 

38 

39 # ── Shared construction helpers ──────────────────────────────────────────── 

40 

41 def _rebuild(self, cash_position: pl.DataFrame, prices: pl.DataFrame | None = None) -> Self: 

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

43 

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

45 frames back to ``from_cash_position`` while carrying ``aum`` and both 

46 cost parameters across unchanged. Centralising that here keeps a new 

47 construction parameter from having to be threaded through each 

48 transform individually. 

49 

50 Args: 

51 cash_position: The derived cash-position frame. 

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

53 which the lag and smoothing transforms leave untouched. 

54 

55 Returns: 

56 A new Portfolio of the same concrete type. 

57 """ 

58 return type(self).from_cash_position( 

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

60 cash_position=cash_position, 

61 aum=self.aum, 

62 cost_per_unit=self.cost_per_unit, 

63 cost_bps=self.cost_bps, 

64 ) 

65 

66 @property 

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

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

69 

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

71 transforms touch only the asset series. 

72 

73 Returns: 

74 The numeric asset column names, in frame order. 

75 """ 

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

77 

78 @staticmethod 

79 def _date_range_mask( 

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

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

82 ) -> pl.Expr: 

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

84 

85 Args: 

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

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

88 

89 Returns: 

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

91 """ 

92 cond = pl.lit(True) 

93 if start is not None: 

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

95 if end is not None: 

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

97 return cond 

98 

99 @staticmethod 

100 def _row_slice_bounds( 

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

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

103 height: int, 

104 ) -> tuple[int, int]: 

105 """Resolve integer row bounds into a ``(offset, length)`` slice. 

106 

107 Used when the portfolio has no ``'date'`` column and truncation falls 

108 back to 0-based row indexing. 

109 

110 Args: 

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

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

113 height: Row count of the frame being sliced. 

114 

115 Returns: 

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

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

118 frame rather than a negative slice. 

119 

120 Raises: 

121 IntegerIndexBoundError: When a supplied bound is not an integer. 

122 """ 

123 if start is not None and not isinstance(start, int): 

124 raise IntegerIndexBoundError("start", type(start).__name__) 

125 if end is not None and not isinstance(end, int): 

126 raise IntegerIndexBoundError("end", type(end).__name__) 

127 row_start = int(start) if start is not None else 0 

128 row_end = int(end) + 1 if end is not None else height 

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

130 

131 # ── Transforms ───────────────────────────────────────────────────────────── 

132 

133 def truncate( 

134 self, 

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

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

137 ) -> Self: 

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

139 

140 When a ``'date'`` column is present in both prices and cash positions, 

141 truncation is performed by comparing the ``'date'`` column against 

142 ``start`` and ``end`` (which should be date/datetime values or strings 

143 parseable by Polars). 

144 

145 When the ``'date'`` column is absent, integer-based row slicing is 

146 used instead. In this case ``start`` and ``end`` must be non-negative 

147 integers representing 0-based row indices. Passing non-integer bounds 

148 to an integer-indexed portfolio raises `TypeError`. 

149 

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

151 

152 Args: 

153 start: Optional lower bound (inclusive). A date/datetime or 

154 Polars-parseable string when a ``'date'`` column exists; a 

155 non-negative int row index when the data has no ``'date'`` 

156 column. 

157 end: Optional upper bound (inclusive). Same type rules as 

158 ``start``. 

159 

160 Returns: 

161 A new Portfolio instance with prices and cash positions filtered 

162 to the specified range. 

163 

164 Raises: 

165 TypeError: When the portfolio has no ``'date'`` column and a 

166 non-integer bound is supplied. 

167 """ 

168 if "date" in self.prices.columns: 

169 cond = self._date_range_mask(start, end) 

170 pr = self.prices.filter(cond) 

171 cp = self.cashposition.filter(cond) 

172 else: 

173 offset, length = self._row_slice_bounds(start, end, self.prices.height) 

174 pr = self.prices.slice(offset, length) 

175 cp = self.cashposition.slice(offset, length) 

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

177 

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

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

180 

181 This method shifts the numeric asset columns in the cashposition 

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

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

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

185 returns the current portfolio unchanged. 

186 

187 Notes: 

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

189 downstream profit computation already guards and treats nulls as 

190 zero when multiplying by returns. 

191 

192 Args: 

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

194 

195 Returns: 

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

197 prices/AUM as the original. 

198 """ 

199 if not isinstance(n, int): 

200 raise TypeError 

201 if n == 0: 

202 return self 

203 

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

205 return self._rebuild(cash_position=cp_lagged) 

206 

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

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

209 

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

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

212 ``n + 1`` so that: 

213 

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

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

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

217 

218 Args: 

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

220 include. 

221 

222 Returns: 

223 A new Portfolio with smoothed cash positions and the same 

224 prices/AUM. 

225 """ 

226 if not isinstance(n, int): 

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

228 if n < 0: 

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

230 if n == 0: 

231 return self 

232 

233 window = n + 1 

234 cp_smoothed = self.cashposition.with_columns( 

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

236 ) 

237 return self._rebuild(cash_position=cp_smoothed) 

238 

239 # ── Utility ──────────────────────────────────────────────────────────────── 

240 

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

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

243 

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

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

246 returns the Pearson correlation matrix across all numeric columns. 

247 

248 Args: 

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

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

251 non-numeric). 

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

253 series to the input frame. 

254 

255 Returns: 

256 A square Polars DataFrame where each cell is the correlation 

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

258 """ 

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

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

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

262 return corr_matrix