tesorotools-python 0.0.49__tar.gz → 0.0.51__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.49 → tesorotools_python-0.0.51}/PKG-INFO +3 -1
  2. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/pyproject.toml +4 -0
  3. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/__init__.py +22 -0
  4. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/__init__.py +8 -0
  5. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/_common.py +1 -3
  6. tesorotools_python-0.0.51/src/tesorotools/artists/bar_line.py +362 -0
  7. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/barh_plot.py +20 -0
  8. tesorotools_python-0.0.51/src/tesorotools/artists/box_plot.py +517 -0
  9. tesorotools_python-0.0.51/src/tesorotools/artists/intraday_plot.py +309 -0
  10. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/line_plot.py +71 -13
  11. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/matrix.py +10 -0
  12. tesorotools_python-0.0.51/src/tesorotools/artists/plotly_backend.py +1029 -0
  13. tesorotools_python-0.0.51/src/tesorotools/artists/shock_plot.py +355 -0
  14. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/stacked.py +26 -3
  15. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/type_curve.py +23 -2
  16. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/vector_plot.py +10 -0
  17. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/waterfall.py +10 -0
  18. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/plots.yaml +35 -0
  19. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/content/images.py +18 -9
  20. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/.gitignore +0 -0
  21. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/_build_context.py +0 -0
  22. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/_registry.py +0 -0
  23. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/artists/compact.py +0 -0
  24. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/README.md +0 -0
  25. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  26. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  27. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  28. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  29. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  30. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  31. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  32. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  33. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/fonts/README.md +0 -0
  34. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/template.docx +0 -0
  35. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  36. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/data_sources/__init__.py +0 -0
  37. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/data_sources/debug.py +0 -0
  38. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/database/__init__.py +0 -0
  39. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/database/local.py +0 -0
  40. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/database/push.py +0 -0
  41. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/database/shared.py +0 -0
  42. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/dependencies/__init__.py +0 -0
  43. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/dependencies/node.py +0 -0
  44. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/dependencies/resolution.py +0 -0
  45. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/driver.py +0 -0
  46. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/manifest.py +0 -0
  47. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/offsets/__init__.py +0 -0
  48. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/offsets/offsets.py +0 -0
  49. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/offsets/outliers.py +0 -0
  50. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/orchestration.py +0 -0
  51. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/pipeline/__init__.py +0 -0
  52. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/pipeline/diagnose.py +0 -0
  53. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/pipeline/engine.py +0 -0
  54. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/pipeline/rules.py +0 -0
  55. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/providers/__init__.py +0 -0
  56. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/providers/base.py +0 -0
  57. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/providers/bde.py +0 -0
  58. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/providers/ecb.py +0 -0
  59. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/providers/imf_irfcl.py +0 -0
  60. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/providers/lseg.py +0 -0
  61. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/py.typed +0 -0
  62. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/__init__.py +0 -0
  63. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/content/__init__.py +0 -0
  64. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/content/content.py +0 -0
  65. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/content/section.py +0 -0
  66. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/content/subtitle.py +0 -0
  67. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/content/table.py +0 -0
  68. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/content/text.py +0 -0
  69. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/content/title.py +0 -0
  70. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/document.py +0 -0
  71. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/render/report.py +0 -0
  72. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/testing/__init__.py +0 -0
  73. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/testing/compare.py +0 -0
  74. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/utils/__init__.py +0 -0
  75. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/utils/config.py +0 -0
  76. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/utils/format.py +0 -0
  77. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/utils/globals.py +0 -0
  78. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/utils/matplotlib.py +0 -0
  79. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/utils/series.py +0 -0
  80. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/utils/shortcuts.py +0 -0
  81. {tesorotools_python-0.0.49 → tesorotools_python-0.0.51}/src/tesorotools/utils/template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.49
3
+ Version: 0.0.51
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]
@@ -17,8 +17,17 @@ plural variants). See ``docs/extending.md`` for the
17
17
  recommended idioms.
