Coverage for src/jquantstats/_utils/_portfolio.py: 100%

37 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-23 06:13 +0000

1"""Utility methods for Portfolio objects.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable, Hashable 

6 

7import numpy as np 

8import polars as pl 

9 

10from ._data import DataUtils 

11from ._protocol import PortfolioLike 

12 

13__all__ = ["PortfolioUtils"] 

14 

15 

16class PortfolioUtils: 

17 """Utility transforms and conversions for Portfolio objects. 

18 

19 Exposes the same API as `DataUtils` 

20 but is initialised from a `Portfolio` 

21 and routes all calls through ``portfolio.data``. 

22 """ 

23 

24 __slots__ = ("_portfolio",) 

25 

26 def __init__(self, portfolio: PortfolioLike) -> None: 

27 self._portfolio = portfolio 

28 

29 def __repr__(self) -> str: 

30 """Return a string representation of the PortfolioUtils object.""" 

31 return f"PortfolioUtils(assets={self._portfolio.assets})" 

32 

33 def _du(self) -> DataUtils: 

34 """Return a DataUtils instance backed by this portfolio's data bridge.""" 

35 return DataUtils(self._portfolio.data) 

36 

37 # ── delegated API (mirrors DataUtils) ───────────────────────────────────── 

38 

39 def to_prices(self, base: float = 1e5) -> pl.DataFrame: 

40 """Convert portfolio returns to a cumulative price series. 

41 

42 See `to_prices` for full 

43 documentation. 

44 

45 Args: 

46 base: Starting value for the price series. Defaults to ``1e5``. 

47 

48 Returns: 

49 DataFrame with date column (if present) and one price column per asset. 

50 

51 """ 

52 return self._du().to_prices(base=base) 

53 

54 def to_log_returns(self) -> pl.DataFrame: 

55 """Convert portfolio returns to log returns: ``ln(1 + r)``. 

56 

57 See `to_log_returns` for 

58 full documentation. 

59 

60 Returns: 

61 DataFrame of log returns. 

62 

63 """ 

64 return self._du().to_log_returns() 

65 

66 def log_returns(self) -> pl.DataFrame: 

67 """Alias for `to_log_returns`. 

68 

69 Returns: 

70 DataFrame of log returns. 

71 

72 """ 

73 return self._du().log_returns() 

74 

75 def rebase(self, base: float = 100.0) -> pl.DataFrame: 

76 """Normalise the portfolio's returns as a price series starting at *base*. 

77 

78 See `rebase` for full 

79 documentation. 

80 

81 Args: 

82 base: Target starting value. Defaults to ``100.0``. 

83 

84 Returns: 

85 DataFrame with price columns anchored to *base* at t = 0. 

86 

87 """ 

88 return self._du().rebase(base=base) 

89 

90 def group_returns(self, period: str = "1mo", compounded: bool = True) -> pl.DataFrame: 

91 """Aggregate portfolio returns by a calendar period. 

92 

93 See `group_returns` for 

94 full documentation. 

95 

96 Args: 

97 period: Aggregation period. Defaults to ``"1mo"`` (monthly). 

98 compounded: Whether to compound returns. Defaults to ``True``. 

99 

100 Returns: 

101 DataFrame with one row per period and one column per asset. 

102 

103 """ 

104 return self._du().group_returns(period=period, compounded=compounded) 

105 

106 def aggregate_returns(self, period: str = "1mo", compounded: bool = True) -> pl.DataFrame: 

107 """Alias for `group_returns`. 

108 

109 Args: 

110 period: Aggregation period. Defaults to ``"1mo"`` (monthly). 

111 compounded: Whether to compound returns. Defaults to ``True``. 

112 

113 Returns: 

114 DataFrame with one row per period and one column per asset. 

115 

116 """ 

117 return self._du().aggregate_returns(period=period, compounded=compounded) 

118 

119 def to_excess_returns(self, rf: float = 0.0, nperiods: int | None = None) -> pl.DataFrame: 

