Coverage for src/jquantstats/_plots/_render/_mpl.py: 100%

163 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 04:11 +0000

1"""Render a `FigureSpec` with matplotlib. 

2 

3Figures are built by constructing `matplotlib.figure.Figure` directly rather 

4than through ``pyplot``. pyplot keeps every figure it creates alive in a global 

5registry, so a script looping over hundreds of portfolios accumulates figures 

6until matplotlib warns and memory grows without bound — the resource cost behind 

7issue #628. A directly constructed Figure is owned by its caller, garbage 

8collected normally, and still supports ``fig.savefig(...)``. Importing this 

9module also never selects a GUI backend or mutates ``rcParams``. 

10 

11This backend reproduces the same data and the same static design as the Plotly 

12one. It does not emulate interactivity: hover tooltips and the date 

13range-selector buttons have no matplotlib equivalent and are silently absent. 

14Everything determining *what* is plotted — series, colours, axis scales and tick 

15formats — is reproduced. 

16""" 

17 

18from __future__ import annotations 

19 

20from typing import TYPE_CHECKING, Any 

21 

22import numpy as np 

23import polars as pl 

24from matplotlib import colormaps 

25from matplotlib.axes import Axes 

26from matplotlib.colors import LinearSegmentedColormap, TwoSlopeNorm, to_rgba 

27from matplotlib.figure import Figure 

28from matplotlib.ticker import EngFormatter, MultipleLocator, StrMethodFormatter 

29 

30from .._spec import ( 

31 Axis, 

32 Band, 

33 BarSeries, 

34 BoxSeries, 

35 Chrome, 

36 ColorScale, 

37 FigureSpec, 

38 HeatmapGrid, 

39 HistogramSeries, 

40 LineSeries, 

41 Panel, 

42 RefLine, 

43 TickFormat, 

44 Values, 

45) 

46 

47if TYPE_CHECKING: 

48 from matplotlib.ticker import Formatter 

49 

50__all__ = ["render_mpl"] 

51 

52# Pixels per inch used to reinterpret the pixel sizes the specs carry, so 

53# `figsize=(920, 420)` frames the same chart on either backend. 

54_DPI = 100 

55 

56# Sizes for a spec that names none. Plotly lets a figure size itself to its 

57# container; matplotlib needs numbers up front, so charts that never fix a 

58# dimension get these. 

59_DEFAULT_WIDTH_PX = 1000 

60_DEFAULT_HEIGHT_PX = 600 

61 

62# Semantic tick format -> a str.format field spec. Python's format mini-language 

63# covers percentages too, so `StrMethodFormatter` serves every case and no 

64# separate `PercentFormatter` is needed. 

65_FORMATS = { 

66 "float2": "{x:.2f}", 

67 "float4": "{x:.4f}", 

68 "percent0": "{x:.0%}", 

69 "percent1": "{x:.1%}", 

70 "percent2": "{x:.2%}", 

71 "currency0": "{x:,.0f}", 

72} 

73 

74# Named colour ramp -> how matplotlib names it. The custom one is built from 

75# its stops; the other two are matplotlib built-ins that Plotly borrowed the 

76# names from, so they need no translation beyond case. 

77_COLORSCALES: dict[ColorScale, tuple[str, ...]] = { 

78 "red_white_green": ("#d62728", "#ffffff", "#2ca02c"), 

79} 

80_BUILTIN_COLORMAPS: dict[ColorScale, str] = {"rdylgn": "RdYlGn", "rdbu_r": "RdBu_r"} 

81 

82# Semantic dash style -> matplotlib's linestyle vocabulary. 

83_LINESTYLES = {"solid": "-", "dash": "--", None: "-"} 

84 

85# The Plotly charts draw on white with a light grey grid; mirror that here so a 

86# figure is recognisably the same chart whichever backend drew it. 

87_GRID_COLOR = "lightgrey" 

88_GRID_WIDTH = 0.5 

89 

90 

