tesorotools-python 0.0.52__tar.gz → 0.0.54__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 (83) hide show
  1. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/.gitignore +3 -1
  2. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/PKG-INFO +4 -2
  3. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/pyproject.toml +1 -0
  4. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/__init__.py +17 -3
  5. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/_common.py +242 -66
  6. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/bar_line.py +4 -2
  7. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/barh_plot.py +7 -5
  8. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/box_plot.py +15 -5
  9. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/compact.py +18 -2
  10. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/intraday_plot.py +24 -3
  11. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/line_plot.py +64 -21
  12. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/plotly_backend.py +26 -1
  13. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/shock_plot.py +4 -1
  14. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/stacked.py +44 -8
  15. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/type_curve.py +12 -4
  16. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/plots.yaml +16 -1
  17. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/providers/__init__.py +11 -5
  18. tesorotools_python-0.0.54/src/tesorotools/providers/bde.py +479 -0
  19. tesorotools_python-0.0.54/src/tesorotools/providers/fred.py +375 -0
  20. tesorotools_python-0.0.52/src/tesorotools/providers/bde.py +0 -269
  21. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/_build_context.py +0 -0
  22. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/_registry.py +0 -0
  23. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/__init__.py +0 -0
  24. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/matrix.py +0 -0
  25. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/vector_plot.py +0 -0
  26. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/artists/waterfall.py +0 -0
  27. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/README.md +0 -0
  28. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  29. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  30. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  31. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  32. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  33. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  34. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  35. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  36. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/fonts/README.md +0 -0
  37. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/template.docx +0 -0
  38. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  39. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/data_sources/__init__.py +0 -0
  40. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/data_sources/debug.py +0 -0
  41. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/database/__init__.py +0 -0
  42. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/database/local.py +0 -0
  43. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/database/push.py +0 -0
  44. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/database/shared.py +0 -0
  45. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/dependencies/__init__.py +0 -0
  46. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/dependencies/node.py +0 -0
  47. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/dependencies/resolution.py +0 -0
  48. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/driver.py +0 -0
  49. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/manifest.py +0 -0
  50. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/offsets/__init__.py +0 -0
  51. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/offsets/offsets.py +0 -0
  52. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/offsets/outliers.py +0 -0
  53. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/orchestration.py +0 -0
  54. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/pipeline/__init__.py +0 -0
  55. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/pipeline/diagnose.py +0 -0
  56. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/pipeline/engine.py +0 -0
  57. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/pipeline/rules.py +0 -0
  58. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/providers/base.py +0 -0
  59. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/providers/ecb.py +0 -0
  60. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/providers/imf_irfcl.py +0 -0
  61. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/providers/lseg.py +0 -0
  62. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/py.typed +0 -0
  63. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/__init__.py +0 -0
  64. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/content/__init__.py +0 -0
  65. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/content/content.py +0 -0
  66. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/content/images.py +0 -0
  67. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/content/section.py +0 -0
  68. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/content/subtitle.py +0 -0
  69. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/content/table.py +0 -0
  70. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/content/text.py +0 -0
  71. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/content/title.py +0 -0
  72. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/document.py +0 -0
  73. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/render/report.py +0 -0
  74. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/testing/__init__.py +0 -0
  75. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/testing/compare.py +0 -0
  76. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/utils/__init__.py +0 -0
  77. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/utils/config.py +0 -0
  78. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/utils/format.py +0 -0
  79. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/utils/globals.py +0 -0
  80. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/utils/matplotlib.py +0 -0
  81. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/utils/series.py +0 -0
  82. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/utils/shortcuts.py +0 -0
  83. {tesorotools_python-0.0.52 → tesorotools_python-0.0.54}/src/tesorotools/utils/template.py +0 -0
@@ -36,4 +36,6 @@ test/
36
36
  .uv-cache/
37
37
 
38
38
  demo_compacto/
39
- demo/
39
+ demo/
40
+
41
+ *.env
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: tesorotools-python
3
- Version: 0.0.52
3
+ Version: 0.0.54
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -16,6 +16,8 @@ Provides-Extra: bde
16
16
  Requires-Dist: requests>=2.31; extra == 'bde'
17
17
  Provides-Extra: ecb
18
18
  Requires-Dist: requests>=2.31; extra == 'ecb'
