Coverage for src/jquantstats/_plots/_specs/_diagnostics.py: 100%
55 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
1"""Spec builders for the portfolio diagnostic charts.
3These ask questions about the strategy rather than reporting its returns: how
4sensitive is it to execution delay, how correlated are its holdings, which
5months carried it, and how much trading cost it can absorb.
6"""
8from __future__ import annotations
10import math
11from typing import TYPE_CHECKING
13import polars as pl
15from .._spec import Axis, BarSeries, FigureSpec, HeatmapGrid, LineSeries, Panel, RefLine
17if TYPE_CHECKING:
18 from .._protocol import PortfolioLike
20__all__ = [
21 "correlation_heatmap_spec",
22 "lead_lag_ir_spec",
23 "monthly_returns_heatmap_spec",
24 "trading_cost_impact_spec",
25]
27_MONTH_NAMES = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
29# The undelayed portfolio is the one being judged, so it is picked out from the
30# lagged variants around it.
31_FOCUS_COLOR = "red"
32_NEUTRAL_COLOR = "#1f77b4"
34# A correlation runs from -1 to 1 whatever this particular data happens to span,
35# so the ramp is pinned rather than fitted.
36_CORRELATION_RANGE = (-1, 1)
37_CORRELATION_SIZE = (700, 600)
39_MARKER_SIZE = 6
42def lead_lag_ir_spec(portfolio: PortfolioLike, start: int, end: int) -> FigureSpec:
43 """Describe the Sharpe-by-execution-delay chart.
45 Shifting the positions forward and backward shows how much of the
46 strategy's edge depends on trading precisely when it says to. A peak away
47 from zero suggests the signal is mistimed.
49 Args:
50 portfolio: The portfolio to plot.
51 start: First lag to include.
52 end: Last lag to include.
54 Returns:
55 FigureSpec: One bar per lag, with the undelayed one picked out.
57 Raises:
58 TypeError: If *start* or *end* is not an integer.
60 """
61 if not isinstance(start, int) or not isinstance(end, int):
62 raise TypeError
63 if start > end:
64 start, end = end, start
66 lags = list(range(start, end + 1))
67 sharpes: list[float] = []
68 for n in lags:
69 lagged = portfolio if n == 0 else portfolio.lag(n)
70 value = lagged.stats.sharpe().get("returns", float("nan"))
71 sharpes.append(float(value) if value is not None else float("nan"))
73 panel = Panel(
74 bars=(
75 BarSeries(
76 name="Sharpe by lag",
77 x=lags,
78 y=sharpes,
79 colors=tuple(_FOCUS_COLOR if lag == 0 else _NEUTRAL_COLOR for lag in lags),
80 ),
81 ),
82 xaxis=Axis(title="Lag (steps)"),
83 yaxis=Axis(title="Sharpe ratio"),
84 )
85 return FigureSpec(
86 title="Lead/Lag Information Ratio (Sharpe) by Lag",
87 panels=(panel,),
88 height=None,
89 chrome="plain",
90 hover_mode="x",
91 )
94def correlation_heatmap_spec(portfolio: PortfolioLike, frame: pl.DataFrame | None, name: str, title: str) -> FigureSpec:
95 """Describe the correlation matrix of the holdings and the portfolio.
97 Args:
98 portfolio: The portfolio to plot.
99 frame: Series to correlate against, or None for the portfolio's prices.
100 name: Column name to give the portfolio's own profit series.
101 title: Chart title.
103 Returns:
104 FigureSpec: A square matrix pinned to the full [-1, 1] range.
106 """
107 frame = portfolio.prices if frame is None else frame
108 corr = portfolio.correlation(frame, name=name)
109 labels = tuple(corr.columns)
110 values = tuple(tuple(row) for row in corr.rows())
111 low, high = _CORRELATION_RANGE
112 width, height = _CORRELATION_SIZE
114 panel = Panel(
115 heatmap=HeatmapGrid(
116 x_labels=labels,
117 y_labels=labels,
118 z=values,
119 text=tuple(tuple(f"{value:.2f}" for value in row) for row in values),
120 colorscale="rdbu_r",
121 # A diverging ramp centred on zero: uncorrelated reads as neutral.
122 zmid=0,
123 zmin=low,
124 zmax=high,
125 colorbar_title="Correlation",
126 # px.imshow generated a tooltip automatically; going to a plain
127 # heatmap means asking for one, or the chart silently loses it.
128 hover_label="Correlation",
129 ),
130 # The labels are the series names; repeating them as axis titles would
131 # say nothing.
132 xaxis=Axis(title=""),
133 yaxis=Axis(title=""),
134 )
135 return FigureSpec(title=title, panels=(panel,), height=height, width=width, chrome="bare")
138def monthly_returns_heatmap_spec(portfolio: PortfolioLike) -> FigureSpec:
139 """Describe the portfolio's monthly-returns calendar.
141 Args:
142 portfolio: The portfolio to plot.
144 Returns:
145 FigureSpec: A year-by-month grid.
147 """
148 monthly = portfolio.monthly
149 years = monthly["year"].unique().sort().to_list()
151 z: list[tuple[float | None, ...]] = []
152 text: list[tuple[str, ...]] = []
153 for year in years:
154 rows = monthly.filter(pl.col("year") == year)
155 by_month = {int(row["month"]): float(row["returns"]) for row in rows.iter_rows(named=True)}
156 percents = [by_month[m] * 100.0 if m in by_month else None for m in range(1, 13)]
157 z.append(tuple(percents))
158 text.append(tuple("" if value is None else f"{value:.1f}%" for value in percents))
160 panel = Panel(
161 heatmap=HeatmapGrid(
162 x_labels=_MONTH_NAMES,
163 y_labels=tuple(str(year) for year in years),
164 z=tuple(z),
165 text=tuple(text),
166 colorscale="rdylgn",
167 colorbar_title="Return (%)",
168 hover_label="Return",
169 ),
170 xaxis=Axis(title="Month"),
171 # Years are labels, not a continuous scale: 2023 and 2024 are adjacent
172 # rows, not a year apart on a number line.
173 yaxis=Axis(title="Year", kind="category"),
174 )
175 return FigureSpec(title="Monthly Returns Heatmap", panels=(panel,), height=None, chrome="bare")
178def trading_cost_impact_spec(portfolio: PortfolioLike, max_bps: int) -> FigureSpec:
179 """Describe how trading cost erodes the Sharpe ratio.
181 Args:
182 portfolio: The portfolio to plot.
183 max_bps: Highest one-way cost to evaluate, in basis points.
185 Returns:
186 FigureSpec: Sharpe against cost, with the zero-cost level marked when
187 it is finite.
189 Raises:
190 ValueError: If *max_bps* is not a positive integer.
192 """
193 impact = portfolio.trading_cost_impact(max_bps=max_bps)
194 costs = impact["cost_bps"].to_list()
195 sharpes = impact["sharpe"].to_list()
197 baseline = float(sharpes[0]) if sharpes and sharpes[0] is not None else float("nan")
198 # An unmeasurable baseline gets no line rather than one drawn at NaN.
199 ref_lines = () if math.isnan(baseline) else (RefLine(value=baseline, dash="dash", label="0 bps baseline"),)
201 panel = Panel(
202 lines=(
203 LineSeries(
204 name="Sharpe (cost-adjusted)",
205 x=costs,
206 y=sharpes,
207 color=_NEUTRAL_COLOR,
208 markers=True,
209 marker_size=_MARKER_SIZE,
210 ),
211 ),
212 ref_lines=ref_lines,
213 # One tick per basis point: the axis spans a couple of dozen integers.
214 xaxis=Axis(title="One-way cost (basis points)", dtick=1),
215 yaxis=Axis(title="Annualised Sharpe ratio"),
216 )
217 return FigureSpec(
218 # Escaped rather than written literally: the title has always used an en
219 # dash, and ruff flags the bare character as visually ambiguous.
220 title=f"Trading Cost Impact on Sharpe Ratio (0\u2013{max_bps} bps)",
221 panels=(panel,),
222 height=None,
223 chrome="plain",
224 hover_mode="x unified",
225 )