91def _tick_formatter(tick_format: TickFormat, prefix: str) -> Formatter: 

92 """Build a tick formatter for one axis. 

93 

94 Args: 

95 tick_format: How to render the number. 

96 prefix: Written before each tick value, e.g. a currency sign. 

97 

98 Returns: 

99 Formatter: A formatter applying the requested format and prefix. 

100 

101 """ 

102 if tick_format == "si2": 

103 # Plotly's ".2s" abbreviates with an SI suffix — 1500000 reads as 

104 # "1.5M". `EngFormatter` is matplotlib's equivalent; `sep=""` keeps the 

105 # suffix tight against the number as d3 writes it. 

106 return EngFormatter(places=1, sep="") 

107 return StrMethodFormatter(f"{prefix}{_FORMATS[tick_format]}") 

108 

109 

110def _as_array(values: Values) -> Any: 

111 """Convert plotted values to a numpy array. 

112 

113 Specs carry either a polars Series or a plain list, whichever the chart 

114 already used (see `~jquantstats._plots._spec.Values`). Going via numpy 

115 rather than a list also turns a polars null into NaN, which matplotlib 

116 draws as a gap; a list of ``None`` would instead force a slow object-dtype 

117 array it cannot interpolate across. 

118 

119 Args: 

120 values: A polars Series or a Python list. 

121 

122 Returns: 

123 The values as a numpy array — floats where they convert, so ``None`` 

124 becomes NaN, and otherwise left as-is for x-axes holding dates. 

125 

126 """ 

127 if isinstance(values, pl.Series): 

128 return values.to_numpy() 

129 try: 

130 return np.asarray(values, dtype=float) 

131 except TypeError: 

132 # A date axis: leave the objects alone, matplotlib understands them. 

133 return np.asarray(values) 

134 

135 

136def _draw_line(ax: Axes, line: LineSeries) -> None: 

137 """Draw one series onto *ax*. 

138 

139 The series' `~jquantstats._spec.HoverSpec` is ignored: tooltips are 

140 interactive, and this backend is static. 

141 

142 Args: 

143 ax: The axes to draw on. 

144 line: The series to draw. 

145 

146 """ 

147 y = _as_array(line.y) 

148 # A series with no x is drawn against its own index, which is what 

149 # Plotly does with an unset `x` too. 

150 x = np.arange(len(y)) if line.x is None else _as_array(line.x) 

151 # A line that names no colour is left to matplotlib's own colour cycle, 

152 # matching how Plotly treats an unset `line.color`. 

153 styling: dict[str, Any] = {} if line.color is None else {"color": mpl_color(line.color)} 

154 if line.markers: 

155 styling["marker"] = "o" 

156 if line.marker_size is not None: 

157 # matplotlib sizes a marker by diameter in points, Plotly by area-ish 

158 # "size"; the square root keeps the two visually comparable. 

159 styling["markersize"] = line.marker_size**0.5 * 2 

160 ax.plot( 

161 x, 

162 y, 

163 linewidth=line.width, 

164 linestyle=_LINESTYLES[line.dash], 

165 label=line.name, 

166 **styling, 

167 ) 

168 if line.fill: 

169 # `where` keeps the fill out of the gaps a NaN leaves in the line, 

170 # which otherwise get shaded as though the value were zero. An unnamed 

171 # fill colour is left to matplotlib, which matches it to the line. 

172 shading: dict[str, Any] = {} if line.fill_color is None else {"color": mpl_color(line.fill_color)} 

173 ax.fill_between(x, y, 0, where=~np.isnan(y), linewidth=0, **shading) 

174 

175 

176def mpl_color(color: str) -> str | tuple[float, float, float, float]: 

