Coverage for src/jquantstats/_plots/_portfolio/_diagnostics.py: 100%

70 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04:52 +0000

1"""Diagnostic charts: lead/lag IR, correlation, monthly calendar, cost impact. 

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.express as px 

12import plotly.graph_objects as go 

13import polars as pl 

14 

15if TYPE_CHECKING: 

16 from .._protocol import PortfolioLike 

17 

18 

19class _DiagnosticPlotsMixin: 

20 """Diagnostic charts for :class:`PortfolioPlots`.""" 

21 

22 __slots__ = () 

23 

24 _portfolio: PortfolioLike 

25 

26 def lead_lag_ir_plot(self, start: int = -10, end: int = 19) -> go.Figure: 

27 """Plot Sharpe ratio (IR) across lead/lag variants of the portfolio. 

28 

29 Builds portfolios with cash positions lagged from ``start`` to ``end`` 

30 (inclusive) and plots a bar chart of the Sharpe ratio for each lag. 

31 Positive lags delay weights; negative lags lead them. 

32 

33 Args: 

34 start: First lag to include (default: -10). 

35 end: Last lag to include (default: +19). 

36 

37 Returns: 

38 A Plotly Figure with one bar per lag labeled by the lag value. 

39 """ 

40 if not isinstance(start, int) or not isinstance(end, int): 

41 raise TypeError 

42 if start > end: 

43 start, end = end, start 

44 

45 lags = list(range(start, end + 1)) 

46 

47 x_vals: list[int] = [] 

48 y_vals: list[float] = [] 

49 

50 for n in lags: 

51 pf = self._portfolio if n == 0 else self._portfolio.lag(n) 

52 # Compute Sharpe on the portfolio's returns series 

53 sharpe_val = pf.stats.sharpe().get("returns", float("nan")) 

54 # Ensure a float (Stats returns mapping asset->value) 

55 y_vals.append(float(sharpe_val) if sharpe_val is not None else float("nan")) 

56 x_vals.append(n) 

57 

58 colors = ["red" if x == 0 else "#1f77b4" for x in x_vals] 

59 fig = go.Figure( 

60 data=[ 

61 go.Bar(x=x_vals, y=y_vals, name="Sharpe by lag", marker_color=colors), 

62 ] 

63 ) 

64 fig.update_layout( 

65 title="Lead/Lag Information Ratio (Sharpe) by Lag", 

66 xaxis_title="Lag (steps)", 

67 yaxis_title="Sharpe ratio", 

68 plot_bgcolor="white", 

69 hovermode="x", 

70 ) 

71 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

72 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

73 return fig 

74 

75 def correlation_heatmap( 

76 self, 

77 frame: pl.DataFrame | None = None, 

78 name: str = "portfolio", 

79 title: str = "Correlation heatmap", 

80 ) -> go.Figure: 

81 """Plot a correlation heatmap for assets and the portfolio. 

82 

83 If ``frame`` is None, uses the portfolio's prices. The portfolio's 

84 profit series is appended under ``name`` before computing the 

85 correlation matrix. 

86 

87 Args: 

88 frame: Optional Polars DataFrame with at least the asset price 

89 columns. If omitted, uses ``self._portfolio.prices``. 

90 name: Column name under which to include the portfolio profit. 

91 title: Plot title. 

92 

93 Returns: 

94 A Plotly Figure rendering the correlation matrix as a heatmap. 

95 """ 

96 if frame is None: 

97 frame = self._portfolio.prices 

98 

99 corr = self._portfolio.correlation(frame, name=name) 

100 

101 # Create an interactive heatmap 

102 fig = px.imshow( 

103 corr, 

104 x=corr.columns, 

105 y=corr.columns, 

106 text_auto=".2f", # show correlation values 

107 color_continuous_scale="RdBu_r", # red-blue diverging colormap 

108 zmin=-1, 

109 zmax=1, # correlation range 

110 title=title, 

111 ) 

112 

113 # Adjust layout 

114 fig.update_layout( 

115 xaxis_title="", yaxis_title="", width=700, height=600, coloraxis_colorbar={"title": "Correlation"} 

116 ) 

117 

118 return fig 

119 

120 def monthly_returns_heatmap(self) -> go.Figure: 

121 """Plot a monthly returns calendar heatmap. 

122 

123 Groups portfolio returns by calendar year and month, then renders a 

124 Plotly heatmap with months on the x-axis and years on the y-axis. 

125 Green cells indicate positive months; red cells indicate negative 

