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

54 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 04:11 +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. A temporal column is 

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

123 risk_position: Risk units per asset aligned with prices. 

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

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

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

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

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

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

130 also raise a ``ValueError``. 

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

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

133 value before dividing the risk position, preventing 

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

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

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

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

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

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

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

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

142 cost_model: Optional `CostModel` 

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

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

145 parameters above. 

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

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

148 Used as the default by `deduct_management_fee`. 

149 

150 Returns: 

151 A Portfolio instance whose cash positions are risk_position 

152 divided by EWMA volatility. 

153 

154 Raises: 

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

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

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

158 PositionExprColumnError: If *risk_position* is an expression that 

159 creates columns not present in *prices*. 

160 """ 

161 if isinstance(risk_position, pl.Expr): 

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

163 if cost_model is not None: 

164 cost_per_unit = cost_model.cost_per_unit 

165 cost_bps = cost_model.cost_bps 

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

167 

168 _validate_vol_cap(vol_cap) 

169 _validate_vola(vola, assets) 

170 

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

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

173 if isinstance(vola, dict): 

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

175 return int(vola) 

176 

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

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

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

180 if vol_cap is not None: 

181 vol = vol.clip(lower_bound=vol_cap) 

182 return vol 

183 

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

185 return cls.from_cash_position( 

186 prices=prices, 

187 cash_position=cash_position, 

188 aum=aum, 

189 cost_per_unit=cost_per_unit, 

190 cost_bps=cost_bps, 

191 annual_fee=annual_fee, 

192 ) 

193 

194 @classmethod 

195 def from_position( 

196 cls, 

197 prices: pl.DataFrame, 

198 position: pl.DataFrame | pl.Expr, 

199 aum: float, 

200 cost_per_unit: float = 0.0, 

201 cost_bps: float = 0.0, 

202 cost_model: CostModel | None = None, 

203 annual_fee: float = 0.0, 

204 ) -> Self: 

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

206 

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

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

209 :py`from_cash_position`. 

210 

211 Args: 

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

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

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

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

216 through unchanged. 

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

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

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

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

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

222 cost_model: Optional `CostModel` instance. 

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

224 take precedence over the individual parameters above. 

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

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

227 Used as the default by `deduct_management_fee`. 

228 

229 Returns: 

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

231 

232 Raises: 

233 PositionExprColumnError: If *position* is an expression that 

234 creates columns not present in *prices*. 

235 

236 Examples: 

237 >>> import polars as pl 

238 >>> from jquantstats.portfolio import Portfolio 

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

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

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

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

243 [1000.0, 1100.0, 1050.0] 

244 """ 

245 if isinstance(position, pl.Expr): 

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

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

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

249 return cls.from_cash_position( 

250 prices=prices, 

251 cash_position=cash_position, 

252 aum=aum, 

253 cost_per_unit=cost_per_unit, 

254 cost_bps=cost_bps, 

255 cost_model=cost_model, 

256 annual_fee=annual_fee, 

257 )