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

54 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04:52 +0000

1"""Factory classmethods for constructing Portfolio objects. 

2 

3`PortfolioConstructorMixin` provides the `from_risk_position` and 

4`from_position` entry points (plus the shared `_evaluate_position_expr` 

5helper). Both funnel through ``cls.from_cash_position`` — defined on the 

6`Portfolio` dataclass — so the actual instantiation happens in one place. 

7""" 

8 

9from __future__ import annotations 

10 

11from typing import TYPE_CHECKING, Self 

12 

13import polars as pl 

14 

15from ._cost_model import CostModel 

16from ._portfolio_base import _PortfolioMembers 

17from .exceptions import PositionExprColumnError 

18 

19 

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

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

22 

23 Args: 

24 prices: Price levels per asset over time. 

25 expr: Polars expression producing positions, evaluated via 

26 ``prices.with_columns(expr)``. 

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

28 the error message). 

29 

30 Returns: 

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

32 as *prices*. 

33 

34 Raises: 

35 PositionExprColumnError: If the expression created columns that do 

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

37 columns untouched, silently treating raw prices as positions. 

38 """ 

39 evaluated = prices.with_columns(expr) 

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

41 if extra: 

42 raise PositionExprColumnError(param, extra) 

43 return evaluated 

44 

45 

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

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

48 

49 Args: 

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

51 

52 Raises: 

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

54 """ 

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

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

57 

58 

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

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

61 

62 Args: 

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

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

65 

66 Raises: 

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

68 not a positive integer. 

69 """ 

70 if isinstance(vola, dict): 

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

72 if unknown: 

73 raise ValueError( # noqa: TRY003 

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

75 ) 

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

77 if int(span) <= 0: 

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

79 elif int(vola) <= 0: 

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

81 

82 

83class PortfolioConstructorMixin(_PortfolioMembers): 

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

85 

86 if TYPE_CHECKING: 

87 

88 @classmethod 

89 def from_cash_position( 

90 cls, 

91 prices: pl.DataFrame, 

92 cash_position: pl.DataFrame, 

93 aum: float, 

94 cost_per_unit: float = 0.0, 

95 cost_bps: float = 0.0, 

96 cost_model: CostModel | None = None, 

97 annual_fee: float = 0.0, 

98 ) -> Self: 

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

100 ... 

101 

102 @classmethod 

103 def from_risk_position( 

104 cls, 

105 prices: pl.DataFrame, 

106 risk_position: pl.DataFrame | pl.Expr, 

107 aum: float, 

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

109 vol_cap: float | None = None, 

110 cost_per_unit: float = 0.0, 

111 cost_bps: float = 0.0, 

112 cost_model: CostModel | None = None, 

113 annual_fee: float = 0.0, 

114 ) -> Self: 

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

116 

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

118 derived from the corresponding price series. 

119 

120 Args: 

121 prices: Price levels per asset over time (may include a date column). 

122 risk_position: Risk units per asset aligned with prices. 

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

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

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

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

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

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

129 also raise a ``ValueError``. 

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

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

132 value before dividing the risk position, preventing 

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

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

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

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

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

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

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

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

141 cost_model: Optional `CostModel` 

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

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

144 parameters above. 

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

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

147 Used as the default by `deduct_management_fee`. 

148 

149 Returns: 

150 A Portfolio instance whose cash positions are risk_position 

151 divided by EWMA volatility. 

152 

153 Raises: 

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

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

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

157 PositionExprColumnError: If *risk_position* is an expression that 

158 creates columns not present in *prices*. 

159 """ 

160 if isinstance(risk_position, pl.Expr): 

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

162 if cost_model is not None: 

163 cost_per_unit = cost_model.cost_per_unit 

164 cost_bps = cost_model.cost_bps 

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

166 

167 _validate_vol_cap(vol_cap) 

168 _validate_vola(vola, assets) 

169 

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

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

172 if isinstance(vola, dict): 

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

174 return int(vola) 

175 

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

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

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

179 if vol_cap is not None: 

180 vol = vol.clip(lower_bound=vol_cap) 

181 return vol 

182 

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

184 return cls.from_cash_position( 

185 prices=prices, 

186 cash_position=cash_position, 

187 aum=aum, 

188 cost_per_unit=cost_per_unit, 

189 cost_bps=cost_bps, 

190 annual_fee=annual_fee, 

191 ) 

192 

193 @classmethod 

194 def from_position( 

195 cls, 

196 prices: pl.DataFrame, 

197 position: pl.DataFrame | pl.Expr, 

198 aum: float, 

199 cost_per_unit: float = 0.0, 

200 cost_bps: float = 0.0, 

201 cost_model: CostModel | None = None, 

202 annual_fee: float = 0.0, 

203 ) -> Self: 

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

205 

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

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

208 :py`from_cash_position`. 

209 

210 Args: 

211 prices: Price levels per asset over time (may include a date column). 

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

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

214 through unchanged. 

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

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

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

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

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

220 cost_model: Optional `CostModel` instance. 

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

222 take precedence over the individual parameters above. 

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

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

225 Used as the default by `deduct_management_fee`. 

226 

227 Returns: 

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

229 

230 Raises: 

231 PositionExprColumnError: If *position* is an expression that 

232 creates columns not present in *prices*. 

233 

234 Examples: 

235 >>> import polars as pl 

236 >>> from jquantstats.portfolio import Portfolio 

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

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

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

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

241 [1000.0, 1100.0, 1050.0] 

242 """ 

243 if isinstance(position, pl.Expr): 

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

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

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

247 return cls.from_cash_position( 

248 prices=prices, 

249 cash_position=cash_position, 

250 aum=aum, 

251 cost_per_unit=cost_per_unit, 

252 cost_bps=cost_bps, 

253 cost_model=cost_model, 

254 annual_fee=annual_fee, 

255 )