tesorotools-python 0.0.24__tar.gz → 0.0.26__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 (72) hide show
  1. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/.gitignore +7 -1
  2. tesorotools_python-0.0.26/PKG-INFO +18 -0
  3. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/pyproject.toml +15 -12
  4. tesorotools_python-0.0.26/src/tesorotools/artists/__init__.py +5 -0
  5. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/barh_plot.py +0 -3
  6. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/line_plot.py +155 -17
  7. tesorotools_python-0.0.26/src/tesorotools/artists/stacked.py +308 -0
  8. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/type_curve.py +8 -47
  9. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/convert.py +1 -1
  10. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/data_sources/lseg.py +26 -6
  11. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/database/push.py +0 -11
  12. tesorotools_python-0.0.26/src/tesorotools/dependencies/node.py +48 -0
  13. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/dependencies/resolution.py +39 -17
  14. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/offsets/offsets.py +69 -6
  15. tesorotools_python-0.0.26/src/tesorotools/offsets/outliers.py +25 -0
  16. tesorotools_python-0.0.26/src/tesorotools/pipeline/__init__.py +0 -0
  17. tesorotools_python-0.0.26/src/tesorotools/pipeline/diagnose.py +54 -0
  18. tesorotools_python-0.0.26/src/tesorotools/pipeline/engine.py +77 -0
  19. tesorotools_python-0.0.26/src/tesorotools/pipeline/rules.py +197 -0
  20. tesorotools_python-0.0.26/src/tesorotools/providers/__init__.py +0 -0
  21. tesorotools_python-0.0.26/src/tesorotools/providers/base.py +72 -0
  22. tesorotools_python-0.0.26/src/tesorotools/providers/bde.py +267 -0
  23. tesorotools_python-0.0.26/src/tesorotools/py.typed +0 -0
  24. tesorotools_python-0.0.26/src/tesorotools/render/__init__.py +0 -0
  25. tesorotools_python-0.0.26/src/tesorotools/render/content/__init__.py +0 -0
  26. tesorotools_python-0.0.26/src/tesorotools/utils/config.py +98 -0
  27. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/series.py +4 -4
  28. tesorotools_python-0.0.24/PKG-INFO +0 -16
  29. tesorotools_python-0.0.24/src/tesorotools/__init__.py +0 -6
  30. tesorotools_python-0.0.24/src/tesorotools/artists/__init__.py +0 -9
  31. tesorotools_python-0.0.24/src/tesorotools/dependencies/functions.py +0 -11
  32. tesorotools_python-0.0.24/src/tesorotools/dependencies/node.py +0 -35
  33. tesorotools_python-0.0.24/src/tesorotools/offsets/outliers.py +0 -15
  34. tesorotools_python-0.0.24/src/tesorotools/render/__init__.py +0 -17
  35. tesorotools_python-0.0.24/src/tesorotools/utils/config.py +0 -38
  36. {tesorotools_python-0.0.24/src/tesorotools/data_sources → tesorotools_python-0.0.26/src/tesorotools}/__init__.py +0 -0
  37. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/barh.md +0 -0
  38. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/table.py +0 -0
  39. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/README.md +0 -0
  40. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  41. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  42. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  43. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  44. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  45. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  46. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  47. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  48. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/README.md +0 -0
  49. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/plots.yaml +0 -0
  50. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  51. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/data_sources/README.md +0 -0
  52. {tesorotools_python-0.0.24/src/tesorotools/dependencies → tesorotools_python-0.0.26/src/tesorotools/data_sources}/__init__.py +0 -0
  53. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/data_sources/debug.py +0 -0
  54. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/database/__init__.py +0 -0
  55. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/database/local.py +0 -0
  56. {tesorotools_python-0.0.24/src/tesorotools/offsets → tesorotools_python-0.0.26/src/tesorotools/dependencies}/__init__.py +0 -0
  57. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/main.py +0 -0
  58. {tesorotools_python-0.0.24/src/tesorotools/render/content → tesorotools_python-0.0.26/src/tesorotools/offsets}/__init__.py +0 -0
  59. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/content.py +0 -0
  60. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/images.py +0 -0
  61. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/section.py +0 -0
  62. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/subtitle.py +0 -0
  63. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/table.py +0 -0
  64. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/text.py +0 -0
  65. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/title.py +0 -0
  66. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/report.py +0 -0
  67. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/__init__.py +0 -0
  68. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/format.py +0 -0
  69. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/globals.py +0 -0
  70. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/matplotlib.py +0 -0
  71. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/shortcuts.py +0 -0
  72. {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/template.py +0 -0
@@ -21,4 +21,10 @@ dist/
21
21
  test/
22
22
 
23
23
  # font files
24
- *.otf
24
+ *.otf
25
+
26
+ # coverage
27
+ .coverage
28
+
29
+ # ruff
30
+ .ruff_cache/
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: tesorotools-python
3
+ Version: 0.0.26
4
+ Requires-Python: >=3.13
5
+ Requires-Dist: babel>=2.17
6
+ Requires-Dist: eikon>=1.1
7
+ Requires-Dist: lseg-data>=2.0
8
+ Requires-Dist: matplotlib>=3.10
9
+ Requires-Dist: openpyxl>=3.1
10
+ Requires-Dist: pandas>=2.2
11
+ Requires-Dist: psycopg[binary]>=3.1
12
+ Requires-Dist: pyarrow>=18.0
13
+ Requires-Dist: python-docx>=1.1
14
+ Requires-Dist: pywin32>=311; sys_platform == 'win32'
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: sqlalchemy>=2.0
17
+ Provides-Extra: bde
18
+ Requires-Dist: requests>=2.31; extra == 'bde'
@@ -1,31 +1,34 @@
1
1
  [project]
2
2
  name = "tesorotools-python"
3
3
  requires-python = ">=3.13"
4
- version = "0.0.24"
4
+ version = "0.0.26"
5
5
  dependencies = [
6
6
  # database and ORM
7
- "psycopg[binary]",
7
+ "psycopg[binary]>=3.1",
8
8
  "SQLAlchemy>=2.0",
9
9
 
10
10
  # data analysis
11
- "pandas",
12
- "pyarrow",
13
- "openpyxl",
11
+ "pandas>=2.2",
12
+ "pyarrow>=18.0",
13
+ "openpyxl>=3.1",
14
14
 
15
15
  # utils
16
- "PyYAML",
17
- "babel",
18
- "eikon",
19
- "lseg-data",
16
+ "PyYAML>=6.0",
17
+ "babel>=2.17",
18
+ "eikon>=1.1",
19
+ "lseg-data>=2.0",
20
20
 
21
21
  # data visualization
22
- "matplotlib",
23
- "python-docx",
22
+ "matplotlib>=3.10",
23
+ "python-docx>=1.1",
24
24
 
25
25
  # os dependencies
26
- "pywin32>=311; sys_platform == 'win32'"
26
+ "pywin32>=311; sys_platform == 'win32'",
27
27
  ]
28
28
 
29
+ [project.optional-dependencies]
30
+ bde = ["requests>=2.31"]
31
+
29
32
  [dependency-groups]
30
33
  dev = [
31
34
  "ruff>=0.8",
@@ -0,0 +1,5 @@
1
+ import matplotlib.style
2
+
3
+ from ..utils.globals import STYLE_SHEET
4
+
5
+ matplotlib.style.use(STYLE_SHEET)
@@ -1,8 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- # Añadir anotaciones de tipos, el objetivo sería eliminar todos los diccionarios con anotaciones del tipo dict[str, Any] que dicen más bien poco
4
- # Tener cuidado para que nada de lo que está hecho con esta librería deje de funcionar
5
-
6
3
  from enum import Enum
7
4
  from pathlib import Path
8
5
  from typing import Any, Self
@@ -26,7 +26,77 @@ AX_CONFIG: dict[str, Any] = PLOT_CONFIG["ax"]
26
26
  FIG_CONFIG: dict[str, Any] = PLOT_CONFIG["figure"]
27
27
 
28
28
 
29
- def _style_spines(
29
+ def adjust_figure_for_plot_size(
30
+ fig: Figure,
31
+ ax: Axes,
32
+ plot_size: tuple[float, float],
33
+ ) -> None:
34
+ """Resize the figure so the axes area matches *plot_size*.
35
+
36
+ Call this **after** adding the legend. The figure
37
+ height grows to accommodate the legend while keeping
38
+ the plot area at the requested size.
39
+
40
+ Parameters
41
+ ----------
42
+ fig
43
+ The figure to resize.
44
+ ax
45
+ The axes whose area should match *plot_size*.
46
+ plot_size
47
+ Desired ``(width, height)`` of the axes area in
48
+ inches.
49
+ """
50
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
51
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
52
+ ax_bbox = ax.get_tightbbox(renderer) # type: ignore[reportUnknownArgumentType]
53
+ fig_bbox = fig.get_tightbbox(renderer) # type: ignore[reportUnknownArgumentType]
54
+ if ax_bbox is None or fig_bbox is None: # type: ignore[reportUnnecessaryComparison]
55
+ return
56
+ fig_w, fig_h = fig.get_size_inches()
57
+ ax_w_in = ax_bbox.width / fig.dpi
58
+ ax_h_in = ax_bbox.height / fig.dpi
59
+ if ax_w_in <= 0 or ax_h_in <= 0:
60
+ return
61
+ new_w = fig_w * (plot_size[0] / ax_w_in)
62
+ new_h = fig_h * (plot_size[1] / ax_h_in)
63
+ fig.set_size_inches(new_w, new_h)
64
+
65
+
66
+ def auto_ncol(ax: Axes, labels: list[str]) -> int:
67
+ """Choose the maximum legend ncol that fits the axes.
68
+
69
+ Renders each label as a temporary Text to measure its
70
+ width, then packs as many columns as the axes width
71
+ allows, with padding for the legend handle + spacing.
72
+ Falls back to ``len(labels)`` (single row) when all
73
+ labels are short enough.
74
+ """
75
+ fig = ax.get_figure()
76
+ if fig is None:
77
+ return len(labels)
78
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
79
+ ax_width_px = ax.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
80
+
81
+ # Measure widest label in pixels.
82
+ handle_pad_px = 40.0 # handle icon + spacing estimate
83
+ max_label_px = 0.0
84
+ for label in labels:
85
+ t = ax.text( # type: ignore[reportUnknownMemberType]
86
+ 0, 0, label
87
+ )
88
+ bbox = t.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
89
+ max_label_px = max(max_label_px, bbox.width)
90
+ t.remove()
91
+
92
+ col_width = max_label_px + handle_pad_px
93
+ if col_width <= 0:
94
+ return len(labels)
95
+ ncol = max(1, int(ax_width_px / col_width))
96
+ return min(ncol, len(labels))
97
+
98
+
99
+ def style_spines(
30
100
  ax: Axes,
31
101
  decimals: int,
32
102
  units: str,
@@ -62,7 +132,37 @@ def _style_spines(
62
132
  tick.set_markeredgecolor(color)
63
133
 
64
134
 
65
- def _style_baseline(
135
+ def export_legend(
136
+ ax: Axes,
137
+ out_path: Path,
138
+ *,
139
+ ncol: int = 5,
140
+ dpi: int = 500,
141
+ ) -> None:
142
+ """Save the legend of *ax* as a standalone PNG.
143
+
144
+ The plot's own legend is removed after export.
145
+ """
146
+ handles, labels = ax.get_legend_handles_labels()
147
+ fig_leg = plt.figure( # type: ignore[reportUnknownMemberType]
148
+ figsize=(6, 0.5), dpi=dpi
149
+ )
150
+ fig_leg.legend( # type: ignore[reportUnknownMemberType]
151
+ handles,
152
+ labels,
153
+ loc="center",
154
+ ncol=ncol,
155
+ )
156
+ fig_leg.savefig( # type: ignore[reportUnknownMemberType]
157
+ out_path, bbox_inches="tight"
158
+ )
159
+ plt.close(fig_leg)
160
+ legend = ax.get_legend()
161
+ if legend is not None:
162
+ legend.remove()
163
+
164
+
165
+ def style_baseline(
66
166
  ax: Axes,
67
167
  reference: float = 0,
68
168
  **baseline_config: Any,
@@ -106,8 +206,8 @@ def plot_line_chart(
106
206
  pass
107
207
 
108
208
  reference = 100 if base_100 else 0
109
- _style_spines(ax, **format, **AX_CONFIG["spines"])
110
- _style_baseline(ax, reference, **AX_CONFIG["baseline"])
209
+ style_spines(ax, **format, **AX_CONFIG["spines"])
210
+ style_baseline(ax, reference, **AX_CONFIG["baseline"])
111
211
  ax.legend( # type: ignore[reportUnknownMemberType]
112
212
  loc="upper center",
113
213
  bbox_to_anchor=(0.5, LINE_PLOT_CONFIG["legend_sep"]),
@@ -161,7 +261,11 @@ class Format:
161
261
 
162
262
 
163
263
  class Legend:
164
- def __init__(self, ncol: int = 5, sep: float = -0.125) -> None:
264
+ def __init__(
265
+ self,
266
+ ncol: int | None = None,
267
+ sep: float = -0.125,
268
+ ) -> None:
165
269
  self.ncol = ncol
166
270
  self.sep = sep
167
271
 
@@ -179,8 +283,8 @@ class LinePlot:
179
283
  def __init__(
180
284
  self,
181
285
  out_path: Path,
182
- data_path: Path,
183
- series: dict[str, str],
286
+ data_path: Path | None = None,
287
+ series: dict[str, str] | None = None,
184
288
  scale: float = 1,
185
289
  start_date: datetime.datetime | None = None,
186
290
  end_date: datetime.datetime | None = None,
@@ -189,17 +293,31 @@ class LinePlot:
189
293
  baseline: bool = False,
190
294
  format: Format | None = None,
191
295
  legend: Legend | None = None,
296
+ data: pd.DataFrame | None = None,
297
+ figsize: tuple[float, float] | None = None,
298
+ series_styles: dict[str, dict[str, Any]] | None = None,
299
+ plot_size: tuple[float, float] | None = None,
192
300
  ) -> None:
193
301
 
194
302
  if out_path.suffix != ".png":
195
303
  raise ValueError(f"The out file {out_path} should be a .png file")
196
304
  self.out_path = out_path
197
305
 
198
- if data_path.suffix != ".feather":
199
- raise ValueError(
200
- f"The data file {data_path} must be a .feather file"
201
- )
202
- self.data = pd.read_feather(data_path)
306
+ if data is not None and data_path is not None:
307
+ raise ValueError("Provide data or data_path, not both")
308
+ if data is not None:
309
+ self.data = data
310
+ elif data_path is not None:
311
+ if data_path.suffix != ".feather":
312
+ raise ValueError(
313
+ f"The data file {data_path} must be a .feather file"
314
+ )
315
+ self.data = pd.read_feather(data_path)
316
+ else:
317
+ raise ValueError("Provide data or data_path")
318
+
319
+ if series is None:
320
+ raise ValueError("series is required")
203
321
 
204
322
  self.base_100 = base_100
205
323
  self.annotate = annotate # unused for the moment
@@ -210,6 +328,9 @@ class LinePlot:
210
328
  self.legend = legend
211
329
  self.baseline = baseline
212
330
  self.scale = scale
331
+ self.figsize = figsize
332
+ self.series_styles = series_styles or {}
333
+ self.plot_size = plot_size
213
334
 
214
335
  @classmethod
215
336
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -244,17 +365,25 @@ class LinePlot:
244
365
  if self.base_100: # maybe more flexible in the future
245
366
  plot_data = plot_data / plot_data.iloc[0, :] * 100
246
367
 
368
+ fig_kw = dict(FIG_CONFIG)
369
+ if self.figsize is not None:
370
+ fig_kw["figsize"] = self.figsize
247
371
  fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
248
- **FIG_CONFIG
372
+ **fig_kw
249
373
  )
250
374
  ax = fig.add_subplot()
251
- plot_data.plot(ax=ax)
375
+ if self.series_styles:
376
+ for col in plot_data.columns:
377
+ style = self.series_styles.get(col, {})
378
+ plot_data[col].plot(ax=ax, label=col, **style)
379
+ else:
380
+ plot_data.plot(ax=ax)
252
381
 
253
382
  if self.annotate: # not implemented yet
254
383
  pass
255
384
 
256
385
  assert self.format is not None
257
- _style_spines( # maybe make this function accept a Format object
386
+ style_spines( # maybe make this function accept a Format object
258
387
  ax,
259
388
  decimals=self.format.decimals,
260
389
  units=self.format.units,
@@ -262,22 +391,31 @@ class LinePlot:
262
391
  )
263
392
  if self.baseline:
264
393
  reference = 100 if self.base_100 else 0
265
- _style_baseline(ax, reference, **AX_CONFIG["baseline"])
394
+ style_baseline(ax, reference, **AX_CONFIG["baseline"])
266
395
 
267
396
  if self.legend is not None:
397
+ labels = list(plot_data.columns)
398
+ ncol = (
399
+ self.legend.ncol
400
+ if self.legend.ncol is not None
401
+ else auto_ncol(ax, labels)
402
+ )
268
403
  ax.legend( # type: ignore[reportUnknownMemberType]
269
404
  loc="upper center",
270
405
  bbox_to_anchor=(
271
406
  0.5,
272
407
  LINE_PLOT_CONFIG["legend_sep"],
273
408
  ),
274
- ncol=self.legend.ncol,
409
+ ncol=ncol,
275
410
  )
276
411
  else:
277
412
  ax.legend().set_visible( # type: ignore[reportUnknownMemberType]
278
413
  False
279
414
  )
280
415
 
416
+ if self.plot_size is not None:
417
+ adjust_figure_for_plot_size(fig, ax, self.plot_size)
418
+
281
419
  fig.savefig( # type: ignore[reportUnknownMemberType]
282
420
  self.out_path
283
421
  )
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Self
5
+
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ import pandas as pd
9
+ from matplotlib.axes import Axes
10
+ from matplotlib.figure import Figure
11
+ from yaml.nodes import MappingNode
12
+
13
+ from tesorotools.artists.line_plot import (
14
+ AX_CONFIG,
15
+ FIG_CONFIG,
16
+ Format,
17
+ Legend,
18
+ adjust_figure_for_plot_size,
19
+ auto_ncol,
20
+ style_baseline,
21
+ style_spines,
22
+ )
23
+ from tesorotools.utils.config import TemplateLoader
24
+
25
+ _DEFAULT_SEP = -0.125
26
+
27
+
28
+ class StackedAreaPlot:
29
+ """Stacked area chart with the tesorotools visual style.
30
+
31
+ Parameters match ``LinePlot`` where applicable so that
32
+ chart configs can switch between types by changing a
33
+ single field.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ out_path: Path,
39
+ data: pd.DataFrame,
40
+ series: dict[str, str],
41
+ *,
42
+ scale: float = 1,
43
+ start_date: str | None = None,
44
+ end_date: str | None = None,
45
+ baseline: bool = False,
46
+ format: Format | None = None,
47
+ legend: Legend | None = None,
48
+ figsize: tuple[float, float] | None = None,
49
+ plot_size: tuple[float, float] | None = None,
50
+ ) -> None:
51
+ if out_path.suffix != ".png":
52
+ raise ValueError(f"out_path must be .png: {out_path}")
53
+ self.out_path = out_path
54
+ self.data = data
55
+ self.series = series
56
+ self.scale = scale
57
+ self.start_date = start_date
58
+ self.end_date = end_date
59
+ self.baseline = baseline
60
+ self.format = format or Format()
61
+ self.legend = legend
62
+ self.figsize = figsize
63
+ self.plot_size = plot_size
64
+
65
+ @classmethod
66
+ def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
67
+ cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
68
+ node, deep=True
69
+ )
70
+ cfg.pop("id")
71
+ cfg["out_path"] = Path(cfg["out_path"])
72
+ cfg["data"] = pd.read_feather(cfg.pop("data_path"))
73
+ return cls(**cfg)
74
+
75
+ def plot(self) -> Axes:
76
+ start = (
77
+ pd.Timestamp(self.start_date)
78
+ if self.start_date
79
+ else self.data.index.min()
80
+ )
81
+ end = (
82
+ pd.Timestamp(self.end_date)
83
+ if self.end_date
84
+ else self.data.index.max()
85
+ )
86
+
87
+ plot_data = self.data.loc[start:end, list(self.series.keys())].dropna()
88
+ plot_data = plot_data * self.scale
89
+
90
+ fig_kw = dict(FIG_CONFIG)
91
+ if self.figsize is not None:
92
+ fig_kw["figsize"] = self.figsize
93
+ fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
94
+ **fig_kw
95
+ )
96
+ ax: Axes = fig.add_subplot()
97
+
98
+ labels = list(self.series.values())
99
+ arrays: list[np.ndarray[tuple[int], np.dtype[np.float64]]] = [
100
+ plot_data[col].to_numpy(dtype=np.float64) for col in self.series
101
+ ]
102
+ ax.stackplot( # type: ignore[reportUnknownMemberType]
103
+ plot_data.index,
104
+ *arrays,
105
+ labels=labels,
106
+ alpha=0.85,
107
+ )
108
+
109
+ style_spines(
110
+ ax,
111
+ decimals=self.format.decimals,
112
+ units=self.format.units,
113
+ **AX_CONFIG["spines"],
114
+ )
115
+ if self.baseline:
116
+ style_baseline(ax, 0, **AX_CONFIG["baseline"])
117
+
118
+ legend_ncol = self.legend.ncol if self.legend else None
119
+ ncol = legend_ncol if legend_ncol is not None else auto_ncol(ax, labels)
120
+ sep = self.legend.sep if self.legend else _DEFAULT_SEP
121
+ ax.legend( # type: ignore[reportUnknownMemberType]
122
+ loc="upper center",
123
+ bbox_to_anchor=(0.5, sep),
124
+ ncol=ncol,
125
+ )
126
+
127
+ if self.plot_size is not None:
128
+ adjust_figure_for_plot_size(fig, ax, self.plot_size)
129
+
130
+ fig.savefig( # type: ignore[reportUnknownMemberType]
131
+ self.out_path
132
+ )
133
+ plt.close(fig)
134
+ return ax
135
+
136
+
137
+ class StackedBarPlot:
138
+ """Stacked bar chart with the tesorotools visual style.
139
+
140
+ Positive and negative values are stacked separately so
141
+ that bars extend in both directions from the baseline.
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ out_path: Path,
147
+ data: pd.DataFrame,
148
+ series: dict[str, str],
149
+ *,
150
+ scale: float = 1,
151
+ start_date: str | None = None,
152
+ end_date: str | None = None,
153
+ baseline: bool = True,
154
+ format: Format | None = None,
155
+ legend: Legend | None = None,
156
+ figsize: tuple[float, float] | None = None,
157
+ overlay_series: dict[str, str] | None = None,
158
+ plot_size: tuple[float, float] | None = None,
159
+ ) -> None:
160
+ if out_path.suffix != ".png":
161
+ raise ValueError(f"out_path must be .png: {out_path}")
162
+ self.out_path = out_path
163
+ self.data = data
164
+ self.series = series
165
+ self.scale = scale
166
+ self.start_date = start_date
167
+ self.end_date = end_date
168
+ self.baseline = baseline
169
+ self.format = format or Format()
170
+ self.legend = legend
171
+ self.plot_size = plot_size
172
+ self.figsize = figsize
173
+ self.overlay_series = overlay_series or {}
174
+
175
+ @classmethod
176
+ def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
177
+ cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
178
+ node, deep=True
179
+ )
180
+ cfg.pop("id")
181
+ cfg["out_path"] = Path(cfg["out_path"])
182
+ cfg["data"] = pd.read_feather(cfg.pop("data_path"))
183
+ return cls(**cfg)
184
+
185
+ def plot(self) -> Axes:
186
+ start = (
187
+ pd.Timestamp(self.start_date)
188
+ if self.start_date
189
+ else self.data.index.min()
190
+ )
191
+ end = (
192
+ pd.Timestamp(self.end_date)
193
+ if self.end_date
194
+ else self.data.index.max()
195
+ )
196
+
197
+ all_cols = list(self.series.keys()) + list(self.overlay_series.keys())
198
+ plot_data = self.data.loc[start:end, all_cols].dropna()
199
+ plot_data = plot_data * self.scale
200
+
201
+ fig_kw = dict(FIG_CONFIG)
202
+ if self.figsize is not None:
203
+ fig_kw["figsize"] = self.figsize
204
+ fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
205
+ **fig_kw
206
+ )
207
+ ax: Axes = fig.add_subplot()
208
+
209
+ cols = list(self.series.keys())
210
+ labels = list(self.series.values())
211
+
212
+ x = np.arange(len(plot_data))
213
+ bar_width = 0.7
214
+
215
+ pos_bottom: np.ndarray[tuple[int], np.dtype[np.float64]] = np.zeros(
216
+ len(plot_data)
217
+ )
218
+ neg_bottom: np.ndarray[tuple[int], np.dtype[np.float64]] = np.zeros(
219
+ len(plot_data)
220
+ )
221
+
222
+ for col, label in zip(cols, labels):
223
+ values: np.ndarray[tuple[int], np.dtype[np.float64]] = plot_data[
224
+ col
225
+ ].to_numpy(dtype=np.float64)
226
+ pos: np.ndarray[tuple[int], np.dtype[np.float64]] = np.where(
227
+ values >= 0, values, 0.0
228
+ )
229
+ neg: np.ndarray[tuple[int], np.dtype[np.float64]] = np.where(
230
+ values < 0, values, 0.0
231
+ )
232
+
233
+ color = (
234
+ ax.bar( # type: ignore[reportUnknownMemberType]
235
+ x,
236
+ pos,
237
+ bottom=pos_bottom,
238
+ width=bar_width,
239
+ label=label,
240
+ )
241
+ .patches[0]
242
+ .get_facecolor()
243
+ )
244
+ ax.bar( # type: ignore[reportUnknownMemberType]
245
+ x,
246
+ neg,
247
+ bottom=neg_bottom,
248
+ width=bar_width,
249
+ color=color,
250
+ )
251
+ pos_bottom = pos_bottom + pos
252
+ neg_bottom = neg_bottom + neg
253
+
254
+ for o_col, o_label in self.overlay_series.items():
255
+ o_vals = plot_data[o_col].to_numpy(dtype=np.float64)
256
+ ax.plot( # type: ignore[reportUnknownMemberType]
257
+ x,
258
+ o_vals,
259
+ color="black",
260
+ linewidth=1.5,
261
+ label=o_label,
262
+ zorder=10,
263
+ )
264
+
265
+ dates = plot_data.index
266
+ step = max(1, len(dates) // 12)
267
+ tick_pos = list(range(0, len(dates), step))
268
+ tick_labels = [dates[i].strftime("%Y") for i in tick_pos]
269
+ ax.set_xticks( # type: ignore[reportUnknownMemberType]
270
+ tick_pos
271
+ )
272
+ ax.set_xticklabels( # type: ignore[reportUnknownMemberType]
273
+ tick_labels
274
+ )
275
+
276
+ style_spines(
277
+ ax,
278
+ decimals=self.format.decimals,
279
+ units=self.format.units,
280
+ **AX_CONFIG["spines"],
281
+ )
282
+ if self.baseline:
283
+ style_baseline(ax, 0, **AX_CONFIG["baseline"])
284
+
285
+ all_labels = list(self.series.values()) + list(
286
+ self.overlay_series.values()
287
+ )
288
+ legend_ncol = self.legend.ncol if self.legend else None
289
+ ncol = (
290
+ legend_ncol
291
+ if legend_ncol is not None
292
+ else auto_ncol(ax, all_labels)
293
+ )
294
+ sep = self.legend.sep if self.legend else _DEFAULT_SEP
295
+ ax.legend( # type: ignore[reportUnknownMemberType]
296
+ loc="upper center",
297
+ bbox_to_anchor=(0.5, sep),
298
+ ncol=ncol,
299
+ )
300
+
301
+ if self.plot_size is not None:
302
+ adjust_figure_for_plot_size(fig, ax, self.plot_size)
303
+
304
+ fig.savefig( # type: ignore[reportUnknownMemberType]
305
+ self.out_path
306
+ )
307
+ plt.close(fig)
308
+ return ax