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
« 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.
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.express as px
12import plotly.graph_objects as go
13import polars as pl
15if TYPE_CHECKING:
16 from .._protocol import PortfolioLike
19class _DiagnosticPlotsMixin:
20 """Diagnostic charts for :class:`PortfolioPlots`."""
22 __slots__ = ()
24 _portfolio: PortfolioLike
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.
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.
33 Args:
34 start: First lag to include (default: -10).
35 end: Last lag to include (default: +19).
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
45 lags = list(range(start, end + 1))
47 x_vals: list[int] = []
48 y_vals: list[float] = []
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)
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
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.
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.
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.
93 Returns:
94 A Plotly Figure rendering the correlation matrix as a heatmap.
95 """
96 if frame is None:
97 frame = self._portfolio.prices
99 corr = self._portfolio.correlation(frame, name=name)
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 )
113 # Adjust layout
114 fig.update_layout(
115 xaxis_title="", yaxis_title="", width=700, height=600, coloraxis_colorbar={"title": "Correlation"}
116 )
118 return fig
120 def monthly_returns_heatmap(self) -> go.Figure:
121 """Plot a monthly returns calendar heatmap.
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.
128 Returns:
129 A Plotly Figure with a calendar heatmap of monthly returns.
131 Raises:
132 ValueError: If the portfolio has no ``date`` column.
133 """
134 monthly = self._portfolio.monthly
136 years = monthly["year"].unique().sort().to_list()
137 month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
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)
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 )
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 )
179 return fig
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.
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.
190 Args:
191 max_bps: Maximum one-way trading cost to evaluate, in basis
192 points. Defaults to 20.
194 Returns:
195 A Plotly Figure with one line trace showing Sharpe vs. cost.
197 Raises:
198 ValueError: If ``max_bps`` is not a positive integer.
199 """
200 impact = self._portfolio.trading_cost_impact(max_bps=max_bps)
202 cost_vals = impact["cost_bps"].to_list()
203 sharpe_vals = impact["sharpe"].to_list()
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")
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 )
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