126 months. Cell text shows the percentage return for that month. 

127 

128 Returns: 

129 A Plotly Figure with a calendar heatmap of monthly returns. 

130 

131 Raises: 

132 ValueError: If the portfolio has no ``date`` column. 

133 """ 

134 monthly = self._portfolio.monthly 

135 

136 years = monthly["year"].unique().sort().to_list() 

137 month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] 

138 

139 z: list[list[float | None]] = [] 

140 text: list[list[str]] = [] 

141 for year in years: 

142 year_data = monthly.filter(pl.col("year") == year) 

143 year_row: list[float | None] = [] 

144 year_text: list[str] = [] 

145 for m in range(1, 13): 

146 month_data = year_data.filter(pl.col("month") == m) 

147 if month_data.is_empty(): 

148 year_row.append(None) 

149 year_text.append("") 

150 else: 

151 ret = float(month_data["returns"][0]) 

152 year_row.append(ret * 100.0) 

153 year_text.append(f"{ret * 100.0:.1f}%") 

154 z.append(year_row) 

155 text.append(year_text) 

156 

157 fig = go.Figure( 

158 data=go.Heatmap( 

159 z=z, 

160 x=month_names, 

161 y=[str(y) for y in years], 

162 text=text, 

163 texttemplate="%{text}", 

164 colorscale="RdYlGn", 

165 zmid=0, 

166 colorbar={"title": "Return (%)"}, 

167 hovertemplate="<b>%{y} %{x}</b><br>Return: %{text}<extra></extra>", 

168 ) 

169 ) 

170 

171 fig.update_layout( 

172 title="Monthly Returns Heatmap", 

173 xaxis_title="Month", 

174 yaxis_title="Year", 

175 plot_bgcolor="white", 

176 yaxis={"type": "category"}, 

177 ) 

178 

179 return fig 

180 

181 def trading_cost_impact_plot(self, max_bps: int = 20) -> go.Figure: 

182 """Plot the Sharpe ratio as a function of one-way trading costs. 

183 

184 Evaluates the portfolio's annualised Sharpe ratio at each integer 

185 cost level from 0 up to ``max_bps`` basis points and renders the 

186 result as a line chart. The zero-cost Sharpe is shown as a 

187 reference horizontal line so that the reader can quickly gauge 

188 at what cost level the strategy's edge is eroded. 

189 

190 Args: 

191 max_bps: Maximum one-way trading cost to evaluate, in basis 

192 points. Defaults to 20. 

193 

194 Returns: 

195 A Plotly Figure with one line trace showing Sharpe vs. cost. 

196 

197 Raises: 

198 ValueError: If ``max_bps`` is not a positive integer. 

199 """ 

200 impact = self._portfolio.trading_cost_impact(max_bps=max_bps) 

201 

202 cost_vals = impact["cost_bps"].to_list() 

203 sharpe_vals = impact["sharpe"].to_list() 

204 

205 # Baseline Sharpe at zero cost 

206 baseline = float(sharpe_vals[0]) if sharpe_vals and sharpe_vals[0] is not None else float("nan") 

207 

208 fig = go.Figure() 

209 fig.add_trace( 

210 go.Scatter( 

211 x=cost_vals, 

212 y=sharpe_vals, 

213 mode="lines+markers", 

214 name="Sharpe (cost-adjusted)", 

215 marker={"size": 6}, 

216 line={"width": 2, "color": "#1f77b4"}, 

217 ) 

218 ) 

219 if baseline == baseline: # only add when baseline is finite (NaN != NaN) 

220 fig.add_hline( 

221 y=baseline, 

222 line_width=1, 

223 line_dash="dash", 

224 line_color="gray", 

225 annotation_text="0 bps baseline", 

226 annotation_position="top right", 

227 ) 

228 

229 fig.update_layout( 

230 title=f"Trading Cost Impact on Sharpe Ratio (0\u2013{max_bps} bps)", 

231 hovermode="x unified", 

232 plot_bgcolor="white", 

233 ) 

234 fig.update_xaxes( 

235 title_text="One-way cost (basis points)", 

236 showgrid=True, 

237 gridwidth=0.5, 

238 gridcolor="lightgrey", 

239 dtick=1, 

240 ) 

241 fig.update_yaxes( 

242 title_text="Annualised Sharpe ratio", 

243 showgrid=True, 

244 gridwidth=0.5, 

245 gridcolor="lightgrey", 

246 ) 

247 return fig