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
« 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.
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"""
23from __future__ import annotations
25from typing import TYPE_CHECKING, Any
27from jquantstats.data import Data
29if TYPE_CHECKING:
30 import pandas as pd
33class PortfolioAnalytics:
34 """Mixin providing performance statistics, plots, and reports for a portfolio.
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.
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:
47 >>> import pandas as pd
48 >>> from cvx.simulator import Portfolio
49 >>> from cvx.simulator._analytics import PortfolioAnalytics
50 >>> issubclass(Portfolio, PortfolioAnalytics)
51 True
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)
61 All of it is derived from the NAV alone:
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']
68 """
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
75 @property
76 def nav(self) -> pd.Series:
77 """Net asset value of the portfolio over time (provided by the host)."""
78 ...
80 def _build_data(self) -> None:
81 """Build and cache the derived quantstats Data from the portfolio NAV.
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)
92 object.__setattr__(self, "_data", d)
94 @property
95 def stats(self) -> Any:
96 """Get statistical analysis data for the portfolio.
98 This property provides access to various statistical metrics calculated
99 for the portfolio, such as Sharpe ratio, volatility, drawdowns, etc.
101 Returns:
102 -------
103 object
104 An object containing various statistical metrics for the portfolio
106 Notes:
107 -----
108 The statistics are calculated by the underlying jquantstats library
109 and are based on the portfolio's NAV time series.
111 """
112 return self._data.stats
114 @property
115 def plots(self) -> Any:
116 """Get visualization tools for the portfolio.
118 This property provides access to various plotting functions for visualizing
119 the portfolio's performance, returns, drawdowns, etc.
121 Returns:
122 -------
123 object
124 An object containing various plotting methods for the portfolio
126 Notes:
127 -----
128 The plotting functions are provided by the underlying jquantstats library
129 and operate on the portfolio's NAV time series.
131 """
132 return self._data.plots
134 @property
135 def reports(self) -> Any:
136 """Get reporting tools for the portfolio.
138 This property provides access to various reporting functions for generating
139 performance reports, risk metrics, and other analytics for the portfolio.
141 Returns:
142 -------
143 object
144 An object containing various reporting methods for the portfolio
146 Notes:
147 -----
148 The reporting functions are provided by the underlying jquantstats library
149 and operate on the portfolio's NAV time series.
151 """
152 return self._data.reports
154 def sharpe(self, periods: int | None = None) -> float:
155 """Calculate the Sharpe ratio for the portfolio.
157 The Sharpe ratio is a measure of risk-adjusted return, calculated as
158 the portfolio's excess return divided by its volatility.
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.
167 Returns:
168 -------
169 float
170 The Sharpe ratio of the portfolio
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.
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)
189 Without ``periods`` the ratio is left unannualized:
191 >>> round(portfolio.sharpe(), 2)
192 8.12
194 Passing the number of periods per year annualizes it — 252 for daily
195 data, 52 for weekly, 12 for monthly:
197 >>> round(portfolio.sharpe(periods=252), 2)
198 6.74
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.
204 """
205 return float(self.stats.sharpe(periods=periods)["NAV"])
207 def snapshot(self, title: str = "Portfolio Summary", log_scale: bool = True) -> Any:
208 """Generate and display a snapshot of the portfolio summary.
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.
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.
220 Returns:
221 The generated plot object representing the portfolio snapshot.
223 """
224 return self.plots.snapshot(title=title, log_scale=log_scale)