19
+ Provides-Extra: fred
20
+ Requires-Dist: requests>=2.31; extra == 'fred'
19
21
  Provides-Extra: imf
20
22
  Requires-Dist: requests>=2.31; extra == 'imf'
21
23
  Requires-Dist: truststore>=0.10; extra == 'imf'
@@ -27,6 +27,7 @@ dependencies = [
27
27
  [project.optional-dependencies]
28
28
  bde = ["requests>=2.31"]
29
29
  ecb = ["requests>=2.31"]
30
+ fred = ["requests>=2.31"]
30
31
  imf = ["requests>=2.31", "truststore>=0.10"]
31
32
  lseg = ["lseg-data>=2.1"]
32
33
  interactive_plots = ["plotly>=5.0"]
@@ -7,9 +7,11 @@ effects) and registers their YAML tags via
7
7
 
8
8
  Provider subclasses gated by optional extras
9
9
  (``BdeProvider`` requires ``[bde]``, ``EcbProvider``
10
- requires ``[ecb]``, ``LSEGProvider`` requires ``[lseg]``)
11
- are exposed lazily through ``__getattr__``; importing this
12
- module does not require the extras to be installed.
10
+ requires ``[ecb]``, ``FredProvider`` requires ``[fred]``,
11
+ ``ImfIrfclProvider`` requires ``[imf]``, ``LSEGProvider``
12
+ requires ``[lseg]``) are exposed lazily through
13
+ ``__getattr__``; importing this module does not require the
14
+ extras to be installed.
13
15
 
14
16
  Third parties extend the package via ``register_artist``,
