Coverage for src/cvx/simulator/_analytics.py: 100%

22 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 02:49 +0000

1# Copyright 2023 Stanford University Convex Optimization Group 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14"""jquantstats-backed performance analytics for the Portfolio class. 

15 

16This module houses :class:`PortfolioAnalytics`, a mixin that supplies the 

17statistics, plotting, and reporting surface layered on top of a portfolio's 

18net asset value. Keeping it separate from the core 

19:class:`~cvx.simulator.portfolio.Portfolio` data model isolates the dependency 

20on :mod:`jquantstats` and keeps each module focused on a single concern. 

21""" 

22 

23from __future__ import annotations 

24 

25from typing import TYPE_CHECKING, Any 

26 

27from jquantstats.data import Data 

28 

29if TYPE_CHECKING: 

30 import pandas as pd 

31 

32 

33class PortfolioAnalytics: 

34 """Mixin providing performance statistics, plots, and reports for a portfolio. 

35 

36 The mixin expects the host class to expose a ``nav`` :class:`pandas.Series` 

37 property. From that NAV it lazily builds and caches a 

38 :class:`jquantstats.data.Data` object (in :meth:`_build_data`) and exposes 

39 the ``stats``/``plots``/``reports`` surface plus the :meth:`sharpe` and 

40 :meth:`snapshot` convenience helpers on top of it. 

41 

42 Examples: 

43 -------- 

44 The mixin is not used directly — :class:`~cvx.simulator.portfolio.Portfolio` 

45 inherits it, so every portfolio carries the analytics surface: 

46 

47 >>> import pandas as pd 

48 >>> from cvx.simulator import Portfolio 

49 >>> from cvx.simulator._analytics import PortfolioAnalytics 

50 >>> issubclass(Portfolio, PortfolioAnalytics) 

51 True 

52 

53 >>> dates = pd.date_range("2020-01-01", periods=4) 

54 >>> prices = pd.DataFrame( 

55 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]}, 

56 ... index=dates, 

57 ... ) 

58 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates) 

59 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0) 

60 

61 All of it is derived from the NAV alone: 

62 

63 >>> portfolio.nav.tolist() 

64 [1000.0, 1020.0, 1040.0, 1025.0] 

65 >>> sorted(m for m in ("stats", "plots", "reports") if hasattr(portfolio, m)) 

66 ['plots', 'reports', 'stats'] 

67 

68 """ 

69 

70 if TYPE_CHECKING: 

71 # Supplied by the host dataclass (Portfolio); declared here purely so the 

72 # type checker can resolve the attributes the mixin relies on. 

73 _data: Data 

74 

75 @property 

76 def nav(self) -> pd.Series: 

77 """Net asset value of the portfolio over time (provided by the host).""" 

78 ... 

79 

80 def _build_data(self) -> None: 

81 """Build and cache the derived quantstats Data from the portfolio NAV. 

82 

83 This constructs the returns-based :class:`~jquantstats.data.Data` object 

84 used by the reporting and statistics helpers and stores it on the frozen 

85 instance's ``_data`` field. It is invoked from ``__post_init__`` after 

86 the input validation has passed. 

87 """ 

88 frame = self.nav.pct_change().to_frame().reset_index() 

89 frame.columns = ["Date", frame.columns[1]] 

90 d = Data.from_returns(returns=frame) 

91 

92 object.__setattr__(self, "_data", d) 

93 

94 @property 

95 def stats(self) -> Any: 

96 """Get statistical analysis data for the portfolio. 

97 

98 This property provides access to various statistical metrics calculated 

99 for the portfolio, such as Sharpe ratio, volatility, drawdowns, etc. 

100 

101 Returns: 

102 ------- 

103 object 

104 An object containing various statistical metrics for the portfolio 

105 

106 Notes: 

107 ----- 

108 The statistics are calculated by the underlying jquantstats library 

109 and are based on the portfolio's NAV time series. 

110 

111 """ 

112 return self._data.stats 

113 

114 @property 

115 def plots(self) -> Any: 

116 """Get visualization tools for the portfolio. 

117 

118 This property provides access to various plotting functions for visualizing 

119 the portfolio's performance, returns, drawdowns, etc. 

120 

121 Returns: 

122 ------- 

123 object 

124 An object containing various plotting methods for the portfolio 

125 

126 Notes: 

127 ----- 

128 The plotting functions are provided by the underlying jquantstats library 

129 and operate on the portfolio's NAV time series. 

130 

131 """ 

132 return self._data.plots 

133 

134 @property 

135 def reports(self) -> Any: 

136 """Get reporting tools for the portfolio. 

137 

138 This property provides access to various reporting functions for generating 

139 performance reports, risk metrics, and other analytics for the portfolio. 

140 

141 Returns: 

142 ------- 

143 object 

144 An object containing various reporting methods for the portfolio 

145 

146 Notes: 

147 ----- 

148 The reporting functions are provided by the underlying jquantstats library 

149 and operate on the portfolio's NAV time series. 

150 

151 """ 

152 return self._data.reports 

153 

154 def sharpe(self, periods: int | None = None) -> float: 

155 """Calculate the Sharpe ratio for the portfolio. 

156 

157 The Sharpe ratio is a measure of risk-adjusted return, calculated as 

158 the portfolio's excess return divided by its volatility. 

159 

160 Parameters 

161 ---------- 

162 periods : int, optional 

163 The number of periods per year for annualization. 

164 For daily data, use 252; for weekly data, use 52; for monthly data, use 12. 

165 If None, no annualization is performed. 

166 

167 Returns: 

168 ------- 

169 float 

170 The Sharpe ratio of the portfolio 

171 

172 Notes: 

173 ----- 

174 The Sharpe ratio is calculated using the portfolio's NAV time series. 

175 A higher Sharpe ratio indicates better risk-adjusted performance. 

176 

177 Examples: 

178 -------- 

179 >>> import pandas as pd 

180 >>> from cvx.simulator import Portfolio 

181 >>> dates = pd.date_range("2020-01-01", periods=4) 

182 >>> prices = pd.DataFrame( 

183 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]}, 

184 ... index=dates, 

185 ... ) 

186 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates) 

187 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0) 

188 

189 Without ``periods`` the ratio is left unannualized: 

190 

191 >>> round(portfolio.sharpe(), 2) 

192 8.12 

193 

194 Passing the number of periods per year annualizes it — 252 for daily 

195 data, 52 for weekly, 12 for monthly: 

196 

197 >>> round(portfolio.sharpe(periods=252), 2) 

198 6.74 

199 

200 Both numbers are meaningless as investment results: three returns is far 

201 too short a sample to say anything about risk-adjusted performance. They 

202 demonstrate the call, not a realistic figure. 

203 

204 """ 

205 return float(self.stats.sharpe(periods=periods)["NAV"]) 

206 

207 def snapshot(self, title: str = "Portfolio Summary", log_scale: bool = True) -> Any: 

208 """Generate and display a snapshot of the portfolio summary. 

209 

210 This method creates a visual representation of the portfolio summary 

211 using the associated plot functionalities. The snapshot can be 

212 configured with a title and whether to use a logarithmic scale. 

213 

214 Args: 

215 title: A string specifying the title of the snapshot. 

216 Default is "Portfolio Summary". 

217 log_scale: A boolean indicating whether to display the plot 

218 using a logarithmic scale. Default is True. 

219 

220 Returns: 

221 The generated plot object representing the portfolio snapshot. 

222 

223 """ 

224 return self.plots.snapshot(title=title, log_scale=log_scale)