120 """Subtract a risk-free rate from portfolio returns. 

121 

122 See `to_excess_returns` 

123 for full documentation. 

124 

125 Args: 

126 rf: Annual risk-free rate as a decimal. Defaults to ``0.0``. 

127 nperiods: Periods per year for rate conversion. Defaults to ``None``. 

128 

129 Returns: 

130 DataFrame of excess returns. 

131 

132 """ 

133 return self._du().to_excess_returns(rf=rf, nperiods=nperiods) 

134 

135 def to_volatility_adjusted_returns( 

136 self, 

137 window: int = 60, 

138 vol_estimator: Callable[[pl.Expr], pl.Expr] | None = None, 

139 ) -> pl.DataFrame: 

140 """Convert portfolio returns to volatility-adjusted returns. 

141 

142 See `to_volatility_adjusted_returns` for full documentation. 

143 

144 Args: 

145 window: Rolling lookback for volatility. Defaults to ``60``. 

146 vol_estimator: A callable ``(pl.Expr) -> pl.Expr`` that 

147 produces a volatility series. Defaults to ``None`` 

148 (uses ``rolling_std(window)``). 

149 

150 Returns: 

151 DataFrame of volatility-adjusted returns. 

152 

153 """ 

154 return self._du().to_volatility_adjusted_returns(window=window, vol_estimator=vol_estimator) 

155 

156 def exponential_stdev(self, window: int = 30, is_halflife: bool = False) -> pl.DataFrame: 

157 """Compute exponentially weighted standard deviation of portfolio returns. 

158 

159 See `exponential_stdev` 

160 for full documentation. 

161 

162 Args: 

163 window: Span or half-life of the EWMA decay. Defaults to ``30``. 

164 is_halflife: Interpret *window* as half-life when ``True``. 

165 Defaults to ``False``. 

166 

167 Returns: 

168 DataFrame of rolling EWMA standard deviations. 

169 

170 """ 

171 return self._du().exponential_stdev(window=window, is_halflife=is_halflife) 

172 

173 def winsorise(self, window: int = 7, n_sigma: float = 3.0) -> pl.DataFrame: 

174 """Winsorise portfolio returns by clipping to within *n_sigma* rolling standard deviations. 

175 

176 See `DataUtils.winsorise` for full 

177 documentation. 

178 

179 Args: 

180 window: Rolling lookback for mean and standard deviation. 

181 Defaults to ``7``. 

182 n_sigma: Number of standard deviations for the clip bounds. 

183 Defaults to ``3.0``. 

184 

185 Returns: 

186 DataFrame with the same columns as the input returns, extreme 

187 values clipped. 

188 

189 """ 

190 return self._du().winsorise(window=window, n_sigma=n_sigma) 

191 

192 def exponential_cov( 

193 self, window: int = 30, is_halflife: bool = False, warmup: int = 0 

194 ) -> dict[Hashable, np.ndarray]: 

195 """Compute the exponentially weighted covariance matrix of portfolio returns. 

196 

197 See `DataUtils.exponential_cov` for full 

198 documentation. 

199 

200 Args: 

201 window: Span (default) or half-life (when *is_halflife* is 

202 ``True``) of the exponential decay. Defaults to ``30``. 

203 is_halflife: When ``True`` *window* is interpreted as the 

204 half-life; otherwise it is the EWMA span. Defaults to 

205 ``False``. 

206 warmup: Minimum number of common observations required before 

207 a pair's cell is non-NaN. Defaults to ``0``. 

208 

209 Returns: 

210 Dictionary keyed by index value (date or integer) mapping to 

211 a square symmetric ``numpy.ndarray`` whose dimensions match 

212 the return columns exposed by ``portfolio.data``. In this 

213 facade that is typically only the portfolio-level ``returns`` 

214 column, so the matrices are usually ``(1, 1)`` even for 

215 multi-asset portfolios. Unavailable cells are ``NaN``. 

216 

217 """ 

218 return self._du().exponential_cov(window=window, is_halflife=is_halflife, warmup=warmup)