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

86 statements  

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

1"""Factory classmethods for constructing Portfolio objects. 

2 

3`PortfolioConstructorMixin` provides the `from_risk_position`, 

4`from_position` and `from_weights` entry points (plus the shared 

5`_evaluate_position_expr` helper). All funnel through 

6``cls.from_cash_position`` — defined on the `Portfolio` dataclass — so the 

7actual instantiation happens in one place. 

8""" 

9 

10from __future__ import annotations 

11 

12import math 

13from typing import TYPE_CHECKING, Self 

14 

15import numpy as np 

16import polars as pl 

17 

18from ._cost_model import CostModel 

19from ._portfolio_base import _PortfolioMembers 

20from ._rebalance import simulate_rebalancing 

21from .exceptions import NegativeCostBpsError, PositionExprColumnError, RowCountMismatchError, WeightsColumnError 

22 

23 

24def _evaluate_position_expr(prices: pl.DataFrame, expr: pl.Expr, param: str) -> pl.DataFrame: 

25 """Evaluate a position expression against *prices* and validate the result. 

26 

27 Args: 

28 prices: Price levels per asset over time. 

29 expr: Polars expression producing positions, evaluated via 

30 ``prices.with_columns(expr)``. 

31 param: Name of the parameter the expression was passed as (used in 

32 the error message). 

33 

34 Returns: 

35 The evaluated positions frame, guaranteed to have the same columns 

36 as *prices*. 

37 

38 Raises: 

39 PositionExprColumnError: If the expression created columns that do 

40 not exist in *prices* — those would leave the original asset 

41 columns untouched, silently treating raw prices as positions. 

42 """ 

43 evaluated = prices.with_columns(expr) 

44 extra = [c for c in evaluated.columns if c not in prices.columns] 

45 if extra: 

46 raise PositionExprColumnError(param, extra) 

47 return evaluated 

48 

49 

50def _validate_cost_bps(cost_bps: float) -> float: 

51 """Validate a one-way trading cost in basis points. 

52 

53 Args: 

54 cost_bps: Candidate cost. 

55 

56 Returns: 

57 The cost as a float. 

58 

59 Raises: 

60 TypeError: If *cost_bps* is not a number. 

61 ValueError: If *cost_bps* is not finite. 

62 NegativeCostBpsError: If *cost_bps* is negative. 

63 """ 

64 if isinstance(cost_bps, bool) or not isinstance(cost_bps, int | float): 

65 raise TypeError(f"cost_bps must be a number, got {type(cost_bps).__name__}") # noqa: TRY003 

66 if not math.isfinite(cost_bps): 

67 raise ValueError(f"cost_bps must be finite, got {cost_bps}") # noqa: TRY003 

68 if cost_bps < 0: 

69 raise NegativeCostBpsError(cost_bps) 

70 return float(cost_bps) 

71 

72 

73def _weights_matrix(prices: pl.DataFrame, weights: pl.DataFrame, assets: list[str]) -> np.ndarray: 

74 """Align *weights* to the asset columns of *prices* as a ``(t, n)`` array. 

75 

76 Args: 

77 prices: Price levels per asset over time. 

78 weights: Target weights, null on rows that do not rebalance. 

79 assets: Numeric asset columns of *prices*, in order. 

80 

81 Returns: 

82 Float array with NaN wherever no weight is given, including for an 

83 asset that has no column in *weights* at all. 

84 

85 Raises: 

86 RowCountMismatchError: If *weights* and *prices* differ in length. 

87 WeightsColumnError: If *weights* has a numeric column that is not an 

88 asset of *prices*. 

89 """ 

90 if weights.height != prices.height: 

91 raise RowCountMismatchError(prices.height, weights.height, "weights") 

92 numeric = [c for c, dtype in weights.schema.items() if dtype.is_numeric()] 

93 unknown = [c for c in numeric if c not in assets] 

94 if unknown: 

95 raise WeightsColumnError(unknown) 

