Coverage for src/jquantstats/_plots/_spec.py: 100%
127 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"""A backend-agnostic description of a chart.
3Every plot method in jquantstats is two separable jobs: prepare the numbers with
4polars, then hand them to a drawing library. The types here are the seam between
5those halves. A *spec builder* under `jquantstats._plots._specs` turns a dataset
6into a `FigureSpec`; a *renderer* under `jquantstats._plots._render` turns that
7`FigureSpec` into a Plotly or matplotlib figure.
9The point of the seam is arithmetic. Without it a second backend means a second
10copy of every rolling beta, drawdown scan and Monte Carlo path, and the two
11copies drift. With it there is one builder per chart and one renderer per
12backend.
14Nothing here may import a drawing library, and no field may hold a
15backend-specific value — no Plotly format strings, no matplotlib linestyle
16tuples. Where a visual property has to be named, it is named semantically
17(`"float2"`, `"dash"`) and each renderer maps it to its own vocabulary.
19**Series stay as `polars.Series`.** Plotly serialises a Series to a compact
20binary buffer and a Python list to a plain JSON array, so converting here would
21silently change every rendered figure. Renderers that need other containers
22convert at the point of use.
23"""
25from __future__ import annotations
27from dataclasses import dataclass, field
28from typing import Any, Literal, TypeAlias
30import numpy as np
31import polars as pl
33__all__ = [
34 "Arrangement",
35 "Axis",
36 "Band",
37 "BarSeries",
38 "BoxSeries",
39 "Chrome",
40 "ColorScale",
41 "Dash",
42 "FigureSpec",
43 "HeatmapGrid",
44 "HistogramSeries",
45 "HoverSpec",
46 "LineSeries",
47 "Panel",
48 "RefLine",
49 "TickFormat",
50 "Values",
51]
53#: Plotted values: a polars Series, a numpy array, or a plain Python list.
54#:
55#: All three are accepted deliberately. Plotly serialises a Series or an array
56#: to a compact binary buffer and a list to a plain JSON array, and each chart's
57#: existing wire format is pinned by the fidelity snapshots. Builders therefore
58#: pass through whichever container the chart already used rather than
59#: normalising, so moving a chart onto this seam changes nothing. Renderers must
60#: accept any of them.
61Values: TypeAlias = "pl.Series | np.ndarray[Any, Any] | list[Any]"
63#: How to render a number, named by intent rather than by any backend's syntax.
64#:
65#: ``float2``/``float4`` are fixed-point to that many decimals; ``percent0`` to
66#: ``percent2`` scale to a percentage with that many decimals; ``currency0`` is
67#: a thousands-separated integer; ``si2`` abbreviates large numbers with an SI
68#: suffix, so a NAV of 1 500 000 reads as ``1.5M``. Each renderer owns the mapping — Plotly wants
69#: ``".2f"``, matplotlib wants a ``Formatter`` — so neither vocabulary leaks in
70#: here.
71TickFormat = Literal["float2", "float4", "percent0", "percent1", "percent2", "currency0", "si2"]
73#: Line styles, kept to the set the charts actually use.
74#:
75#: A field typed ``Dash | None`` treats None as *say nothing* and leave the
76#: backend's default, which is distinct from asking for ``"solid"``
77#: explicitly. Some charts state it and some do not, and Plotly records the
78#: difference in its serialised output.
79Dash = Literal["solid", "dash"]
81#: Named colour ramps for matrix charts, resolved per backend.
82ColorScale = Literal["red_white_green", "rdylgn", "rdbu_r"]
84#: How much furniture a chart carries.
85#:
86#: ``timeseries`` is the standard treatment shared by most charts: a legend,
87#: unified hover, a light grid and optionally the date range-selector.
88#: ``bare`` is for matrix charts, where colour encodes the value rather than
89#: the series — a legend would name nothing and a shared-x hover has no
90#: meaning, so only the title, height and background are set.
91#: ``panels`` is the small-multiples treatment: one panel per asset sharing a
92#: scale, with the legend naming the categories repeated in each panel rather
93#: than the panels themselves.
94#: ``plain`` is the standard treatment minus the legend and the range
95#: selector: a single-series diagnostic that names itself in its title.
96Chrome = Literal["timeseries", "bare", "panels", "plain"]
98#: How a chart's panels are laid out.
99#:
100#: ``side_by_side`` places them in a row sharing the vertical scale, so the same
101#: measurement can be compared across assets. ``stacked`` places them in a
102#: column sharing the time axis, so different measurements line up at the same
103#: date — the dashboard shape.
104Arrangement = Literal["single", "side_by_side", "stacked"]
107@dataclass(frozen=True, slots=True)
108class HoverSpec:
109 """The tooltip shown when a pointer rests on a series.
111 Interactive-only, and therefore Plotly-only: matplotlib has no equivalent
112 and its renderer ignores this entirely. It is described structurally rather
113 than as a template string so that spec builders never write Plotly syntax.
115 Attributes:
116 label: Text naming the series, shown before the value.
117 value_format: How to render the value.
118 prefix: Written immediately before the value, e.g. a currency sign.
119 suffix: Written immediately after the value, e.g. ``"x"`` to mark a
120 growth multiple.
121 date_header: Show the x-value as a bold ``Mon YYYY`` heading above.
122 axis: Which coordinate carries the value. Histograms bin along x, so
123 their tooltip reads the x-value; everything else reads y.
124 hide_extra: Suppress the trace-name box Plotly appends beside the
125 tooltip.
127 """
129 label: str
130 value_format: TickFormat
131 prefix: str = ""
132 suffix: str = ""
133 date_header: bool = True
134 axis: Literal["x", "y"] = "y"
135 hide_extra: bool = False
138@dataclass(frozen=True, slots=True)
139class LineSeries:
140 """One line drawn across a panel.
142 Attributes:
143 name: Legend entry for the series.
144 x: Horizontal positions, typically the date column, or None to let the
145 backend number the points.
146 y: Vertical positions.
147 color: Line colour as ``#RRGGBB``, or None to take the backend's next
148 palette colour.
149 width: Stroke width.
150 dash: Stroke pattern, or None to leave the backend's default.
151 markers: Draw a point at each observation as well as the line. Used
152 where the observations are few and individually meaningful.
153 marker_size: Point size, or None for the backend's default.
154 fill: Shade the area between the line and zero. Used by the underwater
155 curves.
156 fill_color: What colour to shade it, or None to let the backend match
157 the line. Only consulted when *fill* is set.
158 hover: Tooltip description, or None to leave the backend's default.
159 show_legend: Whether to give this series its own legend entry, or None
160 to say nothing and leave the backend's default. The fan charts
161 state it on every path — True for the first of a bundle, False for
162 the rest — so that hundreds of lines produce one entry.
163 legend_group: Name tying several series together, so toggling the
164 legend shows or hides them as one.
166 """
168 name: str
169 y: Values
170 x: Values | None = None
171 color: str | None = None
172 # Left as an int so a whole-number width serialises as `2` rather than
173 # `2.0`. Plotly preserves the distinction in its JSON, and the fidelity
174 # snapshots compare that JSON exactly.
175 width: float = 2
176 dash: Dash | None = None
177 markers: bool = False
178 marker_size: int | None = None
179 fill: bool = False
180 fill_color: str | None = None
181 hover: HoverSpec | None = None
182 show_legend: bool | None = None
183 legend_group: str | None = None
186@dataclass(frozen=True, slots=True)
187class HistogramSeries:
188 """One set of binned values drawn as a histogram.
190 Attributes:
191 name: Legend entry for the series.
192 values: The observations to bin.
193 color: Bar colour.
194 bins: Requested bin count, or None for the backend's own choice.
195 opacity: Fill opacity. These charts overlay several series, so the
196 default is translucent.
197 hover: Tooltip description, or None to leave the backend's default.
199 """
201 name: str
202 values: Values
203 color: str
204 bins: int | None = None
205 opacity: float = 0.6
206 hover: HoverSpec | None = None
209@dataclass(frozen=True, slots=True)
210class BoxSeries:
211 """One box-and-whisker summary of a set of values.
213 Attributes:
214 name: Category label, shown on the axis and in the legend.
215 values: The observations to summarise.
216 color: Box colour.
217 show_legend: Give this series its own legend entry. With one panel per
218 asset the same categories repeat, so only the first panel does.
219 legend_group: Name tying the same category across panels together.
220 hover: Tooltip description, or None to leave the backend's default.
222 """
224 name: str
225 values: Values
226 color: str
227 show_legend: bool = True
228 legend_group: str | None = None
229 hover: HoverSpec | None = None
232@dataclass(frozen=True, slots=True)
233class BarSeries:
234 """One set of bars drawn across a panel.
236 Attributes:
237 name: Legend entry for the series.
238 x: Bar positions — dates, or the category each bar sits at.
239 y: Bar heights.
240 colors: One colour per bar, or None to take the backend's palette.
241 Per-bar rather than per-series because several of these charts
242 colour a bar by the sign of its value.
243 opacity: Fill opacity, or None for the backend's default.
244 hover: Tooltip description, or None to leave the backend's default.
245 show_legend: Whether to give this series its own legend entry, or None
246 to say nothing. A dashboard names an asset once, on its headline
247 panel, rather than again in every panel.
248 legend_group: Name tying several series together, so toggling the
249 legend shows or hides them as one.
251 """
253 name: str
254 x: Values
255 y: Values
256 colors: tuple[str, ...] | None = None
257 opacity: float | None = None
258 hover: HoverSpec | None = None
259 show_legend: bool | None = None
260 legend_group: str | None = None
263@dataclass(frozen=True, slots=True)
264class RefLine:
265 """A straight line marking a fixed value, such as break-even at zero.
267 Attributes:
268 value: Where to draw it, in data coordinates.
269 orientation: ``"h"`` for a horizontal line at *value*, ``"v"`` for a
270 vertical one.
271 color: Line colour.
272 width: Stroke width.
273 dash: Stroke pattern, or None to leave the backend's default.
274 label: Text naming the line, or None for no label.
275 label_size: Point size for that text.
277 """
279 value: float
280 orientation: Literal["h", "v"] = "h"
281 color: str = "gray"
282 width: float = 1
283 dash: Dash | None = None
284 label: str | None = None
285 label_size: int = 10
288@dataclass(frozen=True, slots=True)
289class Band:
290 """A shaded vertical span marking a stretch of the x-axis.
292 Used to pick out the worst drawdown episodes on an equity curve.
294 Attributes:
295 x0: Where the span starts.
296 x1: Where it ends.
297 color: Fill colour, usually translucent so the line stays readable.
298 label: Text drawn at the top of the span, or None for no label.
299 label_size: Point size for that text.
301 """
303 x0: Any
304 x1: Any
305 color: str
306 label: str | None = None
307 label_size: int = 10
310@dataclass(frozen=True, slots=True)
311class HeatmapGrid:
312 """A matrix of values drawn as a coloured grid.
314 Attributes:
315 x_labels: Column headings, left to right.
316 y_labels: Row headings, top to bottom.
317 z: The values, as ``z[row][column]``. None marks a missing cell.
318 text: Per-cell labels drawn over the grid, parallel to *z*.
319 colorscale: The colour ramp to map values through.
320 zmid: Value anchored to the middle of a diverging ramp, or None to
321 span the data range.
322 zmin: Value pinned to the low end of the ramp, or None for the data's
323 minimum.
324 zmax: Value pinned to the high end, or None for the data's maximum.
325 colorbar_title: Heading for the colour scale legend.
326 hover_label: Word naming the quantity in the tooltip, or None for no
327 tooltip. Interactive-only, so matplotlib ignores it.
329 """
331 x_labels: tuple[str, ...]
332 y_labels: tuple[str, ...]
333 z: tuple[tuple[float | None, ...], ...]
334 text: tuple[tuple[str, ...], ...]
335 colorscale: ColorScale
336 # An int, so a whole-number anchor serialises as `0` rather than `0.0`;
337 # Plotly preserves the distinction and the fidelity snapshots compare it.
338 zmid: float | None = 0
339 zmin: float | None = None
340 zmax: float | None = None
341 colorbar_title: str = ""
342 hover_label: str | None = None
345@dataclass(frozen=True, slots=True)
346class Axis:
347 """Configuration for one axis of a panel.
349 Every field defaults to "leave it alone", so a renderer sets only what a
350 builder asked for. That matters for fidelity: emitting a property the
351 original chart never set would change the rendered output.
353 Attributes:
354 title: Axis label, or None for no label.
355 tick_format: How to render tick values, or None for the default.
356 tick_prefix: Written before each tick value, e.g. a currency sign.
357 log: Use a logarithmic scale.
358 kind: Force an axis type, or None to let the backend infer one.
359 ``"category"`` keeps year labels as discrete rows rather than
360 numbers on a continuous scale.
361 dtick: Spacing between ticks, or None for the backend's choice.
362 opposite_side: Draw the axis on the far edge — the top for a
363 horizontal axis, the right for a vertical one. The monthly
364 calendar puts its months along the top, where they read as column
365 headings.
367 """
369 title: str | None = None
370 tick_format: TickFormat | None = None
371 tick_prefix: str = ""
372 log: bool = False
373 kind: Literal["category"] | None = None
374 dtick: float | None = None
375 # Deliberately a flag rather than a compass point. Which edge counts as
376 # "opposite" depends on the axis, and naming it absolutely would admit
377 # nonsense a renderer would then have to police — an x-axis on the "left".
378 opposite_side: bool = False
381@dataclass(frozen=True, slots=True)
382class Panel:
383 """One set of axes and the marks drawn on it.
385 A chart is a tuple of panels. Most are a single panel; the dashboards stack
386 several sharing an x-axis.
388 Attributes:
389 lines: Line series to draw.
390 bars: Bar series to draw.
391 histograms: Histogram series to draw.
392 boxes: Box-and-whisker series to draw.
393 heatmap: A value matrix to draw, or None.
394 ref_lines: Fixed-value marker lines.
395 bands: Shaded vertical spans, drawn behind the series.
396 xaxis: Horizontal axis configuration.
397 yaxis: Vertical axis configuration.
398 title: Heading for this panel, used when a chart has several.
399 height_ratio: This panel's share of the figure height, relative to its
400 siblings. A dashboard gives its headline panel the larger share.
402 """
404 lines: tuple[LineSeries, ...] = ()
405 bars: tuple[BarSeries, ...] = ()
406 histograms: tuple[HistogramSeries, ...] = ()
407 boxes: tuple[BoxSeries, ...] = ()
408 heatmap: HeatmapGrid | None = None
409 ref_lines: tuple[RefLine, ...] = ()
410 bands: tuple[Band, ...] = ()
411 xaxis: Axis = field(default_factory=Axis)
412 yaxis: Axis = field(default_factory=Axis)
413 title: str | None = None
414 height_ratio: float = 1.0
417@dataclass(frozen=True, slots=True)
418class FigureSpec:
419 """A complete chart, ready for any renderer.
421 Attributes:
422 title: Chart title.
423 panels: The panels to draw, top to bottom.
424 height: Figure height in pixels, or None to let the backend size it.
425 figsize: Optional ``(width, height)`` in pixels, overriding *height*.
426 Pixels for both backends; the matplotlib renderer converts to
427 inches so one public signature means the same thing either way.
428 date_range_selector: Offer the Plotly range-selector buttons. Ignored
429 by matplotlib, which has no interactive widgets.
430 chrome: How much surrounding furniture the chart carries.
431 arrangement: How the panels are laid out.
432 shared_y: Give side-by-side panels one vertical scale, so their
433 distributions are directly comparable.
434 shared_x: Give stacked panels one horizontal scale, so a date lines up
435 down the whole dashboard.
436 vertical_spacing: Gap between stacked panels as a figure fraction, or
437 None for the backend's default.
438 hover_mode: How tooltips gather points, or None for the backend's
439 default. Only consulted for ``plain`` chrome; the other treatments
440 each fix their own.
441 width: Figure width in pixels, or None to let the backend size it.
442 Distinct from *figsize*, which sets both dimensions at once.
443 bar_mode: How bars from different series share an x position, or None
444 for the backend's default.
446 """
448 title: str
449 panels: tuple[Panel, ...]
450 height: int | None = 600
451 figsize: tuple[int, int] | None = None
452 date_range_selector: bool = True
453 chrome: Chrome = "timeseries"
454 arrangement: Arrangement = "single"
455 shared_y: bool = False
456 shared_x: bool = False
457 vertical_spacing: float | None = None
458 hover_mode: Literal["x", "x unified"] | None = None
459 width: int | None = None
460 bar_mode: Literal["group", "overlay", "relative"] | None = None