177 """Translate a spec colour into something matplotlib accepts. 

178 

179 Specs spell translucent colours the CSS way — ``rgba(99, 110, 250, 0.4)`` 

180 — because that is what Plotly emits and the fidelity snapshots pin. 

181 matplotlib does not parse that form, so it is converted here; plain hex 

182 passes straight through. 

183 

184 Args: 

185 color: A hex string or a CSS ``rgba()`` string. 

186 

187 Returns: 

188 The colour as matplotlib understands it. 

189 

190 Examples: 

191 >>> mpl_color("#636EFA") 

192 '#636EFA' 

193 >>> mpl_color("rgba(99, 110, 250, 0.4)") 

194 (0.38823529411764707, 0.43137254901960786, 0.9803921568627451, 0.4) 

195 

196 """ 

197 if not color.startswith("rgba("): 

198 return color 

199 r, g, b, a = (part.strip() for part in color[len("rgba(") : -1].split(",")) 

200 return (int(r) / 255, int(g) / 255, int(b) / 255, float(a)) 

201 

202 

203def _faded(color: str, opacity: float) -> tuple[float, float, float, float]: 

204 """Scale a colour's alpha by *opacity*. 

205 

206 Matches Plotly, which multiplies a trace's opacity by the alpha already in 

207 its colour, rather than matplotlib's ``alpha=`` argument, which replaces it. 

208 

209 Args: 

210 color: A hex or CSS ``rgba()`` colour. 

211 opacity: Factor to scale the alpha channel by. 

212 

213 Returns: 

214 The colour as RGBA floats, alpha scaled. 

215 

216 Examples: 

217 >>> _faded("rgba(0, 0, 0, 0.4)", 0.5) 

218 (0.0, 0.0, 0.0, 0.2) 

219 

220 """ 

221 r, g, b, a = to_rgba(mpl_color(color)) 

222 return (r, g, b, a * opacity) 

223 

224 

225def _draw_bars(ax: Axes, bar: BarSeries) -> None: 

226 """Draw one bar series onto *ax*. 

227 

228 Args: 

229 ax: The axes to draw on. 

230 bar: The series to draw. 

231 

232 """ 

233 styling: dict[str, Any] = {} 

234 if bar.colors is not None: 

235 # Opacity is folded into each colour rather than passed as `alpha=`. 

236 # matplotlib's alpha argument *replaces* a colour's own alpha channel, 

237 # whereas Plotly multiplies its trace opacity by it — so passing it 

238 # separately would render the faded negative bars at the wrong strength. 

239 styling["color"] = [_faded(c, bar.opacity if bar.opacity is not None else 1.0) for c in bar.colors] 

240 elif bar.opacity is not None: 

241 # No colours named, so there is no alpha to fold into and nothing to 

242 # get wrong: matplotlib's own argument is the right tool. 

243 styling["alpha"] = bar.opacity 

244 

245 ax.bar(_as_array(bar.x), _as_array(bar.y), linewidth=0, label=bar.name, **styling) 

246 

247 

248def _draw_ref_line(ax: Axes, ref: RefLine) -> None: 

249 """Draw a fixed-value marker line onto *ax*. 

250 

251 Args: 

252 ax: The axes to draw on. 

253 ref: The line to draw. 

254 

255 """ 

256 draw = ax.axhline if ref.orientation == "h" else ax.axvline 

257 draw(ref.value, color=mpl_color(ref.color), linewidth=ref.width, linestyle=_LINESTYLES[ref.dash]) 

258 if ref.label is not None: 

259 # Anchored to the line in data coordinates and to the top of the axes, 

260 # matching where Plotly places its annotation. 

261 anchor = (0.0, ref.value) if ref.orientation == "h" else (ref.value, 1.0) 

262 coords = ("axes fraction", "data") if ref.orientation == "h" else ("data", "axes fraction") 

263 ax.annotate( 

264 ref.label, 

265 xy=anchor, 

266 xycoords=coords, 

267 xytext=(-2, -2), 

268 textcoords="offset points", 

269 ha="right", 

270 va="top", 

271 fontsize=ref.label_size, 

272 ) 

273 

274 

275def _draw_band(ax: Axes, band: Band) -> None: 

276 """Draw a shaded vertical span onto *ax*. 

277 

278 Args: 

279 ax: The axes to draw on. 

280 band: The span to draw. 

281 

282 """ 