18
18
  """
19
19
 
20
+ from importlib.metadata import PackageNotFoundError, version
20
21
  from typing import TYPE_CHECKING, Any
21
22
 
23
+ try:
24
+ #: Installed distribution version (set by hatch-vcs from the git tag).
25
+ #: Falls back to ``"0.0.0"`` when the package is not installed (e.g.
26
+ #: running straight from a source checkout with no metadata).
27
+ __version__ = version("tesorotools-python")
28
+ except PackageNotFoundError: # pragma: no cover - source-only checkout
29
+ __version__ = "0.0.0"
30
+
22
31
  if TYPE_CHECKING:
23
32
  from tesorotools.providers.bde import BdeProvider
24
33
  from tesorotools.providers.ecb import EcbProvider
@@ -44,12 +53,16 @@ from tesorotools._registry import (
44
53
  register_tags,
45
54
  )
46
55
  from tesorotools.artists import (
56
+ BarLinePlot,
57
+ BoxPlot,
47
58
  Format,
48
59
  GroupedBarChart,
49
60
  HorizontalBarChart,
61
+ IntradayPlot,
50
62
  Legend,
51
63
  LinePlot,
52
64
  MatrixChart,
65
+ ShockChart,
53
66
  StackedAreaPlot,
54
67
  StackedBarPlot,
55
68
  TypeCurve,
@@ -78,6 +91,8 @@ from tesorotools.render import (
78
91
 
79
92
  def _register_builtins() -> None:
80
93
  register_artist("line_plot", LinePlot)
94
+ register_artist("bar_line", BarLinePlot)
95
+ register_artist("box_plot", BoxPlot)
81
96
  register_artist("stacked_area", StackedAreaPlot)
82
97
  register_artist("stacked_bar", StackedBarPlot)
83
98
  register_artist("barh", HorizontalBarChart)
@@ -85,6 +100,8 @@ def _register_builtins() -> None:
85
100
  register_artist("type_curve", TypeCurve)
86
101
  register_artist("vector_plot", VectorPlot)
87
102
  register_artist("matrix", MatrixChart)
103
+ register_artist("shock_plot", ShockChart)
104
+ register_artist("intraday", IntradayPlot)
88
105
  register_artist("waterfall", Waterfall)
89
106
 
90
107
  register_tag("format", Format)
@@ -103,7 +120,10 @@ _register_builtins()
103
120
 
104
121
 
105
122
  __all__ = [
123
+ "__version__",
106
124
  "Artist",
125
+ "BarLinePlot",
126
+ "BoxPlot",
107
127
  "BdeProvider",
108
128
  "BuildContext",
109
129
  "CompositeRegistry",
@@ -115,6 +135,7 @@ __all__ = [
115
135
  "HorizontalBarChart",
116
136
  "Image",
117
137
  "Images",
138
+ "IntradayPlot",
118
139
  "Legend",
119
140
  "LinePlot",
120
141
  "LSEGProvider",
@@ -122,6 +143,7 @@ __all__ = [
122
143
  "RegistryProtocol",
123
144
  "Report",
124
145
  "Section",
146
+ "ShockChart",
125
147
  "StackedAreaPlot",
126
148
  "StackedBarPlot",
127
149
  "Subtitle",
@@ -26,9 +26,13 @@ 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
35
+ from tesorotools.artists.shock_plot import ShockChart
32
36
  from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
33
37
  from tesorotools.artists.type_curve import TypeCurve
34
38
  from tesorotools.artists.vector_plot import VectorPlot
@@ -36,12 +40,16 @@ from tesorotools.artists.waterfall import Waterfall
36
40
 
37
41
  __all__ = [
38
42
  "Artist",
43
+ "BarLinePlot",
44
+ "BoxPlot",
39
45
  "Format",
40
46
  "GroupedBarChart",
41
47
  "HorizontalBarChart",
48
+ "IntradayPlot",
42
49
  "Legend",
43
50
  "LinePlot",
44
51
  "MatrixChart",
52
+ "ShockChart",
45
53
  "StackedAreaPlot",
46
54
  "StackedBarPlot",
47
55
  "TypeCurve",
@@ -96,9 +96,7 @@ def dynamic_dpi(fig: Figure) -> int:
96
96
  class Artist(Protocol):
97
97
  """Structural contract every image artist satisfies.
98
98
 
99
- The five built-in artists (``LinePlot``,
100
- ``StackedAreaPlot``, ``StackedBarPlot``, ``TypeCurve``,
101
- ``HorizontalBarChart``) and any third-party artist
99
+ The built-in artists and any third-party artist
102
100
  registered via :func:`tesorotools.register_artist`
103
101
  expose the same three-piece surface:
104
102
 