96 aligned = pl.select( 

97 (weights[a] if a in numeric else pl.repeat(None, prices.height, eager=True)).cast(pl.Float64).alias(a) 

98 for a in assets 

99 ) 

100 return aligned.fill_nan(None).to_numpy().astype(float) 

101 

102 

103def _validate_vol_cap(vol_cap: float | None) -> None: 

104 """Validate the optional ``vol_cap`` lower bound. 

105 

106 Args: 

107 vol_cap: Candidate lower bound for the EWMA volatility estimate. 

108 

109 Raises: 

110 ValueError: If *vol_cap* is provided but not strictly positive. 

111 """ 

112 if vol_cap is not None and vol_cap <= 0: 

113 raise ValueError(f"vol_cap must be a positive number when provided, got {vol_cap!r}") # noqa: TRY003 

114 

115 

116def _validate_vola(vola: int | dict[str, int], assets: list[str]) -> None: 

117 """Validate the EWMA ``vola`` span specification against the asset columns. 

118 

119 Args: 

120 vola: A single span applied to every asset, or a per-asset span dict. 

121 assets: Numeric column names available in the price frame. 

122 

123 Raises: 

124 ValueError: If a dict key matches no numeric column, or any span is 

125 not a positive integer. 

126 """ 

127 if isinstance(vola, dict): 

128 unknown = set(vola.keys()) - set(assets) 

129 if unknown: 

130 raise ValueError( # noqa: TRY003 

131 f"vola dict contains keys that do not match any numeric column in prices: {sorted(unknown)}" 

132 ) 

133 for asset, span in vola.items(): 

134 if int(span) <= 0: 

135 raise ValueError(f"vola span for '{asset}' must be a positive integer, got {span!r}") # noqa: TRY003 

136 elif int(vola) <= 0: 

137 raise ValueError(f"vola span must be a positive integer, got {vola!r}") # noqa: TRY003 

138 

139 

140class PortfolioConstructorMixin(_PortfolioMembers): 

141 """Mixin providing the risk- and notional-position factory classmethods.""" 

142 

143 if TYPE_CHECKING: 

144 

145 @classmethod 

146 def from_cash_position( 

147 cls, 

148 prices: pl.DataFrame, 

149 cash_position: pl.DataFrame, 

150 aum: float, 

151 cost_per_unit: float = 0.0, 

152 cost_bps: float = 0.0, 

153 cost_model: CostModel | None = None, 

154 annual_fee: float = 0.0, 

155 compounding: bool = False, 

156 realized_costs: pl.DataFrame | None = None, 

157 ) -> Self: 

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

159 ... 

160 

161 @classmethod 

162 def from_risk_position( 

163 cls, 

164 prices: pl.DataFrame, 

165 risk_position: pl.DataFrame | pl.Expr, 

166 aum: float, 

167 vola: int | dict[str, int] = 32, 

168 vol_cap: float | None = None, 

169 cost_per_unit: float = 0.0, 

170 cost_bps: float = 0.0, 

171 cost_model: CostModel | None = None, 

172 annual_fee: float = 0.0, 

173 ) -> Self: 