283 ax.axvspan(band.x0, band.x1, color=mpl_color(band.color), linewidth=0) 

284 if band.label is not None: 

285 # Placed at the top of the span in axes coordinates, matching where 

286 # Plotly puts its annotation. 

287 ax.annotate( 

288 band.label, 

289 xy=(band.x0, 1.0), 

290 xycoords=("data", "axes fraction"), 

291 xytext=(2, -2), 

292 textcoords="offset points", 

293 ha="left", 

294 va="top", 

295 fontsize=band.label_size, 

296 ) 

297 

298 

299def _draw_histogram(ax: Axes, hist: HistogramSeries) -> None: 

300 """Draw one histogram series onto *ax*. 

301 

302 Args: 

303 ax: The axes to draw on. 

304 hist: The series to bin and draw. 

305 

306 """ 

307 # `bins=None` is matplotlib's own "use the default count", so an unset bin 

308 # count passes straight through rather than needing a separate call. 

309 ax.hist( 

310 _as_array(hist.values), 

311 bins=hist.bins, 

312 color=mpl_color(hist.color), 

313 alpha=hist.opacity, 

314 label=hist.name, 

315 ) 

316 

317 

318def _draw_boxes(ax: Axes, boxes: tuple[BoxSeries, ...]) -> None: 

319 """Draw a panel's box-and-whisker series onto *ax*. 

320 

321 Drawn together rather than one at a time: matplotlib positions boxes by 

322 index within a single call, and wants the category labels alongside. 

323 

324 Args: 

325 ax: The axes to draw on. 

326 boxes: The series to summarise, left to right. 

327 

328 """ 

329 if not boxes: 

330 return 

331 artists = ax.boxplot( 

332 [_as_array(box.values) for box in boxes], 

333 tick_labels=[box.name for box in boxes], 

334 showfliers=True, 

335 patch_artist=True, 

336 ) 

337 for patch, box in zip(artists["boxes"], boxes, strict=True): 

338 patch.set_facecolor(mpl_color(box.color)) 

339 

340 

341def _colormap(scale: ColorScale) -> Any: 

342 """Resolve a named colour ramp to a matplotlib colormap. 

343 

344 Args: 

345 scale: The ramp's semantic name. 

346 

347 Returns: 

348 The colormap, built from explicit stops or looked up by name. 

349 

350 """ 

351 builtin = _BUILTIN_COLORMAPS.get(scale) 

352 if builtin is not None: 

353 return colormaps[builtin] 

354 return LinearSegmentedColormap.from_list(scale, _COLORSCALES[scale]) 

355 

356 

357def _heatmap_norm(masked: Any, grid: HeatmapGrid) -> TwoSlopeNorm | None: 

358 """Build the diverging normalisation a matrix with a pinned centre needs. 

359 

360 Args: 

361 masked: The matrix's values, with uncovered cells masked out. 

362 grid: The matrix being drawn, for its pinned bounds. 

363 

364 Returns: 

365 The normalisation, or None when the grid sets no centre or has no 

366 unmasked values to scale. 

367 

368 """ 

369 if grid.zmid is None or not masked.count(): 

370 return None 

371 # Pinned bounds win over the data's own range: a correlation runs -1 to 

372 # 1 whatever this particular matrix happens to span. 

373 low = float(masked.min()) if grid.zmin is None else grid.zmin 

374 high = float(masked.max()) if grid.zmax is None else grid.zmax 

375 # TwoSlopeNorm needs the centre strictly inside the range; widen a 

376 # one-sided or degenerate span so an all-positive year still renders. 

377 low = min(low, grid.zmid - 1e-9) 

378 high = max(high, grid.zmid + 1e-9) 

379 return TwoSlopeNorm(vmin=low, vcenter=grid.zmid, vmax=high) 

380 

381 

382def _draw_cell_labels(ax: Axes, grid: HeatmapGrid) -> None: 