@@ -0,0 +1,362 @@
1
+ """Bar + line combo chart artist, registered as ``!bar_line``.
2
+
3
+ Defines :class:`BarLinePlot`: one time-series chart that draws some
4
+ series as **vertical bars** and others as **marked lines** on a single
5
+ shared y-axis. The canonical use is a secondary magnitude in bars (an
6
+ earnings *surprise* in p.p.) under a primary series as the highlighted
7
+ line (earnings *growth* in %), both read against the same 0 baseline.
8
+
9
+ Unlike :class:`LinePlot`, the x-axis is **categorical**: one slot per
10
+ observation (``np.arange``) with date-formatted tick labels, so the bars
11
+ sit at even spacing regardless of the calendar gaps between periods --
12
+ the layout every quarterly/monthly bar chart wants. Lines are drawn at
13
+ the same integer positions so they overlay the bars exactly.
14
+
15
+ This is deliberately *not* an extension of :class:`StackedBarPlot`'s
16
+ ``overlay_series`` (a hardcoded black 1.5 px line, no markers): here the
17
+ line is a first-class, fully styleable series -- colour, marker,
18
+ linewidth -- and the bars are the secondary layer.
19
+
20
+ Layout and the shared ``Format`` / ``Legend`` config holders live in
21
+ :mod:`tesorotools.artists._common`; combo styling defaults come from
22
+ ``PLOT_CONFIG['bar_line']``. Render lifecycle mirrors
23
+ :class:`LinePlot`: ``build()`` returns ``(Figure, Axes)`` in memory;
24
+ ``save(fig)`` persists to ``out_path``; ``plot()`` does both.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import locale
30
+ from pathlib import Path
31
+ from typing import Any, Self
32
+
33
+ import matplotlib.pyplot as plt
34
+ import numpy as np
35
+ import pandas as pd
36
+ from matplotlib.axes import Axes
37
+ from matplotlib.figure import Figure
38
+ from yaml.nodes import MappingNode
39
+
40
+ from tesorotools.artists._common import (
41
+ AX_CONFIG,
42
+ Format,
43
+ Legend,
44
+ adjust_figure_for_plot_size,
45
+ compact_figure_kwargs,
46
+ compact_font_pt,
47
+ dynamic_dpi,
48
+ place_legend,
49
+ resolve_data,
50
+ style_baseline,
51
+ style_compact_axes,
52
+ style_spines,
53
+ )
54
+ from tesorotools.utils.matplotlib import PLOT_CONFIG
55
+ from tesorotools.utils.template import TemplateLoader
56
+
57
+ locale.setlocale(locale.LC_ALL, "")
58
+
59
+ __all__ = ["BarLinePlot"]
60
+
61
+ BAR_LINE_CONFIG: dict[str, Any] = PLOT_CONFIG["bar_line"]
62
+
63
+
64
+ class BarLinePlot:
65
+ """Combo chart: vertical bars and marked lines on one shared axis.
66
+
67
+ Parameters mirror the other time-series artists where they overlap
68
+ (``out_path``, ``data`` / ``data_path``, ``scale``, ``start_date`` /
69
+ ``end_date``, ``fmt``, ``legend``, ``figsize``, ``plot_size``,
70
+ ``compact`` / ``font_pt``). Combo-specific knobs:
71
+
72
+ * ``bars`` -- ``id -> label`` mapping drawn as vertical bars. Several
73
+ bar series are grouped (dodged) side by side within each slot.
74
+ * ``lines`` -- ``id -> label`` mapping drawn as marked lines, on top of
75
+ the bars. At least one of ``bars`` / ``lines`` must be non-empty.
76
+ * ``bar_styles`` / ``line_styles`` -- per-series matplotlib kwargs.
77
+ Lines default to a round marker (``marker: "o"``, configurable per
78
+ series) and the combo linewidth from ``plots.yaml``; bars default to
79
+ that section's alpha. Colours default to the house cycle -- lines
80
+ first (``C0``, ``C2``, ...), bars after them -- so the common one
81
+ line + one bar case lands on ``C0`` (line) over ``C1`` (bar).
82
+ * ``bar_width`` -- total width of each slot's bar group (fraction of the
83
+ unit spacing).
84
+ * ``baseline`` -- draw the 0 line (default ``True``; bars grow from it).
85
+ * ``date_format`` / ``x_rotation`` / ``max_xticks`` -- x tick labelling.
86
+ ``date_format`` is applied via ``strftime`` (lower-cased, e.g.
87
+ ``"dic-21"``) to a DatetimeIndex; ``max_xticks`` thins the labels
88
+ (``None`` labels every slot).
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ out_path: Path,
94
+ *,
95
+ data: pd.DataFrame | None = None,
96
+ data_path: Path | None = None,
97
+ bars: dict[str, str] | None = None,
98
+ lines: dict[str, str] | None = None,
99
+ scale: float = 1,
100
+ start_date: str | None = None,
101
+ end_date: str | None = None,
102
+ baseline: bool = True,
103
+ fmt: Format | None = None,
104
+ legend: Legend | None = None,
105
+ figsize: tuple[float, float] | None = None,
106
+ plot_size: tuple[float, float] | None = None,
107
+ bar_styles: dict[str, dict[str, Any]] | None = None,
108
+ line_styles: dict[str, dict[str, Any]] | None = None,
109
+ bar_width: float = 0.7,
110
+ date_format: str = "%b-%y",
111
+ x_rotation: float = 45,
112
+ max_xticks: int | None = None,
113
+ compact: bool = False,
114
+ font_pt: float | None = None,
115
+ ) -> None:
116
+ if out_path.suffix != ".png":
117
+ raise ValueError(f"out_path must be .png: {out_path}")
118
+ self.bars = bars or {}
119
+ self.lines = lines or {}
120
+ if not self.bars and not self.lines:
121
+ raise ValueError(
122
+ "BarLinePlot needs at least one series in `bars` or `lines`"
123
+ )
124
+ self.out_path = out_path
125
+ self.data = resolve_data(data, data_path)
126
+ self.scale = scale
127
+ self.start_date = start_date
128
+ self.end_date = end_date
129
+ self.baseline = baseline
130
+ self.fmt = fmt or Format()
131
+ self.legend = legend
132
+ self.figsize = figsize
133
+ self.plot_size = plot_size
134
+ self.bar_styles = bar_styles or {}
135
+ self.line_styles = line_styles or {}
136
+ self.bar_width = bar_width
137
+ self.date_format = date_format
138
+ self.x_rotation = x_rotation
139
+ self.max_xticks = max_xticks
140
+ self.compact = compact
141
+ self.font_pt = font_pt
142
+
143
+ @classmethod
144
+ def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
145
+ """Build a :class:`BarLinePlot` from the ``!bar_line`` YAML tag.
146
+
147
+ Required keys
148
+ -------------
149
+ ``out_path``
150
+ ``.png`` destination path.
151
+ ``data_path``
152
+ ``.feather`` file with the time-series DataFrame
153
+ (DatetimeIndex × series columns).
154
+ ``bars`` and/or ``lines``
155
+ ``id -> display_label`` mappings; at least one must be
156
+ non-empty and every id must be a column of the DataFrame.
157
+
158
+ Optional keys (forwarded as-is to ``__init__``)
159
+ ----------------------------------------------
160
+ ``scale``, ``start_date`` / ``end_date``, ``baseline``,
161
+ ``fmt`` (``!format``), ``legend`` (``!legend``), ``figsize``,
162
+ ``plot_size``, ``bar_styles`` / ``line_styles``, ``bar_width``,
163
+ ``date_format``, ``x_rotation``, ``max_xticks``, ``compact``,
164
+ ``font_pt``.
165
+
166
+ Example
167
+ -------
168
+ .. code-block:: yaml
169
+
170
+ stoxx_earnings: !bar_line
171
+ out_path: out/stoxx_earnings.png
172
+ data_path: data/earnings.feather
173
+ lines:
174
+ growth: "Crecimiento beneficios (%)"
175
+ bars:
176
+ surprise: "Sorpresa (pp)"
177
+ fmt: !format {decimals: 0}
178
+ legend: !legend {}
179
+ """
180
+ cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
181
+ node, deep=True
182
+ )
183
+ cfg.pop("id")
184
+ cfg["out_path"] = Path(cfg["out_path"])
185
+ if "data_path" in cfg:
186
+ cfg["data_path"] = Path(cfg["data_path"])
187
+ return cls(**cfg)
188
+
189
+ def _prepare_data(self) -> pd.DataFrame:
190
+ """Slice by date and scale -- the backend-neutral IR.
191
+
192
+ Returns the sliced, scaled frame carrying every bar and line
193
+ column in dict order. NaNs are preserved (lines skip missing
194
+ points, bars zero them at draw time), so the transform is shared
195
+ byte-for-byte by :meth:`build` and :meth:`to_plotly`.
196
+ """
197
+ start = (
198
+ pd.Timestamp(self.start_date)
199
+ if self.start_date
200
+ else self.data.index.min()
201
+ )
202
+ end = (
203
+ pd.Timestamp(self.end_date)
204
+ if self.end_date
205
+ else self.data.index.max()
206
+ )
207
+ cols = list(self.bars.keys()) + list(self.lines.keys())
208
+ plot_data = self.data.loc[start:end, cols]
209
+ plot_data = plot_data.dropna(how="all")
210
+ return plot_data * self.scale
211
+
212
+ def _check_columns(self) -> None:
213
+ missing = [
214
+ c for c in (*self.bars, *self.lines) if c not in self.data.columns
215
+ ]
216
+ if missing:
217
+ raise KeyError(
218
+ f"BarLinePlot columns not found in data: {missing}. "
219
+ f"Available: {list(self.data.columns)}"
220
+ )
221
+
222
+ def _fmt_date(self, value: Any) -> str:
223
+ """Format one x label: ``strftime`` (lower-cased) or ``str``."""
224
+ strftime = getattr(value, "strftime", None)
225
+ if strftime is None:
226
+ return str(value)
227
+ return strftime(self.date_format).lower().rstrip(".")
228
+
229
+ def _format_xticks(
230
+ self,
231
+ ax: Axes,
232
+ index: pd.Index,
233
+ x: np.ndarray[tuple[int], np.dtype[np.intp]],
234
+ ) -> None:
235
+ """Place date-formatted x ticks, thinned to ``max_xticks``."""
236
+ n = len(index)
237
+ divisor = self.max_xticks or (6 if self.compact else n)
238
+ step = max(1, -(-n // max(1, divisor))) # ceil(n / divisor)
239
+ positions = list(range(0, n, step))
240
+ labels = [self._fmt_date(index[i]) for i in positions]
241
+ ax.set_xticks( # type: ignore[reportUnknownMemberType]
242
+ positions
243
+ )
244
+ ax.set_xticklabels( # type: ignore[reportUnknownMemberType]
245
+ labels,
246
+ rotation=self.x_rotation,
247
+ ha="right" if self.x_rotation else "center",
248
+ )
249
+
250
+ def to_plotly(self) -> Any:
251
+ """Render the same combo chart as an interactive Plotly figure.
252
+
253
+ Experimental sibling of :meth:`build`; requires the optional
254
+ ``interactive_plots`` extra (``plotly``), imported lazily.
255
+ """
256
+ from tesorotools.artists.plotly_backend import bar_line_to_figure
257
+
258
+ return bar_line_to_figure(self)
259
+
260
+ def build(self) -> tuple[Figure, Axes]:
261
+ """Render the chart in memory; return ``(fig, ax)`` without saving."""
262
+ self._check_columns()
263
+ plot_data = self._prepare_data()
264
+ x = np.arange(len(plot_data))
265
+ cycle: list[str] = plt.rcParams["axes.prop_cycle"].by_key()["color"]
266
+
267
+ font_pt = compact_font_pt(self.font_pt) if self.compact else None
268
+ fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
269
+ **compact_figure_kwargs(self.compact, self.figsize)
270
+ )
271
+ ax: Axes = fig.add_subplot()
272
+
273
+ # Bars first (behind the lines). Several bar series share each slot,
274
+ # dodged side by side; a single series is centred.
275
+ n_bars = len(self.bars)
276
+ each = self.bar_width / n_bars if n_bars else self.bar_width
277
+ default_alpha: float = float(BAR_LINE_CONFIG["bar"].get("alpha", 0.9))
278
+ for j, (col, label) in enumerate(self.bars.items()):
279
+ style = dict(self.bar_styles.get(col, {}))
280
+ style.setdefault("color", cycle[(len(self.lines) + j) % len(cycle)])
281
+ style.setdefault("alpha", default_alpha)
282
+ offset = (j - (n_bars - 1) / 2) * each
283
+ values = plot_data[col].fillna(0).to_numpy(dtype=np.float64)
284
+ ax.bar( # type: ignore[reportUnknownMemberType]
285
+ x + offset,
286
+ values,
287
+ width=each,
288
+ label=label,
289
+ zorder=2,
290
+ **style,
291
+ )
292
+
293
+ # Lines on top, marked. Defaults (marker, linewidth, markersize) come
294
+ # from the bar_line config; per-series line_styles override them.
295
+ line_cfg: dict[str, Any] = BAR_LINE_CONFIG["line"]
296
+ for i, (col, label) in enumerate(self.lines.items()):
297
+ style = dict(self.line_styles.get(col, {}))
298
+ style.setdefault("color", cycle[i % len(cycle)])
299
+ style.setdefault("marker", line_cfg.get("marker", "o"))
300
+ style.setdefault("linewidth", line_cfg.get("linewidth", 2))
301
+ style.setdefault("markersize", line_cfg.get("markersize", 5))
302
+ ax.plot( # type: ignore[reportUnknownMemberType]
303
+ x,
304
+ plot_data[col].to_numpy(dtype=np.float64),
305
+ label=label,
306
+ zorder=3,
307
+ **style,
308
+ )
309
+
310
+ self._format_xticks(ax, plot_data.index, x)
311
+
312
+ style_spines(
313
+ ax,
314
+ decimals=self.fmt.decimals,
315
+ units=self.fmt.units,
316
+ **AX_CONFIG["spines"],
317
+ )
318
+ # style_spines enables the y grid only (axis="y"), which is what these
319
+ # charts read against; the categorical x-axis stays gridless.
320
+ if self.baseline:
321
+ style_baseline(ax, 0, **AX_CONFIG["baseline"])
322
+
323
+ if self.compact:
324
+ style_compact_axes(ax, font_pt, side="right")
325
+
326
+ legend_ncol = self.legend.ncol if self.legend else None
327
+ if self.legend is not None:
328
+ place_legend(fig, ax, compact=self.compact, ncol=legend_ncol)
329
+
330
+ if self.plot_size is not None and not self.compact:
331
+ adjust_figure_for_plot_size(fig, ax, self.plot_size)
332
+
333
+ return fig, ax
334
+
335
+ def save(
336
+ self,
337
+ fig: Figure,
338
+ *,
339
+ path: Path | None = None,
340
+ dpi: int | None = None,
341
+ ) -> Path:
342
+ """Persist *fig* as a PNG and return the path written.
343
+
344
+ Mirrors :meth:`LinePlot.save`: defaults to ``self.out_path``;
345
+ ``dpi`` overrides the save resolution, derived from the figure
346
+ size via :func:`dynamic_dpi` when omitted.
347
+ """
348
+ target: Path = path if path is not None else self.out_path
349
+ save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
350
+ fig.savefig( # type: ignore[reportUnknownMemberType]
351
+ target, dpi=save_dpi
352
+ )
353
+ return target
354
+
355
+ def plot(self) -> Axes:
356
+ """Build the chart and persist it to ``self.out_path``."""
357
+ fig, ax = self.build()
358
+ try:
359
+ self.save(fig)
360
+ finally:
361
+ plt.close(fig)
362
+ return ax
@@ -604,6 +604,16 @@ class HorizontalBarChart:
604
604
  cfg["data_path"] = Path(cfg["data_path"])
605
605
  return cls(**cfg)
606
606
 
607
+ def to_plotly(self) -> Any:
608
+ """Render the same horizontal bars as an interactive Plotly figure.
609
+
610
+ Experimental sibling of :meth:`plot`; requires the optional
611
+ ``interactive_plots`` extra (``plotly``), imported lazily.
612
+ """
613
+ from tesorotools.artists.plotly_backend import horizontal_bar_to_figure
614
+
615
+ return horizontal_bar_to_figure(self)
616
+
607
617
  def _build_frame(self) -> pd.DataFrame:
608
618
  """Stage the per-bar value/colour/alpha DataFrame."""
609
619
  ids = list(self.alias.keys())
@@ -846,6 +856,16 @@ class GroupedBarChart:
846
856
  cfg["data_path"] = Path(cfg["data_path"])
847
857
  return cls(**cfg)
848
858
 
859
+ def to_plotly(self) -> Any:
860
+ """Render the same grouped bars as an interactive Plotly figure.
861
+
862
+ Experimental sibling of :meth:`plot`; requires the optional
863
+ ``interactive_plots`` extra (``plotly``), imported lazily.
864
+ """
865
+ from tesorotools.artists.plotly_backend import grouped_bar_to_figure
866
+
867
+ return grouped_bar_to_figure(self)
868
+
849
869
  def plot(self) -> Axes:
850
870
  """Render the chart and persist it to ``self.out_path``."""
851
871
  cat_ids = list(self.categories.keys())