Coverage for src/jquantstats/_plots/_portfolio/_nav.py: 100%
59 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""NAV-accumulated performance charts: snapshot, lag sweep, holdings smoothing.
3Split out of the former single-module `_plots/_portfolio.py`; composed into
4:class:`PortfolioPlots` by `_core.py`.
5"""
7from __future__ import annotations
9from typing import TYPE_CHECKING
11import plotly.graph_objects as go
12from plotly.subplots import make_subplots
14from .._data._styling import _apply_base_layout
16if TYPE_CHECKING:
17 from .._protocol import PortfolioLike
20class _NavPlotsMixin:
21 """Accumulated-NAV charts for :class:`PortfolioPlots`."""
23 __slots__ = ()
25 _portfolio: PortfolioLike
27 def snapshot(self, log_scale: bool = False) -> go.Figure:
28 """Return a snapshot dashboard of NAV and drawdown.
30 When the portfolio has a non-zero ``cost_model.cost_per_unit``, an additional
31 ``"Net-of-Cost NAV"`` trace is overlaid on the NAV panel showing the
32 realised NAV path after deducting position-delta trading costs.
34 Args:
35 log_scale (bool, optional): If True, display NAV on a log scale. Defaults to False.
37 Returns:
38 plotly.graph_objects.Figure: A Figure with accumulated NAV (including tilt/timing)
39 and drawdown shaded area, equipped with a range selector.
40 """
41 # Create subplot grid with domain for stats table
42 fig = make_subplots(
43 rows=2,
44 cols=1,
45 shared_xaxes=True,
46 row_heights=[0.66, 0.33],
47 subplot_titles=["Accumulated Profit", "Drawdown"],
48 vertical_spacing=0.05,
49 )
51 # --- Row 1: Cumulative Returns
52 fig.add_trace(
53 go.Scatter(
54 x=self._portfolio.nav_accumulated["date"],
55 y=self._portfolio.nav_accumulated["NAV_accumulated"],
56 mode="lines",
57 name="NAV",
58 showlegend=False,
59 ),
60 row=1,
61 col=1,
62 )
64 fig.add_trace(
65 go.Scatter(
66 x=self._portfolio.tilt.nav_accumulated["date"],
67 y=self._portfolio.tilt.nav_accumulated["NAV_accumulated"],
68 mode="lines",
69 name="Tilt",
70 showlegend=False,
71 ),
72 row=1,
73 col=1,
74 )
76 fig.add_trace(
77 go.Scatter(
78 x=self._portfolio.timing.nav_accumulated["date"],
79 y=self._portfolio.timing.nav_accumulated["NAV_accumulated"],
80 mode="lines",
81 name="Timing",
82 showlegend=False,
83 ),
84 row=1,
85 col=1,
86 )
88 # Net-of-cost NAV overlay (only when a cost model is active)
89 if self._portfolio.cost_model.cost_per_unit > 0:
90 net_nav_df = self._portfolio.net_cost_nav
91 x_dates = net_nav_df["date"] if "date" in net_nav_df.columns else None
92 fig.add_trace(
93 go.Scatter(
94 x=x_dates,
95 y=net_nav_df["NAV_accumulated_net"],
96 mode="lines",
97 name="Net-of-Cost NAV",
98 line={"dash": "dash"},
99 showlegend=True,
100 ),
101 row=1,
102 col=1,
103 )
105 fig.add_trace(
106 go.Scatter(
107 x=self._portfolio.drawdown["date"],
108 y=self._portfolio.drawdown["drawdown_pct"],
109 mode="lines",
110 fill="tozeroy",
111 name="Drawdown",
112 showlegend=False,
113 ),
114 row=2,
115 col=1,
116 )
118 fig.add_hline(y=0, line_width=1, line_color="gray", row=2, col=1)
120 _apply_base_layout(fig, "Performance Dashboard", height=1200)
122 fig.update_yaxes(title_text="NAV (accumulated)", row=1, col=1, tickformat=".2s")
123 fig.update_yaxes(title_text="Drawdown", row=2, col=1, tickformat=".0%")
125 if log_scale:
126 fig.update_yaxes(type="log", row=1, col=1)
127 # Ensure the first y-axis is explicitly set for environments
128 # where subplot updates may not propagate to layout alias.
129 if hasattr(fig.layout, "yaxis"): # pragma: no branch — plotly figures always have .yaxis
130 fig.layout.yaxis.type = "log"
132 return fig
134 @staticmethod
135 def _apply_nav_layout(fig: go.Figure, title: str, log_scale: bool = False) -> None:
136 """Apply common NAV-accumulated layout to *fig* in-place.
138 Configures the plot background, legend, hover mode, x-axis date range
139 selector, y-axis label, grid lines, and optional logarithmic y-scale.
140 Shared by `lagged_performance_plot` and
141 `smoothed_holdings_performance_plot`.
143 Args:
144 fig: The Plotly Figure to configure.
145 title: Chart title text.
146 log_scale: If True, set the primary y-axis to logarithmic scale.
147 """
148 _apply_base_layout(fig, title)
149 fig.update_yaxes(title_text="NAV (accumulated)")
151 if log_scale:
152 fig.update_yaxes(type="log")
153 if hasattr(fig.layout, "yaxis"): # pragma: no branch — plotly figures always have .yaxis
154 fig.layout.yaxis.type = "log"
156 def lagged_performance_plot(self, lags: list[int] | None = None, log_scale: bool = False) -> go.Figure:
157 """Plot NAV_accumulated for multiple lagged portfolios.
159 Creates a Plotly figure with one line per lag value showing the
160 accumulated NAV series for the portfolio with cash positions
161 shifted by that lag. By default, lags [0, 1, 2, 3, 4] are used.
163 Args:
164 lags: A list of integer lags to apply; defaults to [0, 1, 2, 3, 4].
165 log_scale: If True, set the primary y-axis to logarithmic scale.
167 Returns:
168 A Plotly Figure containing one trace per requested lag.
169 """
170 if lags is None:
171 lags = [0, 1, 2, 3, 4]
172 if not isinstance(lags, list) or not all(isinstance(x, int) for x in lags):
173 raise TypeError
175 fig = go.Figure()
176 for lag in lags:
177 pf = self._portfolio if lag == 0 else self._portfolio.lag(lag)
178 nav = pf.nav_accumulated
179 fig.add_trace(
180 go.Scatter(
181 x=nav["date"],
182 y=nav["NAV_accumulated"],
183 mode="lines",
184 name=f"lag {lag}",
185 line={"width": 1},
186 )
187 )
189 self._apply_nav_layout(fig, title="NAV accumulated by lag", log_scale=log_scale)
190 return fig
192 def smoothed_holdings_performance_plot(
193 self,
194 windows: list[int] | None = None,
195 log_scale: bool = False,
196 ) -> go.Figure:
197 """Plot NAV_accumulated for smoothed-holding portfolios.
199 Builds portfolios with cash positions smoothed by a trailing rolling
200 mean over the previous ``n`` steps (window size n+1) for n in
201 ``windows`` (defaults to [0, 1, 2, 3, 4]) and plots their
202 accumulated NAV curves.
204 Args:
205 windows: List of non-negative integers specifying smoothing steps
206 to include; defaults to [0, 1, 2, 3, 4].
207 log_scale: If True, set the primary y-axis to logarithmic scale.
209 Returns:
210 A Plotly Figure containing one line per requested smoothing level.
211 """
212 if windows is None:
213 windows = [0, 1, 2, 3, 4]
214 if not isinstance(windows, list) or not all(isinstance(x, int) and x >= 0 for x in windows):
215 raise TypeError
217 fig = go.Figure()
218 for n in windows:
219 pf = self._portfolio if n == 0 else self._portfolio.smoothed_holding(n)
220 nav = pf.nav_accumulated
221 fig.add_trace(
222 go.Scatter(
223 x=nav["date"],
224 y=nav["NAV_accumulated"],
225 mode="lines",
226 name=f"smooth {n}",
227 line={"width": 1},
228 )
229 )
231 self._apply_nav_layout(fig, title="NAV accumulated by smoothed holdings", log_scale=log_scale)
232 return fig