tesorotools-python 0.0.50__tar.gz → 0.0.52__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/.gitignore +5 -1
  2. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/PKG-INFO +3 -1
  3. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/pyproject.toml +4 -0
  4. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/__init__.py +9 -0
  5. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/__init__.py +6 -0
  6. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/_common.py +238 -34
  7. tesorotools_python-0.0.52/src/tesorotools/artists/bar_line.py +362 -0
  8. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/barh_plot.py +20 -0
  9. tesorotools_python-0.0.52/src/tesorotools/artists/box_plot.py +517 -0
  10. tesorotools_python-0.0.52/src/tesorotools/artists/intraday_plot.py +309 -0
  11. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/line_plot.py +161 -16
  12. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/matrix.py +10 -0
  13. tesorotools_python-0.0.52/src/tesorotools/artists/plotly_backend.py +1033 -0
  14. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/shock_plot.py +25 -3
  15. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/stacked.py +26 -3
  16. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/type_curve.py +10 -0
  17. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/vector_plot.py +10 -0
  18. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/waterfall.py +10 -0
  19. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/plots.yaml +57 -0
  20. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/images.py +18 -9
  21. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/_build_context.py +0 -0
  22. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/_registry.py +0 -0
  23. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/compact.py +0 -0
  24. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/README.md +0 -0
  25. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  26. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  27. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  28. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  29. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  30. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  31. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  32. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  33. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/README.md +0 -0
  34. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/template.docx +0 -0
  35. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  36. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/data_sources/__init__.py +0 -0
  37. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/data_sources/debug.py +0 -0
  38. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/database/__init__.py +0 -0
  39. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/database/local.py +0 -0
  40. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/database/push.py +0 -0
  41. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/database/shared.py +0 -0
  42. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/dependencies/__init__.py +0 -0
  43. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/dependencies/node.py +0 -0
  44. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/dependencies/resolution.py +0 -0
  45. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/driver.py +0 -0
  46. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/manifest.py +0 -0
  47. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/offsets/__init__.py +0 -0
  48. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/offsets/offsets.py +0 -0
  49. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/offsets/outliers.py +0 -0
  50. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/orchestration.py +0 -0
  51. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/__init__.py +0 -0
  52. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/diagnose.py +0 -0
  53. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/engine.py +0 -0
  54. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/rules.py +0 -0
  55. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/__init__.py +0 -0
  56. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/base.py +0 -0
  57. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/bde.py +0 -0
  58. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/ecb.py +0 -0
  59. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/imf_irfcl.py +0 -0
  60. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/lseg.py +0 -0
  61. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/py.typed +0 -0
  62. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/__init__.py +0 -0
  63. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/__init__.py +0 -0
  64. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/content.py +0 -0
  65. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/section.py +0 -0
  66. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/subtitle.py +0 -0
  67. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/table.py +0 -0
  68. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/text.py +0 -0
  69. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/title.py +0 -0
  70. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/document.py +0 -0
  71. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/report.py +0 -0
  72. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/testing/__init__.py +0 -0
  73. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/testing/compare.py +0 -0
  74. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/__init__.py +0 -0
  75. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/config.py +0 -0
  76. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/format.py +0 -0
  77. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/globals.py +0 -0
  78. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/matplotlib.py +0 -0
  79. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/series.py +0 -0
  80. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/shortcuts.py +0 -0
  81. {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/template.py +0 -0
@@ -3,7 +3,11 @@
3
3
 
4
4
  __pycache__
5
5
 
6
- .vscode/
6
+ # Ignorar ajustes personales de VS Code, pero versionar los compartidos
7
+ # (recomendación de extensiones y formato de markdown con prettier).
8
+ .vscode/*
9
+ !.vscode/extensions.json
10
+ !.vscode/settings.json
7
11
 
8
12
  data/
9
13
  config/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.50
3
+ Version: 0.0.52
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -19,5 +19,7 @@ Requires-Dist: requests>=2.31; extra == 'ecb'
19
19
  Provides-Extra: imf
20
20
  Requires-Dist: requests>=2.31; extra == 'imf'
21
21
  Requires-Dist: truststore>=0.10; extra == 'imf'
22
+ Provides-Extra: interactive-plots
23
+ Requires-Dist: plotly>=5.0; extra == 'interactive-plots'
22
24
  Provides-Extra: lseg
23
25
  Requires-Dist: lseg-data>=2.1; extra == 'lseg'
@@ -29,6 +29,7 @@ bde = ["requests>=2.31"]
29
29
  ecb = ["requests>=2.31"]
30
30
  imf = ["requests>=2.31", "truststore>=0.10"]
31
31
  lseg = ["lseg-data>=2.1"]
32
+ interactive_plots = ["plotly>=5.0"]
32
33
 
33
34
  [dependency-groups]
34
35
  dev = [
@@ -37,6 +38,9 @@ dev = [
37
38
  "pre-commit>=4.0",
38
39
  "pandas-stubs>=2.2",
39
40
  "coverage>=7.0",
41
+ # the interactive Plotly backend is an optional extra, but the test
42
+ # suite exercises it (and the 100% coverage gate requires it present).
43
+ "plotly>=5.0",
40
44
  ]
41
45
 
42
46
  [build-system]
@@ -53,9 +53,12 @@ from tesorotools._registry import (
53
53
  register_tags,
54
54
  )
55
55
  from tesorotools.artists import (
56
+ BarLinePlot,
57
+ BoxPlot,
56
58
  Format,
57
59
  GroupedBarChart,
58
60
  HorizontalBarChart,
61
+ IntradayPlot,
59
62
  Legend,
60
63
  LinePlot,
61
64
  MatrixChart,
@@ -88,6 +91,8 @@ from tesorotools.render import (
88
91
 
89
92
  def _register_builtins() -> None:
90
93
  register_artist("line_plot", LinePlot)
94
+ register_artist("bar_line", BarLinePlot)
95
+ register_artist("box_plot", BoxPlot)
91
96
  register_artist("stacked_area", StackedAreaPlot)
92
97
  register_artist("stacked_bar", StackedBarPlot)
93
98
  register_artist("barh", HorizontalBarChart)
@@ -96,6 +101,7 @@ def _register_builtins() -> None:
96
101
  register_artist("vector_plot", VectorPlot)
97
102
  register_artist("matrix", MatrixChart)
98
103
  register_artist("shock_plot", ShockChart)
104
+ register_artist("intraday", IntradayPlot)
99
105
  register_artist("waterfall", Waterfall)
100
106
 
101
107
  register_tag("format", Format)
@@ -116,6 +122,8 @@ _register_builtins()
116
122
  __all__ = [
117
123
  "__version__",
118
124
  "Artist",
125
+ "BarLinePlot",
126
+ "BoxPlot",
119
127
  "BdeProvider",
120
128
  "BuildContext",
121
129
  "CompositeRegistry",
@@ -127,6 +135,7 @@ __all__ = [
127
135
  "HorizontalBarChart",
128
136
  "Image",
129
137
  "Images",
138
+ "IntradayPlot",
130
139
  "Legend",
131
140
  "LinePlot",
132
141
  "LSEGProvider",
@@ -26,7 +26,10 @@ strictly image output.
26
26
  """
27
27
 
28
28
  from tesorotools.artists._common import Artist, Format, Legend
29
+ from tesorotools.artists.bar_line import BarLinePlot
29
30
  from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
31
+ from tesorotools.artists.box_plot import BoxPlot
32
+ from tesorotools.artists.intraday_plot import IntradayPlot
30
33
  from tesorotools.artists.line_plot import LinePlot
31
34
  from tesorotools.artists.matrix import MatrixChart
32
35
  from tesorotools.artists.shock_plot import ShockChart
@@ -37,9 +40,12 @@ from tesorotools.artists.waterfall import Waterfall
37
40
 
38
41
  __all__ = [
39
42
  "Artist",
43
+ "BarLinePlot",
44
+ "BoxPlot",
40
45
  "Format",
41
46
  "GroupedBarChart",
42
47
  "HorizontalBarChart",
48
+ "IntradayPlot",
43
49
  "Legend",
44
50
  "LinePlot",
45
51
  "MatrixChart",
@@ -24,7 +24,7 @@ overrides only need to update ``plots.yaml``.
24
24
  from __future__ import annotations
25
25
 
26
26
  from pathlib import Path
27
- from typing import Any, Protocol, Self, cast, runtime_checkable
27
+ from typing import Any, Literal, Protocol, Self, cast, runtime_checkable
28
28
 
29
29
  import matplotlib.pyplot as plt
30
30
  import pandas as pd
@@ -58,10 +58,44 @@ apply_house_style()
58
58
  AX_CONFIG: dict[str, Any] = PLOT_CONFIG["ax"]
59
59
  FIG_CONFIG: dict[str, Any] = PLOT_CONFIG["figure"]
60
60
  COMPACT_CONFIG: dict[str, Any] = PLOT_CONFIG.get("compact", {})
61
+ LINE_CONFIG: dict[str, Any] = PLOT_CONFIG.get("line", {})
61
62
  SAVE_CONFIG: dict[str, Any] = PLOT_CONFIG.get("save", {})
62
63
 
63
64
  CM_TO_IN: float = 1.0 / 2.54
64
65
 
66
+ # Soft date-density levels -> AutoDateLocator maxticks. Fallbacks used when the
67
+ # matching ``plots.yaml`` block (``compact.date_maxticks`` / ``line.date_maxticks``)
68
+ # is missing a level; "default" reproduces the historical tick count of each mode
69
+ # (4 for compact, 6 for non-compact).
70
+ DATE_DENSITY_LEVELS: tuple[str, ...] = ("few", "default", "many")
71
+ _DATE_MAXTICKS_FALLBACK_COMPACT: dict[str, int] = {
72
+ "few": 3,
73
+ "default": 4,
74
+ "many": 7,
75
+ }
76
+ _DATE_MAXTICKS_FALLBACK_LINE: dict[str, int] = {
77
+ "few": 3,
78
+ "default": 6,
79
+ "many": 10,
80
+ }
81
+
82
+
83
+ def date_maxticks(density: str, *, compact: bool) -> int:
84
+ """Resolve a soft ``date_density`` level to an ``AutoDateLocator`` maxticks.
85
+
86
+ Reads the per-mode ``date_maxticks`` table from ``plots.yaml``
87
+ (``compact.date_maxticks`` when *compact*, else ``line.date_maxticks``),
88
+ falling back to the built-in table when the config (or a level) is absent.
89
+ """
90
+ cfg = COMPACT_CONFIG if compact else LINE_CONFIG
91
+ fallback = (
92
+ _DATE_MAXTICKS_FALLBACK_COMPACT
93
+ if compact
94
+ else _DATE_MAXTICKS_FALLBACK_LINE
95
+ )
96
+ table: dict[str, Any] = cfg.get("date_maxticks", fallback)
97
+ return int(table.get(density, fallback[density]))
98
+
65
99
 
66
100
  def dynamic_dpi(fig: Figure) -> int:
67
101
  """Save DPI adapted to the figure's physical size.
@@ -96,9 +130,7 @@ def dynamic_dpi(fig: Figure) -> int:
96
130
  class Artist(Protocol):
97
131
  """Structural contract every image artist satisfies.
98
132
 
99
- The five built-in artists (``LinePlot``,
100
- ``StackedAreaPlot``, ``StackedBarPlot``, ``TypeCurve``,
101
- ``HorizontalBarChart``) and any third-party artist
133
+ The built-in artists and any third-party artist
102
134
  registered via :func:`tesorotools.register_artist`
103
135
  expose the same three-piece surface:
104
136
 
@@ -260,6 +292,51 @@ def adjust_figure_for_plot_size(
260
292
  fig.set_size_inches(new_w, new_h)
261
293
 
262
294
 
295
+ def grow_height_to_axes(
296
+ fig: Figure,
297
+ ax: Axes,
298
+ target_axes_h_in: float,
299
+ *,
300
+ max_iter: int = 3,
301
+ cap_factor: float = 2.0,
302
+ ) -> None:
303
+ """Grow the figure **height** (width fixed) until *ax* is *target* inches tall.
304
+
305
+ Under ``constrained`` layout an outside legend band and the x-tick label
306
+ strip keep a roughly **fixed absolute** height, so height added to the
307
+ figure flows almost entirely into the axes rectangle. Growing to the axes
308
+ height measured *before* the legend was added therefore gives the plot area
309
+ back the vertical space the legend (and any rotated date labels) stole,
310
+ instead of squashing it inside a fixed figure.
311
+
312
+ A feedback loop (grow -> draw -> re-measure the deficit) converges in a few
313
+ passes because the reserved bands are non-linear only at the margin; total
314
+ growth is capped at ``cap_factor`` times the original height so a huge
315
+ legend cannot run the figure away. Measures the axes rectangle via
316
+ ``get_window_extent`` (not ``get_tightbbox``, which includes the very tick
317
+ strip being budgeted for).
318
+ """
319
+ orig_w, orig_h = (float(v) for v in fig.get_size_inches())
320
+ max_h = orig_h * cap_factor
321
+ eps_in = 0.02
322
+ for _ in range(max_iter):
323
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
324
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
325
+ ax_h_in = (
326
+ ax.get_window_extent(renderer).height # type: ignore[reportUnknownArgumentType]
327
+ / fig.dpi
328
+ )
329
+ deficit = target_axes_h_in - ax_h_in
330
+ if deficit <= eps_in:
331
+ break
332
+ current_h = float(fig.get_size_inches()[1])
333
+ new_h = min(max_h, current_h + deficit)
334
+ if new_h <= current_h: # capped out: no further progress possible
335
+ break
336
+ fig.set_size_inches(orig_w, new_h)
337
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
338
+
339
+
263
340
  def auto_ncol(
264
341
  ax: Axes,
265
342
  labels: list[str],
@@ -498,25 +575,62 @@ class _StartLabeledConcise(ConciseDateFormatter):
498
575
  return labels
499
576
 
500
577
 
578
+ def _date_label_ha(
579
+ rotation: float | None,
580
+ ) -> Literal["left", "center", "right"]:
581
+ """Horizontal alignment for date tick labels at *rotation* degrees.
582
+
583
+ Horizontal labels centre under their tick; a positive (counter-clockwise,
584
+ up-to-the-right) slant reads best right-anchored so each label runs
585
+ down-left under its tick, and a negative slant left-anchored.
586
+ """
587
+ if rotation is None or rotation == 0:
588
+ return "center"
589
+ return "right" if rotation > 0 else "left"
590
+
591
+
501
592
  def compact_date_axis(
502
593
  ax: Axes,
503
594
  data_min: Any,
504
595
  data_max: Any,
505
596
  *,
506
597
  maxticks: int = 4,
598
+ rotation: float | None = None,
599
+ pin_start: bool | None = None,
600
+ auto_rotate: bool = False,
507
601
  ) -> None:
508
- """Compact date x-axis: few horizontal ticks, anchored to the data span.
602
+ """Compact date x-axis: few ticks, anchored to the data span.
509
603
 
510
604
  Ticks are computed over the **data span** ``[data_min,
511
605
  data_max]`` and pinned with a ``FixedLocator``, so none
512
606
  float in the right-hand margin that the value labels open
513
607
  up -- the last tick lands as close to the final data
514
- point as the tick step allows. When ``compact.date_show_start``
515
- is true (the default) the first data point is pinned as the
516
- leading tick, so the reader can see exactly when the series
517
- begins. Requires the series to have been plotted with
518
- ``x_compat=True`` (pandas' own date plotting ignores this
519
- formatter).
608
+ point as the tick step allows. When the start is pinned (see
609
+ ``pin_start``) the first data point becomes the leading tick, so
610
+ the reader sees exactly when the series begins. Requires the
611
+ series to have been plotted with ``x_compat=True`` (pandas' own
612
+ date plotting ignores this formatter).
613
+
614
+ ``rotation`` (degrees) tilts the tick labels so more dates fit;
615
+ ``None`` keeps them horizontal (the historical behaviour). The
616
+ rotation and its matching horizontal alignment are applied
617
+ **before** the a-priori de-overlap measures the label boxes, so
618
+ the collision sweep sees the real (slanted, anchored) geometry.
619
+
620
+ ``pin_start`` pins the first data point as the leading tick;
621
+ ``None`` (the default) reads ``compact.date_show_start``. Pass
622
+ ``False`` to prefer a **clean equally spaced calendar grid** over
623
+ the (off-grid, wide) start label -- the caller does this when the
624
+ user asks for a non-default density or a rotation, where an
625
+ even cadence reads better than an anchored-but-ragged one.
626
+
627
+ ``auto_rotate`` makes the requested density **actually count**: it
628
+ tries increasing tilts (0, 30, 45, 60, 90 degrees) and keeps the
629
+ smallest one at which the most (ideally all) of the requested date
630
+ ticks survive the de-overlap -- so choosing a denser level packs
631
+ more dates by leaning the labels only as far as needed, instead of
632
+ silently collapsing to the same few. Ignored when ``rotation`` is
633
+ given (an explicit angle wins).
520
634
  """
521
635
  lo: float = float(cast(Any, date2num(data_min)))
522
636
  hi: float = float(cast(Any, date2num(data_max)))
@@ -524,8 +638,13 @@ def compact_date_axis(
524
638
  candidates: list[float] = cast(
525
639
  "list[float]", locator.tick_values(data_min, data_max)
526
640
  )
641
+ show_start: bool = (
642
+ bool(COMPACT_CONFIG.get("date_show_start", True))
643
+ if pin_start is None
644
+ else pin_start
645
+ )
527
646
  interior: list[float] = [t for t in candidates if lo < t <= hi]
528
- if COMPACT_CONFIG.get("date_show_start", True):
647
+ if show_start:
529
648
  # pin the first data point as the leading tick so the reader sees
530
649
  # exactly when the series starts (the natural calendar tick often
531
650
  # falls just *before* the first datum and is clipped by margins=0).
@@ -535,7 +654,6 @@ def compact_date_axis(
535
654
  ticks: list[float] = [lo, *interior]
536
655
  else:
537
656
  ticks = [t for t in candidates if lo <= t <= hi] or [lo, hi]
538
- show_start = bool(COMPACT_CONFIG.get("date_show_start", True))
539
657
 
540
658
  def _set_ticks(values: list[float]) -> None:
541
659
  loc = FixedLocator(values)
@@ -546,18 +664,34 @@ def compact_date_axis(
546
664
  else ConciseDateFormatter(loc)
547
665
  )
548
666
 
549
- _set_ticks(ticks)
550
- ax.tick_params( # type: ignore[reportUnknownMemberType]
551
- axis="x", rotation=0
552
- )
553
-
554
- # A priori de-overlap: render the labels, measure their boxes, and drop
555
- # any tick whose label would collide with the previous kept one (the
556
- # start tick is always kept). Catches a wide start label (e.g. a January
557
- # start widened to a year, or "jul. 2020") crowding the next tick.
558
- fig = ax.get_figure()
559
- if fig is not None and len(ticks) > 1:
667
+ def _style_labels(rot: float) -> None:
668
+ # rotation goes in the axis tick kwargs; ha / anchor rotation-mode are
669
+ # per-label Text properties reapplied after every draw or locator swap.
670
+ ha = _date_label_ha(None if rot == 0 else rot)
671
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
672
+ axis="x", rotation=rot
673
+ )
674
+ for label in ax.get_xticklabels():
675
+ label.set_horizontalalignment(ha)
676
+ if rot != 0:
677
+ label.set_rotation_mode("anchor")
678
+
679
+ def _decimate_at(rot: float) -> list[int]:
680
+ # Render all candidate ticks at this tilt, measure their label boxes,
681
+ # and thin to a UNIFORMLY spaced subset when they collide -- so the
682
+ # survivors stay equally spaced in time (a greedy left-to-right drop
683
+ # leaves ragged gaps). Returns the kept indices into ``ticks``.
684
+ _set_ticks(ticks)
685
+ chosen: list[int] = list(range(len(ticks)))
686
+ fig = ax.get_figure()
687
+ if fig is None or len(ticks) <= 1:
688
+ _style_labels(rot)
689
+ return chosen
560
690
  fig.canvas.draw() # type: ignore[reportUnknownMemberType]
691
+ # set ha AFTER the draw (labels now exist) but BEFORE measuring:
692
+ # get_window_extent recomputes from the current Text properties, so the
693
+ # boxes reflect both the rotation (from the draw) and the anchor/ha.
694
+ _style_labels(rot)
561
695
  renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
562
696
  boxes = [
563
697
  lbl.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
@@ -565,18 +699,88 @@ def compact_date_axis(
565
699
  ]
566
700
  if len(boxes) == len(ticks):
567
701
  pad_px = 2.0
568
- kept: list[float] = [ticks[0]]
569
- last_x1: float = boxes[0].x1
570
- for tick, box in zip(ticks[1:], boxes[1:]):
571
- if box.x0 <= last_x1 + pad_px:
572
- continue # collides with previous kept label -> drop
573
- kept.append(tick)
574
- last_x1 = box.x1
575
- if len(kept) < len(ticks):
576
- _set_ticks(kept)
702
+ n = len(ticks)
703
+
704
+ def _clears(idx: list[int]) -> bool:
705
+ return all(
706
+ boxes[b].x0 > boxes[a].x1 + pad_px
707
+ for a, b in zip(idx, idx[1:])
708
+ )
709
+
710
+ # decimate from the right (anchor the latest date) by a constant
711
+ # stride so the gaps are equal. Force-keep index 0 ONLY when it is
712
+ # the pinned start tick; otherwise the whole grid is uniform and
713
+ # forcing it would re-open a ragged left gap.
714
+ keep0: set[int] = {0} if show_start else set()
715
+ chosen = sorted(keep0 | {0, n - 1})
716
+ for stride in range(1, n):
717
+ idx = sorted(keep0 | set(range(n - 1, -1, -stride)))
718
+ if _clears(idx):
719
+ chosen = idx
720
+ break
721
+ return chosen
722
+
723
+ if auto_rotate and rotation is None:
724
+ # honour the requested density by leaning the labels only as far as
725
+ # needed: pick the smallest tilt keeping the most date ticks (ties ->
726
+ # flatter), stopping as soon as one keeps them all.
727
+ best_rot = 0.0
728
+ best_chosen = _decimate_at(0.0)
729
+ for cand in (30.0, 45.0, 60.0, 90.0):
730
+ if len(best_chosen) >= len(ticks):
731
+ break
732
+ cand_chosen = _decimate_at(cand)
733
+ if len(cand_chosen) > len(best_chosen):
734
+ best_rot, best_chosen = cand, cand_chosen
735
+ rot_final, chosen = best_rot, best_chosen
736
+ else:
737
+ rot_final = 0.0 if rotation is None else float(rotation)
738
+ chosen = _decimate_at(rot_final)
739
+
740
+ # apply the winning tick subset + tilt as the final state
741
+ if len(chosen) < len(ticks):
742
+ _set_ticks([ticks[i] for i in chosen])
743
+ fig = ax.get_figure()
744
+ if fig is not None:
745
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
746
+ _style_labels(rot_final)
747
+
577
748
 
749
+ def apply_date_axis(
750
+ ax: Axes,
751
+ *,
752
+ maxticks: int,
753
+ rotation: float | None = None,
754
+ ) -> None:
755
+ """Native matplotlib date x-axis with a soft tick count and optional slant.
756
+
757
+ The non-compact counterpart to :func:`compact_date_axis`: it takes over the
758
+ x-axis (which must carry a **native** date axis, i.e. the series were
759
+ plotted with ``x_compat=True``) with an ``AutoDateLocator`` capped at
760
+ *maxticks* and a ``ConciseDateFormatter`` bound to the **same** locator
761
+ instance (the concise context logic misfires with a mismatched one).
762
+ ``rotation`` (degrees) tilts the labels; ``None`` keeps them horizontal.
763
+
764
+ Unlike the compact path this changes the label **text style** too (concise
765
+ hierarchical labels instead of pandas' own date formatting), so callers use
766
+ it only when the user explicitly asks for a non-default density or rotation.
767
+ """
768
+ locator = AutoDateLocator(minticks=2, maxticks=maxticks)
769
+ ax.xaxis.set_major_locator(locator)
770
+ ax.xaxis.set_major_formatter(ConciseDateFormatter(locator))
771
+ if rotation is None or rotation == 0:
772
+ return
773
+ rot = float(rotation)
774
+ ha = _date_label_ha(rotation)
775
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
776
+ axis="x", rotation=rot
777
+ )
778
+ fig = ax.get_figure()
779
+ if fig is not None:
780
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
578
781
  for label in ax.get_xticklabels():
579
- label.set_horizontalalignment("center")
782
+ label.set_horizontalalignment(ha)
783
+ label.set_rotation_mode("anchor")
580
784
 
581
785
 
582
786
  def compact_y_axis(ax: Axes) -> None: