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.
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/.gitignore +7 -1
- tesorotools_python-0.0.26/PKG-INFO +18 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/pyproject.toml +15 -12
- tesorotools_python-0.0.26/src/tesorotools/artists/__init__.py +5 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/barh_plot.py +0 -3
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/line_plot.py +155 -17
- tesorotools_python-0.0.26/src/tesorotools/artists/stacked.py +308 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/type_curve.py +8 -47
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/convert.py +1 -1
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/data_sources/lseg.py +26 -6
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/database/push.py +0 -11
- tesorotools_python-0.0.26/src/tesorotools/dependencies/node.py +48 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/dependencies/resolution.py +39 -17
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/offsets/offsets.py +69 -6
- tesorotools_python-0.0.26/src/tesorotools/offsets/outliers.py +25 -0
- tesorotools_python-0.0.26/src/tesorotools/pipeline/__init__.py +0 -0
- tesorotools_python-0.0.26/src/tesorotools/pipeline/diagnose.py +54 -0
- tesorotools_python-0.0.26/src/tesorotools/pipeline/engine.py +77 -0
- tesorotools_python-0.0.26/src/tesorotools/pipeline/rules.py +197 -0
- tesorotools_python-0.0.26/src/tesorotools/providers/__init__.py +0 -0
- tesorotools_python-0.0.26/src/tesorotools/providers/base.py +72 -0
- tesorotools_python-0.0.26/src/tesorotools/providers/bde.py +267 -0
- tesorotools_python-0.0.26/src/tesorotools/py.typed +0 -0
- tesorotools_python-0.0.26/src/tesorotools/render/__init__.py +0 -0
- tesorotools_python-0.0.26/src/tesorotools/render/content/__init__.py +0 -0
- tesorotools_python-0.0.26/src/tesorotools/utils/config.py +98 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/series.py +4 -4
- tesorotools_python-0.0.24/PKG-INFO +0 -16
- tesorotools_python-0.0.24/src/tesorotools/__init__.py +0 -6
- tesorotools_python-0.0.24/src/tesorotools/artists/__init__.py +0 -9
- tesorotools_python-0.0.24/src/tesorotools/dependencies/functions.py +0 -11
- tesorotools_python-0.0.24/src/tesorotools/dependencies/node.py +0 -35
- tesorotools_python-0.0.24/src/tesorotools/offsets/outliers.py +0 -15
- tesorotools_python-0.0.24/src/tesorotools/render/__init__.py +0 -17
- tesorotools_python-0.0.24/src/tesorotools/utils/config.py +0 -38
- {tesorotools_python-0.0.24/src/tesorotools/data_sources → tesorotools_python-0.0.26/src/tesorotools}/__init__.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/barh.md +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/table.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/README.md +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/README.md +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/plots.yaml +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/assets/tesoro.mplstyle +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/data_sources/README.md +0 -0
- {tesorotools_python-0.0.24/src/tesorotools/dependencies → tesorotools_python-0.0.26/src/tesorotools/data_sources}/__init__.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/data_sources/debug.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/database/__init__.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/database/local.py +0 -0
- {tesorotools_python-0.0.24/src/tesorotools/offsets → tesorotools_python-0.0.26/src/tesorotools/dependencies}/__init__.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/main.py +0 -0
- {tesorotools_python-0.0.24/src/tesorotools/render/content → tesorotools_python-0.0.26/src/tesorotools/offsets}/__init__.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/content.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/images.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/section.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/subtitle.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/table.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/text.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/content/title.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/render/report.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/__init__.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/format.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/globals.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/matplotlib.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/shortcuts.py +0 -0
- {tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/utils/template.py +0 -0
|
@@ -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.
|
|
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",
|
{tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/barh_plot.py
RENAMED
|
@@ -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
|
{tesorotools_python-0.0.24 → tesorotools_python-0.0.26}/src/tesorotools/artists/line_plot.py
RENAMED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
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__(
|
|
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
|
|
199
|
-
raise ValueError(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
**
|
|
372
|
+
**fig_kw
|
|
249
373
|
)
|
|
250
374
|
ax = fig.add_subplot()
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|