174 """Create a Portfolio from per-asset risk positions. 

175 

176 De-volatizes each risk position using an EWMA volatility estimate 

177 derived from the corresponding price series. 

178 

179 Args: 

180 prices: Price levels per asset over time. A temporal column is 

181 normalised to ``'date'`` at construction, whatever it was named. 

182 risk_position: Risk units per asset aligned with prices. 

183 vola: EWMA lookback (span-equivalent) used to estimate volatility. 

184 Pass an ``int`` to apply the same span to every asset, or a 

185 ``dict[str, int]`` to set a per-asset span (assets absent from 

186 the dict default to ``32``). Every span value must be a 

187 positive integer; a ``ValueError`` is raised otherwise. Dict 

188 keys that do not correspond to any numeric column in *prices* 

189 also raise a ``ValueError``. 

190 vol_cap: Optional lower bound for the EWMA volatility estimate. 

191 When provided, the vol series is clipped from below at this 

192 value before dividing the risk position, preventing 

193 position blow-up in calm, low-volatility regimes. For 

194 example, ``vol_cap=0.05`` ensures annualised vol is never 

195 estimated below 5%. Must be positive when not ``None``. 

196 aum: Assets under management used as the base NAV offset. 

197 cost_per_unit: One-way trading cost per unit of position change. 

198 Defaults to 0.0 (no cost). Ignored when *cost_model* is given. 

199 cost_bps: One-way trading cost in basis points of AUM turnover. 

200 Defaults to 0.0 (no cost). Ignored when *cost_model* is given. 

201 cost_model: Optional `CostModel` 

202 instance. When supplied, its ``cost_per_unit`` and 

203 ``cost_bps`` values take precedence over the individual 

204 parameters above. 

205 annual_fee: Flat annual management fee as a fraction of AUM 

206 (e.g. 0.0085 for 85 bps p.a.). Defaults to 0.0 (no fee). 

207 Used as the default by `deduct_management_fee`. 

208 

209 Returns: 

210 A Portfolio instance whose cash positions are risk_position 

211 divided by EWMA volatility. 

212 

213 Raises: 

214 ValueError: If any span value in *vola* is ≤ 0, or if a key in a 

215 *vola* dict does not match any numeric column in *prices*, or 

216 if *vol_cap* is provided but is not positive. 

217 PositionExprColumnError: If *risk_position* is an expression that 

218 creates columns not present in *prices*. 

219 """ 

220 if isinstance(risk_position, pl.Expr): 

221 risk_position = _evaluate_position_expr(prices, risk_position, "risk_position") 

222 if cost_model is not None: 

223 cost_per_unit = cost_model.cost_per_unit 

224 cost_bps = cost_model.cost_bps 

225 assets = [col for col, dtype in prices.schema.items() if dtype.is_numeric()] 

226 

227 _validate_vol_cap(vol_cap) 

228 _validate_vola(vola, assets) 

229 

230 def _span(asset: str) -> int: 

231 """Return the EWMA span for *asset*, falling back to 32 if not specified.""" 

232 if isinstance(vola, dict): 

233 return int(vola.get(asset, 32)) 

234 return int(vola) 

235 

236 def _vol(asset: str) -> pl.Series: 

237 """Return the EWMA volatility series for *asset*, optionally clipped from below.""" 

238 vol = prices[asset].pct_change().ewm_std(com=_span(asset) - 1, adjust=True, min_samples=_span(asset)) 

239 if vol_cap is not None: 

240 vol = vol.clip(lower_bound=vol_cap) 

241 return vol 

242 

243 cash_position = risk_position.with_columns((pl.col(asset) / _vol(asset)).alias(asset) for asset in assets) 

244 return cls.from_cash_position( 

245 prices=prices, 

246 cash_position=cash_position, 

247 aum=aum, 

248 cost_per_unit=cost_per_unit, 

249 cost_bps=cost_bps, 

250 annual_fee=annual_fee, 

251 ) 

252 

253 @classmethod 

254 def from_position( 

255 cls, 

256 prices: pl.DataFrame, 

257 position: pl.DataFrame | pl.Expr, 

258 aum: float, 

259 cost_per_unit: float = 0.0, 

260 cost_bps: float = 0.0, 

261 cost_model: CostModel | None = None, 

262 annual_fee: float = 0.0, 

263 ) -> Self: 

