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

1"""A backend-agnostic description of a chart. 

2 

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. 

8 

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. 

13 

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. 

18 

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

24 

25from __future__ import annotations 

26 

27from dataclasses import dataclass, field 

28from typing import Any, Literal, TypeAlias 

29 

30import numpy as np 

31import polars as pl 

32 

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] 

52 

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

62 

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

72 

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

80 

81#: Named colour ramps for matrix charts, resolved per backend. 

82ColorScale = Literal["red_white_green", "rdylgn", "rdbu_r"] 

83 

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

97 

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

105 

106 

107@dataclass(frozen=True, slots=True) 

108class HoverSpec: 

109 """The tooltip shown when a pointer rests on a series. 

110 

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. 

114 

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. 

126 

127 """ 

128 

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 

136 

137 

138@dataclass(frozen=True, slots=True) 

139class LineSeries: 

140 """One line drawn across a panel. 

141 

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. 

165 

166 """ 

167 

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 

184 

185 

186@dataclass(frozen=True, slots=True) 

187class HistogramSeries: 

188 """One set of binned values drawn as a histogram. 

189 

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. 

198 

199 """ 

200 

201 name: str 

202 values: Values 

203 color: str 

204 bins: int | None = None 

205 opacity: float = 0.6 

206 hover: HoverSpec | None = None 

207 

208 

209@dataclass(frozen=True, slots=True) 

210class BoxSeries: 

211 """One box-and-whisker summary of a set of values. 

212 

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. 

221 

222 """ 

223 

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 

230 

231 

232@dataclass(frozen=True, slots=True) 

233class BarSeries: 

234 """One set of bars drawn across a panel. 

235 

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. 

250 

251 """ 

252 

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 

261 

262 

263@dataclass(frozen=True, slots=True) 

264class RefLine: 

265 """A straight line marking a fixed value, such as break-even at zero. 

266 

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. 

276 

277 """ 

278 

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 

286 

287 

288@dataclass(frozen=True, slots=True) 

289class Band: 

290 """A shaded vertical span marking a stretch of the x-axis. 

291 

292 Used to pick out the worst drawdown episodes on an equity curve. 

293 

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. 

300 

301 """ 

302 

303 x0: Any 

304 x1: Any 

305 color: str 

306 label: str | None = None 

307 label_size: int = 10 

308 

309 

310@dataclass(frozen=True, slots=True) 

311class HeatmapGrid: 

312 """A matrix of values drawn as a coloured grid. 

313 

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. 

328 

329 """ 

330 

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 

343 

344 

345@dataclass(frozen=True, slots=True) 

346class Axis: 

347 """Configuration for one axis of a panel. 

348 

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. 

352 

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. 

366 

367 """ 

368 

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 

379 

380 

381@dataclass(frozen=True, slots=True) 

382class Panel: 

383 """One set of axes and the marks drawn on it. 

384 

385 A chart is a tuple of panels. Most are a single panel; the dashboards stack 

386 several sharing an x-axis. 

387 

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. 

401 

402 """ 

403 

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 

415 

416 

417@dataclass(frozen=True, slots=True) 

418class FigureSpec: 

419 """A complete chart, ready for any renderer. 

420 

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. 

445 

446 """ 

447 

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