15
17
  ``register_tag``, and ``register_provider`` (and their
@@ -31,6 +33,8 @@ except PackageNotFoundError: # pragma: no cover - source-only checkout
31
33
  if TYPE_CHECKING:
32
34
  from tesorotools.providers.bde import BdeProvider
33
35
  from tesorotools.providers.ecb import EcbProvider
36
+ from tesorotools.providers.fred import FredProvider
37
+ from tesorotools.providers.imf_irfcl import ImfIrfclProvider
34
38
  from tesorotools.providers.lseg import LSEGProvider
35
39
 
36
40
  from tesorotools._build_context import BuildContext
@@ -131,10 +135,12 @@ __all__ = [
131
135
  "DataProvider",
132
136
  "EcbProvider",
133
137
  "Format",
138
+ "FredProvider",
134
139
  "GroupedBarChart",
135
140
  "HorizontalBarChart",
136
141
  "Image",
137
142
  "Images",
143
+ "ImfIrfclProvider",
138
144
  "IntradayPlot",
139
145
  "Legend",
140
146
  "LinePlot",
@@ -183,6 +189,14 @@ def __getattr__(name: str) -> Any:
183
189
  from tesorotools.providers.ecb import EcbProvider
184
190
 
185
191
  return EcbProvider
192
+ if name == "FredProvider":
193
+ from tesorotools.providers.fred import FredProvider
194
+
195
+ return FredProvider
196
+ if name == "ImfIrfclProvider":
197
+ from tesorotools.providers.imf_irfcl import ImfIrfclProvider
198
+
199
+ return ImfIrfclProvider
186
200
  if name == "LSEGProvider":
187
201
  from tesorotools.providers.lseg import LSEGProvider
188
202
 
@@ -68,6 +68,11 @@ CM_TO_IN: float = 1.0 / 2.54
68
68
  # is missing a level; "default" reproduces the historical tick count of each mode
69
69
  # (4 for compact, 6 for non-compact).
70
70
  DATE_DENSITY_LEVELS: tuple[str, ...] = ("few", "default", "many")
71
+
72
+ # Legend bands a chart may ask for. ``auto`` defers to the mode default:
73
+ # ``compact.legend_position`` in ``plots.yaml`` for a compact chart (shipped
74
+ # as "above"), ``below`` for a full-size one.
75
+ LEGEND_POSITIONS: tuple[str, ...] = ("auto", "above", "below")
71
76
  _DATE_MAXTICKS_FALLBACK_COMPACT: dict[str, int] = {
72
77
  "few": 3,
73
78
  "default": 4,
@@ -229,28 +234,45 @@ class Format:
229
234
  class Legend:
230
235
  """Legend layout knobs carried in YAML.
231
236
 
232
- Currently exposes ``ncol``; absent (None) means "let the
233
- artist auto-fit using ``auto_ncol``".
237
+ ``ncol`` absent (None) means "let the artist auto-fit
238
+ using ``auto_ncol``". ``position`` picks the band the
239
+ legend lives in: ``"above"`` the plot or ``"below"`` it;
240
+ ``"auto"`` (the default) keeps each mode's house default,
241
+ which for compact charts is read from
242
+ ``compact.legend_position`` in ``plots.yaml``. Both
243
+ values work in compact and in full-size charts, so a
244
+ compact chart in a Word table can carry its labels under
245
+ the plot when the text above it already reads as a title.
234
246
  """
235
247
 
236
- def __init__(self, ncol: int | None = None) -> None:
248
+ def __init__(self, ncol: int | None = None, position: str = "auto") -> None:
249
+ if position not in LEGEND_POSITIONS:
250
+ raise ValueError(
251
+ f"legend position must be one of {LEGEND_POSITIONS}: "
252
+ f"{position!r}"
253
+ )
237
254
  self.ncol = ncol
255
+ self.position = position
238
256
 
239
257
  @classmethod
240
258
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
241
259
  """Build a :class:`Legend` from a ``!legend`` YAML tag.
242
260
 
243
- ``ncol`` is optional; omitting it (or passing
244
- ``null``) lets the artist auto-fit using
245
- :func:`auto_ncol`. An empty ``!legend {}`` enables
246
- the legend with auto-fitted columns.
261
+ Both keys are optional. Omitting ``ncol`` (or
262
+ passing ``null``) lets the artist auto-fit using
263
+ :func:`auto_ncol`; omitting ``position`` keeps the
264
+ mode default (``compact.legend_position`` for a
265
+ compact chart, below the plot for a full-size one).
266
+ An empty ``!legend {}`` enables the legend with
267
+ auto-fitted columns in its default band.
247
268
 
248
269
  Example
249
270
  -------
250
271
  .. code-block:: yaml
251
272
 
252
- legend: !legend {ncol: 3} # explicit
253
- legend: !legend {} # auto-fit
273
+ legend: !legend {ncol: 3} # explicit
274
+ legend: !legend {position: below} # under the plot
275
+ legend: !legend {} # auto-fit
254
276
  """
255
277
  legend_cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
256
278
  node, deep=True
@@ -526,6 +548,65 @@ def compact_legend_max_ncol() -> int | None:
526
548
  return None if raw is None else int(raw)
527
549
 
528
550
 
551
+ def date_show_start_default(*, compact: bool) -> bool:
552
+ """House default for pinning the first datum as the leading x tick.
553
+
554
+ Reads ``compact.date_show_start`` / ``line.date_show_start`` from
555
+ ``plots.yaml`` (both shipped ``true``), so "does the chart say when the
556
+ series starts?" stays a data decision. A per-chart
557
+ ``date_show_start: false`` overrides it.
558
+ """
559
+ cfg = COMPACT_CONFIG if compact else LINE_CONFIG
560
+ return bool(cfg.get("date_show_start", True))
561
+
562
+
563
+ def date_span_ticks(
564
+ data_min: Any,
565
+ data_max: Any,
566
+ *,
567
+ maxticks: int,
568
+ pin_start: bool,
569
+ ) -> list[float]:
570
+ """Date ticks (as date numbers) computed over the **data span**.
571
+
572
+ ``AutoDateLocator`` candidates clipped to ``[data_min, data_max]``, so no
573
+ tick floats outside the plotted data: off-span ticks are what leave the
574
+ blank labels at both ends of pandas' own date axis, and in compact (where
575
+ ``margins(x=0)``) they get clipped by the figure edge.
576
+
577
+ With ``pin_start`` the first datum becomes the leading tick -- the only
578
+ way the reader can tell *when the series starts*, since the natural
579
+ calendar tick usually falls before it -- and any candidate closer than
580
+ 10 % of the span is dropped so the two labels do not collide. Without
581
+ it the plain in-span candidates are returned (falling back to the two
582
+ endpoints when the locator offers nothing inside).
583
+ """
584
+ lo: float = float(cast(Any, date2num(data_min)))
585
+ hi: float = float(cast(Any, date2num(data_max)))
586
+ locator = AutoDateLocator(minticks=2, maxticks=maxticks)
587
+ candidates: list[float] = cast(
588
+ "list[float]", locator.tick_values(data_min, data_max)
589
+ )
590
+ if not pin_start:
591
+ return [t for t in candidates if lo <= t <= hi] or [lo, hi]
592
+ min_gap: float = (hi - lo) * 0.10
593
+ interior: list[float] = [
594
+ t for t in candidates if lo < t <= hi and t - lo >= min_gap
595
+ ]
596
+ return [lo, *interior]
597
+
598
+
599
+ def compact_legend_position() -> str:
600
+ """Default legend band for compact charts (``compact.legend_position``).
601
+
602
+ ``"above"`` unless ``plots.yaml`` says otherwise: on a 7.4 cm figure the
603
+ bottom edge already carries the date ticks, so the top band is the
604
+ cheaper place to spend vertical space. A per-chart
605
+ ``legend: !legend {position: below}`` always wins over this default.
606
+ """
607
+ return str(COMPACT_CONFIG.get("legend_position", "above"))
608
+
609
+
529
610
  def compact_headroom_pad_pt() -> float:
530
611
  """Points of clearance to leave above the top value label (0 if unset)."""
531
612
  return float(COMPACT_CONFIG.get("annotate_headroom_pad_pt", 0))
@@ -633,27 +714,16 @@ def compact_date_axis(
633
714
  given (an explicit angle wins).
634
715
  """
635
716
  lo: float = float(cast(Any, date2num(data_min)))
636
- hi: float = float(cast(Any, date2num(data_max)))
637
- locator = AutoDateLocator(minticks=2, maxticks=maxticks)
638
- candidates: list[float] = cast(
639
- "list[float]", locator.tick_values(data_min, data_max)
640
- )
641
717
  show_start: bool = (
642
- bool(COMPACT_CONFIG.get("date_show_start", True))
718
+ date_show_start_default(compact=True)
643
719
  if pin_start is None
644
720
  else pin_start
645
721
  )
646
- interior: list[float] = [t for t in candidates if lo < t <= hi]
647
- if show_start:
648
- # pin the first data point as the leading tick so the reader sees
649
- # exactly when the series starts (the natural calendar tick often
650
- # falls just *before* the first datum and is clipped by margins=0).
651
- # Drop any auto tick that would crowd the start label.
652
- min_gap: float = (hi - lo) * 0.10
653
- interior = [t for t in interior if t - lo >= min_gap]
654
- ticks: list[float] = [lo, *interior]
655
- else:
656
- ticks = [t for t in candidates if lo <= t <= hi] or [lo, hi]
722
+ # same span-clipped ticks (and same pinned start) the non-compact axis
723
+ # uses, so both modes answer "when does this start?" identically
724
+ ticks: list[float] = date_span_ticks(
725
+ data_min, data_max, maxticks=maxticks, pin_start=show_start
726
+ )
657
727
 
658
728
  def _set_ticks(values: list[float]) -> None:
659
729
  loc = FixedLocator(values)
@@ -751,6 +821,9 @@ def apply_date_axis(
751
821
  *,
752
822
  maxticks: int,
753
823
  rotation: float | None = None,
824
+ data_min: Any = None,
825
+ data_max: Any = None,
826
+ pin_start: bool = False,
754
827
  ) -> None:
755
828
  """Native matplotlib date x-axis with a soft tick count and optional slant.
756
829
 
@@ -761,17 +834,35 @@ def apply_date_axis(
761
834
  instance (the concise context logic misfires with a mismatched one).
762
835
  ``rotation`` (degrees) tilts the labels; ``None`` keeps them horizontal.
763
836
 
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.
837
+ ``pin_start`` (with the ``data_min`` / ``data_max`` span) switches to the
838
+ span-clipped :func:`date_span_ticks` with the first datum pinned as the
839
+ leading tick and :class:`_StartLabeledConcise` to spell that date out --
840
+ the same treatment the compact axis gives it. Pandas' own date axis
841
+ cannot do this: its formatter only labels the positions its own locator
842
+ produced, so a pinned tick comes out blank; that is why pinning the start
843
+ means taking the axis over here.
844
+
845
+ Taking over changes the label **text style** too (concise hierarchical
846
+ labels instead of pandas' own date formatting).
847
+
848
+ The tilt is always set explicitly, never inherited: pandas leaves the
849
+ x labels rotated 30 degrees on the ``x_compat`` path this axis needs, so
850
+ ``rotation=None`` has to *actively* flatten them to keep the documented
851
+ "horizontal unless asked" behaviour.
767
852
  """
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)
853
+ if pin_start and data_min is not None and data_max is not None:
854
+ ticks = date_span_ticks(
855
+ data_min, data_max, maxticks=maxticks, pin_start=True
856
+ )
857
+ fixed = FixedLocator(ticks)
858
+ ax.xaxis.set_major_locator(fixed)
859
+ ax.xaxis.set_major_formatter(_StartLabeledConcise(fixed, ticks[0]))
860
+ else:
861
+ locator = AutoDateLocator(minticks=2, maxticks=maxticks)
862
+ ax.xaxis.set_major_locator(locator)
863
+ ax.xaxis.set_major_formatter(ConciseDateFormatter(locator))
864
+ rot = 0.0 if rotation is None else float(rotation)
865
+ ha = _date_label_ha(None if rot == 0 else rot)
775
866
  ax.tick_params( # type: ignore[reportUnknownMemberType]
776
867
  axis="x", rotation=rot
777
868
  )
@@ -780,7 +871,8 @@ def apply_date_axis(
780
871
  fig.canvas.draw() # type: ignore[reportUnknownMemberType]
781
872
  for label in ax.get_xticklabels():
782
873
  label.set_horizontalalignment(ha)
783
- label.set_rotation_mode("anchor")
874
+ if rot != 0:
875
+ label.set_rotation_mode("anchor")
784
876
 
785
877
 
786
878
  def compact_y_axis(ax: Axes) -> None:
@@ -802,42 +894,100 @@ def compact_y_axis(ax: Axes) -> None:
802
894
  )
803
895
 
804
896
 
805
- def legend_above(
897
+ def resolve_legend_position(position: str, *, compact: bool) -> str:
898
+ """Resolve a :class:`Legend` ``position`` to the band to draw in.
899
+
900
+ ``"above"`` / ``"below"`` are honoured as given, in **both** modes.
901
+ ``"auto"`` keeps the house default of each mode: the compact band read
902
+ from ``compact.legend_position`` (see :func:`compact_legend_position`)
903
+ and ``"below"`` for a full-size chart, which is where every non-compact
904
+ chart has always put it. Returns ``"above"`` or ``"below"``.
905
+ """
906
+ if position != "auto":
907
+ return position
908
+ return compact_legend_position() if compact else "below"
909
+
910
+
911
+ def legend_band(
806
912
  ax: Axes,
807
913
  handles: list[Any],
808
914
  labels: list[str],
809
915
  *,
810
916
  ncol: int,
917
+ position: str = "above",
811
918
  fontsize: float | None = None,
812
919
  handlelength: float | None = None,
813
920
  handletextpad: float | None = None,
814
921
  columnspacing: float | None = None,
922
+ frameon: bool | None = None,
815
923
  ) -> MplLegend:
816
- """Place the legend in a band **above the axes, spanning the figure**.
817
-
818
- Uses ``loc="outside upper center"`` so constrained layout reserves a
819
- strip at the top of the *figure* and the legend may use the whole
820
- figure width -- wider than the narrow compact axes, whose left edge
821
- is eaten by the y-tick numbers and whose right edge is eaten by the
822
- end-of-series value labels. Spanning the figure lets the maximum
823
- number of columns share one row, which **minimises the vertical
824
- space the legend steals from the plot** (fewer rows = taller chart).
825
-
826
- ``fontsize`` / ``handlelength`` / ``handletextpad`` /
827
- ``columnspacing`` style the legend (``None`` keeps the
828
- ``rcParams`` default); the compact mode passes a smaller
829
- font and shorter handles so more labels fit per row.
924
+ """Place the legend in a band **outside the axes, spanning the figure**.
925
+
926
+ ``position`` picks the edge: ``"above"`` (``loc="outside upper
927
+ center"``) or ``"below"`` (``loc="outside lower center"``). Either way
928
+ constrained layout reserves a full-width strip on that side of the
929
+ *figure*, so the legend may use the whole figure width -- wider than the
930
+ narrow compact axes, whose left edge is eaten by the y-tick numbers and
931
+ whose right edge is eaten by the end-of-series value labels. Spanning
932
+ the figure lets the maximum number of columns share one row, which
933
+ **minimises the vertical space the legend steals from the plot** (fewer
934
+ rows = taller chart), whichever side it sits on.
935
+
936
+ Above is the compact default (the bottom edge already spends space on
937
+ the date ticks); below is the classic report look and the default of the
938
+ full-size charts. ``fontsize`` / ``handlelength`` / ``handletextpad`` /
939
+ ``columnspacing`` / ``frameon`` style the legend (``None`` keeps the
940
+ ``rcParams`` default); the compact mode passes a smaller font, shorter
941
+ handles and no box so more labels fit per row.
830
942
  """
831
943
  fig = ax.get_figure()
944
+ loc = (
945
+ "outside upper center"
946
+ if position == "above"
947
+ else "outside lower center"
948
+ )
832
949
  return fig.legend( # type: ignore[reportUnknownMemberType, reportOptionalMemberAccess]
833
950
  handles,
834
951
  labels,
835
- loc="outside upper center",
952
+ loc=loc,
836
953
  ncol=ncol,
837
954
  fontsize=fontsize,
838
955
  handlelength=handlelength,
839
956
  handletextpad=handletextpad,
840
957
  columnspacing=columnspacing,
958
+ frameon=frameon,
959
+ )
960
+
961
+
962
+ def legend_above(
963
+ ax: Axes,
964
+ handles: list[Any],
965
+ labels: list[str],
966
+ *,
967
+ ncol: int,
968
+ fontsize: float | None = None,
969
+ handlelength: float | None = None,
970
+ handletextpad: float | None = None,
971
+ columnspacing: float | None = None,
972
+ ) -> MplLegend:
973
+ """Frameless legend band above the axes.
974
+
975
+ Thin shortcut for ``legend_band(..., position="above",
976
+ frameon=False)``, kept because it is the call the compact toolkit has
977
+ always exported for third-party artists. New code should call
978
+ :func:`legend_band` (or better :func:`place_legend`) so the band side
979
+ stays configurable.
980
+ """
981
+ return legend_band(
982
+ ax,
983
+ handles,
984
+ labels,
985
+ ncol=ncol,
986
+ position="above",
987
+ fontsize=fontsize,
988
+ handlelength=handlelength,
989
+ handletextpad=handletextpad,
990
+ columnspacing=columnspacing,
841
991
  frameon=False, # no box: less clutter in the tight compact figure
842
992
  )
843
993
 
@@ -918,19 +1068,23 @@ def place_legend(
918
1068
  *,
919
1069
  compact: bool,
920
1070
  ncol: int | None = None,
1071
+ position: str = "auto",
921
1072
  ) -> MplLegend | None:
922
1073
  """Place the figure legend, compact-aware, from the labelled artists.
923
1074
 
924
- Compact charts get the band **above** the axes spanning the figure
925
- width (:func:`legend_above`) with the compact legend metrics; ``ncol``
926
- is auto-fitted to the full figure width and hard-capped by
927
- :func:`compact_legend_max_ncol` unless the caller passes an explicit
928
- ``ncol``. Non-compact charts get the house ``outside lower center``
929
- band. Returns ``None`` (drawing nothing) when there are no labels.
1075
+ Compact charts get a frameless band spanning the figure width with the
1076
+ compact legend metrics; ``ncol`` is auto-fitted to that width and
1077
+ hard-capped by :func:`compact_legend_max_ncol` unless the caller passes
1078
+ an explicit ``ncol``. Non-compact charts get the same band with the
1079
+ ``rcParams`` metrics. ``position`` (``"above"`` / ``"below"`` /
1080
+ ``"auto"``) chooses the side through :func:`resolve_legend_position`, so
1081
+ a compact chart can carry its legend under the plot instead of over it.
1082
+ Returns ``None`` (drawing nothing) when there are no labels.
930
1083
  """
931
1084
  handles, labels = ax.get_legend_handles_labels()
932
1085
  if not labels:
933
1086
  return None
1087
+ side = resolve_legend_position(position, compact=compact)
934
1088
  fig_w_px: float = fig.get_size_inches()[0] * fig.dpi
935
1089
  if compact:
936
1090
  legend_kw = compact_legend_kwargs()
@@ -941,15 +1095,37 @@ def place_legend(
941
1095
  max_ncol = compact_legend_max_ncol()
942
1096
  if max_ncol is not None:
943
1097
  ncol = min(ncol, max_ncol)
944
- return legend_above(ax, handles, labels, ncol=ncol, **legend_kw)
1098
+ return legend_band(
1099
+ ax,
1100
+ handles,
1101
+ labels,
1102
+ ncol=ncol,
1103
+ position=side,
1104
+ frameon=False,
1105
+ **legend_kw,
1106
+ )
945
1107
  if ncol is None:
946
1108
  ncol = auto_ncol(ax, labels, available_width_px=fig_w_px)
947
- return fig.legend( # type: ignore[reportUnknownMemberType]
948
- handles,
949
- labels,
950
- loc="outside lower center",
951
- ncol=ncol,
952
- )
1109
+ return legend_band(ax, handles, labels, ncol=ncol, position=side)
1110
+
1111
+
1112
+ def legend_layout(legend: Legend | None) -> dict[str, Any]:
1113
+ """``place_legend`` layout kwargs from an optional :class:`Legend` holder.
1114
+
1115
+ Every artist carries a ``legend: Legend | None`` (absent means "house
1116
+ defaults"), so this maps the holder to the ``ncol`` / ``position`` pair
1117
+ in one place instead of each artist repeating a
1118
+ ``self.legend.x if self.legend else <default>`` per knob -- and a new
1119
+ knob then reaches every artist at once::
1120
+
1121
+ place_legend(
1122
+ fig, ax, compact=self.compact, **legend_layout(self.legend)
1123
+ )
1124
+ """
1125
+ return {
1126
+ "ncol": legend.ncol if legend is not None else None,
1127
+ "position": legend.position if legend is not None else "auto",
1128
+ }
953
1129
 
954
1130
 
955
1131
  def declutter_spines(ax: Axes, side: str = "left") -> None:
@@ -45,6 +45,7 @@ from tesorotools.artists._common import (
45
45
  compact_figure_kwargs,
46
46
  compact_font_pt,
47
47
  dynamic_dpi,
48
+ legend_layout,
48
49
  place_legend,
49
50
  resolve_data,
50
51
  style_baseline,
@@ -323,9 +324,10 @@ class BarLinePlot:
323
324
  if self.compact:
324
325
  style_compact_axes(ax, font_pt, side="right")
325
326
 
326
- legend_ncol = self.legend.ncol if self.legend else None
327
327
  if self.legend is not None:
328
- place_legend(fig, ax, compact=self.compact, ncol=legend_ncol)
328
+ place_legend(
329
+ fig, ax, compact=self.compact, **legend_layout(self.legend)
330
+ )
329
331
 
330
332
  if self.plot_size is not None and not self.compact:
331
333
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
@@ -40,6 +40,7 @@ from tesorotools.artists._common import (
40
40
  compact_figure_kwargs,
41
41
  compact_font_pt,
42
42
  dynamic_dpi,
43
+ legend_layout,
43
44
  place_legend,
44
45
  resolve_data,
45
46
  )
@@ -928,11 +929,12 @@ class GroupedBarChart:
928
929
  axis="both", labelsize=font_pt
929
930
  )
930
931
 
931
- # compact gets the legend in a band above the axes (spanning the
932
- # figure width, columns auto-fitted and capped); non-compact gets
933
- # the house lower-centre band.
934
- legend_ncol = self.legend.ncol if self.legend else None
935
- place_legend(fig, ax, compact=self.compact, ncol=legend_ncol)
932
+ # the legend lives in a band spanning the figure width: above the
933
+ # axes in compact (columns auto-fitted and capped), below it at full
934
+ # size. !legend {position} overrides the side.
935
+ place_legend(
936
+ fig, ax, compact=self.compact, **legend_layout(self.legend)
937
+ )
936
938
 
937
939
  # compact fixes the figure size, so the axes-sizing pass would fight
938
940
  # it; skip it there (mirrors LinePlot).
@@ -53,8 +53,9 @@ from tesorotools.artists._common import (
53
53
  compact_font_pt,
54
54
  compact_legend_kwargs,
55
55
  dynamic_dpi,
56
- legend_above,
56
+ legend_band,
57
57
  resolve_data,
58
+ resolve_legend_position,
58
59
  style_compact_axes,
59
60
  style_spines,
60
61
  )
@@ -478,19 +479,28 @@ class BoxPlot:
478
479
  if not draw_last:
479
480
  handles, labels = handles[:-1], labels[:-1]
480
481
  fig_w_px: float = fig.get_size_inches()[0] * fig.dpi
482
+ position = resolve_legend_position(
483
+ self.legend.position, compact=self.compact
484
+ )
481
485
  if self.compact:
482
486
  kw = compact_legend_kwargs()
483
487
  ncol = self.legend.ncol or auto_ncol(
484
488
  ax, labels, available_width_px=fig_w_px, **kw
485
489
  )
486
- legend_above(ax, handles, labels, ncol=ncol, **kw)
490
+ legend_band(
491
+ ax,
492
+ handles,
493
+ labels,
494
+ ncol=ncol,
495
+ position=position,
496
+ frameon=False,
497
+ **kw,
498
+ )
487
499
  else:
488
500
  ncol = self.legend.ncol or auto_ncol(
489
501
  ax, labels, available_width_px=fig_w_px
490
502
  )
491
- fig.legend( # type: ignore[reportUnknownMemberType]
492
- handles, labels, loc="outside lower center", ncol=ncol
493
- )
503
+ legend_band(ax, handles, labels, ncol=ncol, position=position)
494
504
 
495
505
  def save(
496
506
  self,
@@ -18,11 +18,19 @@ into :mod:`tesorotools.artists._common`. The recipe is:
18
18
  point size (equal to what the reader sees in Word).
19
19
  3. Draw the chart, then ``style_compact_axes(ax, font_pt, side=...)`` to
20
20
  pin the tick label size and the y-axis side / spines.
21
- 4. ``place_legend(fig, ax, compact=compact)`` -- the above-the-axes band
22
- with auto-fitted, capped columns.
21
+ 4. ``place_legend(fig, ax, compact=compact, **legend_layout(legend))``
22
+ -- the full-figure-width legend band with auto-fitted, capped columns,
23
+ above the axes by default and below them when the chart's
24
+ ``!legend {position: below}`` (or ``compact.legend_position``) asks for
25
+ it.
23
26
  5. For a date x-axis, ``compact_date_axis(ax, lo, hi)`` thins the ticks to
24
27
  a few concise labels.
25
28
 
29
+ Legend side: the band sits above the axes by default (the bottom edge
30
+ already spends space on the date ticks). ``compact.legend_position`` in
31
+ ``plots.yaml`` flips that default for every compact chart and
32
+ ``legend: !legend {position: above|below}`` decides it per chart.
33
+
26
34
  Legends note: a compact figure is only ~7.4 cm wide, so a few long labels
27
35
  (e.g. ``"Pre-conflicto 2026-02-27"``) collapse the column fit to a single
28
36
  tall column that eats the chart. Shorten labels for compact charts and
@@ -41,10 +49,14 @@ from tesorotools.artists._common import (
41
49
  compact_legend_font_pt,
42
50
  compact_legend_kwargs,
43
51
  compact_legend_max_ncol,
52
+ compact_legend_position,
44
53
  compact_y_axis,
45
54
  declutter_spines,
46
55
  legend_above,
56
+ legend_band,
57
+ legend_layout,
47
58
  place_legend,
59
+ resolve_legend_position,
48
60
  style_compact_axes,
49
61
  )
50
62
 
@@ -58,9 +70,13 @@ __all__ = [
58
70
  "compact_legend_font_pt",
59
71
  "compact_legend_kwargs",
60
72
  "compact_legend_max_ncol",
73
+ "compact_legend_position",
61
74
  "compact_y_axis",
62
75
  "declutter_spines",
63
76
  "legend_above",
77
+ "legend_band",
78
+ "legend_layout",
64
79
  "place_legend",
80
+ "resolve_legend_position",
65
81
  "style_compact_axes",
66
82
  ]