264 """Create a Portfolio from share/unit positions. 

265 

266 Converts *position* (number of units held per asset) to cash exposure 

267 by multiplying element-wise with *prices*, then delegates to 

268 :py`from_cash_position`. 

269 

270 Args: 

271 prices: Price levels per asset over time. A temporal column is 

272 normalised to ``'date'`` at construction, whatever it was named. 

273 position: Number of units held per asset over time, aligned with 

274 *prices*. Non-numeric columns (e.g. ``'date'``) are passed 

275 through unchanged. 

276 aum: Assets under management used as the base NAV offset. 

277 cost_per_unit: One-way trading cost per unit of position change. 

278 Defaults to 0.0 (no cost). Ignored when *cost_model* is given. 

279 cost_bps: One-way trading cost in basis points of AUM turnover. 

280 Defaults to 0.0 (no cost). Ignored when *cost_model* is given. 

281 cost_model: Optional `CostModel` instance. 

282 When supplied, its ``cost_per_unit`` and ``cost_bps`` values 

283 take precedence over the individual parameters above. 

284 annual_fee: Flat annual management fee as a fraction of AUM 

285 (e.g. 0.0085 for 85 bps p.a.). Defaults to 0.0 (no fee). 

286 Used as the default by `deduct_management_fee`. 

287 

288 Returns: 

289 A Portfolio instance whose cash positions equal *position* x *prices*. 

290 

291 Raises: 

292 PositionExprColumnError: If *position* is an expression that 

293 creates columns not present in *prices*. 

294 

295 Examples: 

296 >>> import polars as pl 

297 >>> from jquantstats.portfolio import Portfolio 

298 >>> prices = pl.DataFrame({"A": [100.0, 110.0, 105.0]}) 

299 >>> pos = pl.DataFrame({"A": [10.0, 10.0, 10.0]}) 

300 >>> pf = Portfolio.from_position(prices=prices, position=pos, aum=1e6) 

301 >>> pf.cashposition["A"].to_list() 

302 [1000.0, 1100.0, 1050.0] 

303 """ 

304 if isinstance(position, pl.Expr): 

305 position = _evaluate_position_expr(prices, position, "position") 

306 assets = [col for col, dtype in prices.schema.items() if dtype.is_numeric()] 

307 cash_position = position.with_columns((pl.col(asset) * prices[asset]).alias(asset) for asset in assets) 

308 return cls.from_cash_position( 

309 prices=prices, 

310 cash_position=cash_position, 

311 aum=aum, 

312 cost_per_unit=cost_per_unit, 

313 cost_bps=cost_bps, 

314 cost_model=cost_model, 

315 annual_fee=annual_fee, 

316 ) 

317 

318 @classmethod 

319 def from_weights( 

320 cls, 

321 prices: pl.DataFrame, 

322 weights: pl.DataFrame | pl.Expr, 

323 aum: float, 

324 cost_bps: float = 0.0, 

325 compounding: bool = True, 

326 annual_fee: float = 0.0, 

327 ) -> Self: 

