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

32 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-07 15:52 +0000

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

2 

3from __future__ import annotations 

4 

5import dataclasses 

6from collections.abc import Callable 

7 

8import polars as pl 

9 

10from ._data import DataUtils 

11from ._protocol import PortfolioLike 

12 

13__all__ = ["PortfolioUtils"] 

14 

15 

16@dataclasses.dataclass(frozen=True) 

17class PortfolioUtils: 

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

19 

20 Exposes the same API as `DataUtils` 

21 but is initialised from a `Portfolio` 

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

23 

24 Attributes: 

25 portfolio: Any object satisfying the 

26 `PortfolioLike` protocol — 

27 typically a `Portfolio` instance. 

28 

29 """ 

30 

31 portfolio: PortfolioLike 

32 

33 def __repr__(self) -> str: 

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

35 return f"PortfolioUtils(assets={self.portfolio.assets})" 

36 

37 def _du(self) -> DataUtils: 

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

39 return DataUtils(self.portfolio.data) 

40 

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

42 

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

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

45 

46 See `to_prices` for full 

47 documentation. 

48 

49 Args: 

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

51 

52 Returns: 

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

54 

55 """ 

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

57 

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

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

60 

61 See `to_log_returns` for 

62 full documentation. 

63 

64 Returns: 

65 DataFrame of log returns. 

66 

67 """ 

68 return self._du().to_log_returns() 

69 

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

71 """Alias for `to_log_returns`. 

72 

73 Returns: 

74 DataFrame of log returns. 

75 

76 """ 

77 return self._du().log_returns() 

78 

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

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

81 

82 See `rebase` for full 

83 documentation. 

84 

85 Args: 

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

87 

88 Returns: 

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

90 

91 """ 

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

93 

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

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

96 

97 See `group_returns` for 

98 full documentation. 

99 

100 Args: 

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

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

103 

104 Returns: 

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

106 

107 """ 

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

109 

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

111 """Alias for `group_returns`. 

112 

113 Args: 

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

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

116 

117 Returns: 

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

119 

120 """ 

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

122 

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

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

125 

126 See `to_excess_returns` 

127 for full documentation. 

128 

129 Args: 

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

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

132 

133 Returns: 

134 DataFrame of excess returns. 

135 

136 """ 

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

138 

139 def to_volatility_adjusted_returns( 

140 self, 

141 window: int = 60, 

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

143 ) -> pl.DataFrame: 

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

145 

146 See `to_volatility_adjusted_returns` for full documentation. 

147 

148 Args: 

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

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

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

152 (uses ``rolling_std(window)``). 

153 

154 Returns: 

155 DataFrame of volatility-adjusted returns. 

156 

157 """ 

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

159 

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

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

162 

163 See `exponential_stdev` 

164 for full documentation. 

165 

166 Args: 

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

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

169 Defaults to ``False``. 

170 

171 Returns: 

172 DataFrame of rolling EWMA standard deviations. 

173 

174 """ 

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