383 """Write each cell's label into the middle of its square. 

384 

385 Args: 

386 ax: The axes to draw on. 

387 grid: The matrix being drawn, for its per-cell text. 

388 

389 """ 

390 for row, labels in enumerate(grid.text): 

391 for col, label in enumerate(labels): 

392 if label: 

393 ax.text(col, row, label, ha="center", va="center", fontsize=8) 

394 

395 

396def _draw_heatmap(fig: Figure, ax: Axes, grid: HeatmapGrid) -> None: 

397 """Draw a value matrix onto *ax*, with per-cell labels and a colour bar. 

398 

399 Args: 

400 fig: The figure owning *ax*, needed to attach the colour bar. 

401 ax: The axes to draw on. 

402 grid: The matrix to draw. 

403 

404 """ 

405 # None marks a month the data does not cover. NaN carries that through 

406 # numpy, and a masked array keeps those cells unpainted rather than 

407 # colouring them as if they were zero. 

408 values = np.array([[float("nan") if v is None else v for v in row] for row in grid.z], dtype=float) 

409 masked = np.ma.masked_invalid(values) 

410 

411 image = ax.imshow(masked, cmap=_colormap(grid.colorscale), norm=_heatmap_norm(masked, grid), aspect="auto") 

412 

413 ax.set_xticks(range(len(grid.x_labels)), labels=list(grid.x_labels)) 

414 ax.set_yticks(range(len(grid.y_labels)), labels=list(grid.y_labels)) 

415 _draw_cell_labels(ax, grid) 

416 

417 fig.colorbar(image, ax=ax, label=grid.colorbar_title) 

418 

419 

420def _apply_axis(ax: Axes, axis: Axis, *, vertical: bool) -> None: 

421 """Apply one axis configuration to *ax*. 

422 

423 Only properties the spec actually set are touched, mirroring the Plotly 

424 renderer so the two backends agree on what "unset" means. 

425 

426 Args: 

427 ax: The axes to configure. 

428 axis: The requested configuration. 

429 vertical: Configure the y-axis rather than the x-axis. 

430 

431 """ 

432 target = ax.yaxis if vertical else ax.xaxis 

433 if axis.title is not None: 

434 target.set_label_text(axis.title) 

435 if axis.tick_format is not None: 

436 target.set_major_formatter(_tick_formatter(axis.tick_format, axis.tick_prefix)) 

437 if axis.log: 

438 set_scale = ax.set_yscale if vertical else ax.set_xscale 

439 set_scale("log") 

440 if axis.dtick is not None: 

441 target.set_major_locator(MultipleLocator(axis.dtick)) 

442 if axis.opposite_side: 

443 # Each axis has its own vocabulary for "the far edge", which is why 

444 # the spec says `opposite_side` rather than naming a compass point. 

445 if vertical: 

446 ax.yaxis.set_ticks_position("right") 

447 ax.yaxis.set_label_position("right") 

448 else: 

449 ax.xaxis.set_ticks_position("top") 

450 ax.xaxis.set_label_position("top") 

451 

452 

453def render_mpl(spec: FigureSpec) -> Figure: 

454 """Render *spec* as a static matplotlib figure. 

455 

456 Args: 

457 spec: The chart to draw. 

458 

459 Returns: 

460 Figure: The rendered figure. It is not registered with pyplot, so the 

461 caller owns it and nothing accumulates between calls. 

462 

463 """ 

464 width, height = spec.figsize if spec.figsize is not None else (_DEFAULT_WIDTH_PX, spec.height) 

465 fig = Figure(figsize=(width / _DPI, (height or _DEFAULT_HEIGHT_PX) / _DPI), dpi=_DPI) 

466 axes = _make_axes(fig, spec) 

467 

468 for ax, panel in zip(axes, spec.panels, strict=True): 

469 _draw_panel(fig, ax, panel, spec) 

470 fig.suptitle(spec.title) 

471 return fig 

472 

473 

474def _make_axes(fig: Figure, spec: FigureSpec) -> Any: 

