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

1"""NAV-accumulated performance charts: snapshot, lag sweep, holdings smoothing. 

2 

3Split out of the former single-module `_plots/_portfolio.py`; composed into 

4:class:`PortfolioPlots` by `_core.py`. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import TYPE_CHECKING 

10 

11import plotly.graph_objects as go 

12from plotly.subplots import make_subplots 

13 

14from .._data._styling import _apply_base_layout 

15 

16if TYPE_CHECKING: 

17 from .._protocol import PortfolioLike 

18 

19 

20class _NavPlotsMixin: 

21 """Accumulated-NAV charts for :class:`PortfolioPlots`.""" 

22 

23 __slots__ = () 

24 

25 _portfolio: PortfolioLike 

26 

27 def snapshot(self, log_scale: bool = False) -> go.Figure: 

28 """Return a snapshot dashboard of NAV and drawdown. 

29 

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. 

33 

34 Args: 

35 log_scale (bool, optional): If True, display NAV on a log scale. Defaults to False. 

36 

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 ) 

50 

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 ) 

63 

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 ) 

75 

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 ) 

87 

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 ) 

104 

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 ) 

117 

118 fig.add_hline(y=0, line_width=1, line_color="gray", row=2, col=1) 

119 

120 _apply_base_layout(fig, "Performance Dashboard", height=1200) 

121 

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%") 

124 

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" 

131 

132 return fig 

133 

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. 

137 

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`. 

142 

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)") 

150 

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" 

155 

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. 

158 

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. 

162 

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. 

166 

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 

174 

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 ) 

188 

189 self._apply_nav_layout(fig, title="NAV accumulated by lag", log_scale=log_scale) 

190 return fig 

191 

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. 

198 

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. 

203 

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. 

208 

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 

216 

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 ) 

230 

231 self._apply_nav_layout(fig, title="NAV accumulated by smoothed holdings", log_scale=log_scale) 

232 return fig