328 """Create a Portfolio that rebalances to target weights on sparse dates. 

329 

330 *weights* gives the target fraction of NAV per asset, but only on the 

331 rows where the portfolio rebalances — every other row is null. Between 

332 two rebalancings the book holds its **units**, so the realised weights 

333 drift with prices rather than staying at the targets. On a rebalancing 

334 row the units are reset to ``NAV * w / price``, where NAV is the value 

335 the drifted book has reached, and the trade pays ``cost_bps`` on its 

336 traded notional out of cash. Because each rebalancing is sized off a 

337 NAV that every earlier cost has already lowered, the book is simulated 

338 row by row rather than vectorised. 

339 

340 Weights are applied at the price of the row they appear on, matching 

341 the convention that a position set on row *t* earns the return from 

342 *t* to *t + 1*. Shift the weights first (``weights.shift(n)``) to 

343 trade them *n* rows later. Weights that sum to less than one leave the 

344 remainder in cash, which earns nothing; weights above one borrow it. 

345 

346 The trading costs are realised inside the simulation and stored as 

347 ``realized_costs``, so `profits`, NAV and `returns` are already net of 

348 them. The portfolio's own ``cost_bps`` is therefore left at zero: 

349 `cost_adjusted_returns` would otherwise charge the same trades twice. 

350 

351 Args: 

352 prices: Price levels per asset over time. A temporal column is 

353 normalised to ``'date'`` at construction, whatever it was named. 

354 weights: Target weights per asset, aligned row for row with 

355 *prices*, as a DataFrame or as an expression evaluated against 

356 *prices*. A row with at least one non-null weight is a 

357 rebalancing; within it, a null weight or a missing asset 

358 column means a target of zero. Non-numeric columns (e.g. 

359 ``'date'``) are ignored. 

360 aum: Starting capital, held in cash until the first rebalancing. 

361 cost_bps: One-way trading cost in basis points of traded notional. 

362 Defaults to 0.0 (no cost). 

363 compounding: When True (the default) each rebalancing is sized off 

364 the current NAV and `returns` are measured against the 

365 previous period's NAV. When False every rebalancing is sized 

366 off the constant *aum*, as a fixed-notional book, and 

367 `returns` are measured against *aum*. 

368 annual_fee: Flat annual management fee as a fraction of AUM, used 

369 as the default by `deduct_management_fee`. Defaults to 0.0. 

370 

371 Returns: 

372 A Portfolio whose cash positions are the simulated units times 

373 prices, with ``compounding`` set as requested and the costs paid 

374 on every trade in ``realized_costs``. 

375 

376 Raises: 

377 RowCountMismatchError: If *weights* and *prices* differ in length. 

378 WeightsColumnError: If *weights* has a numeric column that is not 

379 an asset of *prices*. 

380 PositionExprColumnError: If *weights* is an expression that creates 

381 columns not present in *prices*. 

382 RebalancePriceError: If an asset that is held or targeted has no 

383 finite, positive price on a rebalancing row. 

384 NegativeCostBpsError: If *cost_bps* is negative. 

385 

386 Examples: 

387 >>> import polars as pl 

388 >>> from jquantstats.portfolio import Portfolio 

389 >>> prices = pl.DataFrame({"A": [100.0, 110.0, 110.0], "B": [100.0, 90.0, 90.0]}) 

390 >>> weights = pl.DataFrame({"A": [0.5, None, 0.5], "B": [0.5, None, 0.5]}) 

391 >>> pf = Portfolio.from_weights(prices=prices, weights=weights, aum=1000.0) 

392 >>> [round(u, 4) for u in pf.units["A"].to_list()] # held through row 1, reset on row 2 

393 [5.0, 5.0, 4.5455] 

394 >>> [round(w, 2) for w in pf.weights["A"].to_list()] # drifted on row 1 

395 [0.5, 0.55, 0.5] 

396 """ 

397 if isinstance(weights, pl.Expr): 

398 weights = _evaluate_position_expr(prices, weights, "weights") 

399 cost_bps = _validate_cost_bps(cost_bps) 

400 assets = [col for col, dtype in prices.schema.items() if dtype.is_numeric()] 

401 target = _weights_matrix(prices, weights, assets) 

402 price_matrix = prices.select(pl.col(a).cast(pl.Float64) for a in assets).fill_nan(None).to_numpy().astype(float) 

403 

404 path = simulate_rebalancing( 

405 prices=price_matrix, 

406 weights=np.nan_to_num(target), 

407 is_rebalancing=np.asarray(~np.isnan(target).all(axis=1)), 

408 aum=aum, 

409 assets=assets, 

410 cost_bps=cost_bps, 

411 compounding=compounding, 

412 ) 

413 cash_position = prices.with_columns(pl.Series(a, path.units[:, i]) * pl.col(a) for i, a in enumerate(assets)) 

414 realized_costs = prices.with_columns(pl.Series(a, path.costs[:, i]) for i, a in enumerate(assets)) 

415 return cls.from_cash_position( 

416 prices=prices, 

417 cash_position=cash_position, 

418 aum=aum, 

419 annual_fee=annual_fee, 

420 compounding=compounding, 

421 realized_costs=realized_costs, 

422 )