475 """Create one Axes per panel, laid out as the spec asks. 

476 

477 Args: 

478 fig: The figure to add axes to. 

479 spec: The chart being rendered. 

480 

481 Returns: 

482 The axes, flattened to one per panel in spec order. 

483 

484 """ 

485 if spec.arrangement == "stacked": 

486 # Stacked panels share the time axis and split the height in the 

487 # proportions the panels ask for, so the headline view gets the room. 

488 return fig.subplots( 

489 nrows=len(spec.panels), 

490 sharex=spec.shared_x, 

491 height_ratios=[panel.height_ratio for panel in spec.panels], 

492 squeeze=False, 

493 )[:, 0] 

494 return fig.subplots(ncols=len(spec.panels), sharey=spec.shared_y, squeeze=False)[0] 

495 

496 

497def _draw_marks(fig: Figure, ax: Axes, panel: Panel) -> None: 

498 """Draw every mark the panel carries, in back-to-front order. 

499 

500 Reference lines and bands come last so they sit above the series they 

501 annotate. 

502 

503 Args: 

504 fig: The figure owning *ax*, needed to attach a colour bar. 

505 ax: The axes to draw on. 

506 panel: The panel whose marks to draw. 

507 

508 """ 

509 for line in panel.lines: 

510 _draw_line(ax, line) 

511 for bar in panel.bars: 

512 _draw_bars(ax, bar) 

513 for hist in panel.histograms: 

514 _draw_histogram(ax, hist) 

515 _draw_boxes(ax, panel.boxes) 

516 if panel.heatmap is not None: 

517 _draw_heatmap(fig, ax, panel.heatmap) 

518 for ref in panel.ref_lines: 

519 _draw_ref_line(ax, ref) 

520 for band in panel.bands: 

521 _draw_band(ax, band) 

522 

523 

524def _apply_grid(ax: Axes, chrome: Chrome) -> None: 

525 """Apply the grid the chart's chrome calls for. 

526 

527 Stated either way rather than leaning on ``rcParams["axes.grid"]``: that 

528 default is global and third-party libraries flip it on import (quantstats 

529 does), which would otherwise put a grid behind a matrix chart depending on 

530 what else the caller happened to import. 

531 

532 Args: 

533 ax: The axes to configure. 

534 chrome: The chart's figure-wide chrome setting. 

535 

536 """ 

537 if chrome == "bare": 

538 ax.grid(visible=False) 

539 elif chrome == "panels": 

540 # Horizontal rules help compare box heights across panels; vertical 

541 # ones would only clutter what is a categorical axis. 

542 ax.grid(visible=True, axis="y", color=_GRID_COLOR, linewidth=_GRID_WIDTH) 

543 else: 

544 ax.grid(visible=True, color=_GRID_COLOR, linewidth=_GRID_WIDTH) 

545 

546 

547def _draw_panel(fig: Figure, ax: Axes, panel: Panel, spec: FigureSpec) -> None: 

548 """Draw one panel's marks and configure its axes. 

549 

550 Args: 

551 fig: The figure owning *ax*, needed to attach a colour bar. 

552 ax: The axes to draw on. 

553 panel: The panel to draw. 

554 spec: The chart being rendered, for its figure-wide chrome. 

555 

556 """ 

557 _draw_marks(fig, ax, panel) 

558 

559 ax.set_facecolor("white") 

560 _apply_grid(ax, spec.chrome) 

561 

562 if panel.title is not None: 

563 ax.set_title(panel.title) 

564 _apply_axis(ax, panel.xaxis, vertical=False) 

565 _apply_axis(ax, panel.yaxis, vertical=True) 

566 

567 # Colour carries the value on a matrix chart, so its series names would 

568 # label nothing; only series charts get a legend. 

569 series_count = len(panel.lines) + len(panel.bars) + len(panel.histograms) 

570 if series_count and spec.chrome != "bare": 

571 ax.legend(loc="upper left", frameon=False, ncols=series_count)