tesorotools-python 0.0.47__tar.gz → 0.0.48__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.47 → tesorotools_python-0.0.48}/PKG-INFO +1 -1
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/pyproject.toml +13 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/__init__.py +3 -7
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/_common.py +303 -48
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/barh_plot.py +3 -2
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/line_plot.py +102 -23
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/matrix.py +7 -2
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/stacked.py +3 -2
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/type_curve.py +2 -1
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/vector_plot.py +2 -1
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/waterfall.py +9 -5
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/plots.yaml +24 -6
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/matplotlib.py +19 -1
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/.gitignore +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/_build_context.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/_registry.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/README.md +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/README.md +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/tesoro.mplstyle +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/data_sources/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/data_sources/debug.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/database/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/database/local.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/database/push.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/database/shared.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/dependencies/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/dependencies/node.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/dependencies/resolution.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/driver.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/manifest.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/offsets/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/offsets/offsets.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/offsets/outliers.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/orchestration.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/diagnose.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/engine.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/rules.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/base.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/bde.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/ecb.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/imf_irfcl.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/lseg.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/py.typed +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/content.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/images.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/section.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/subtitle.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/table.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/text.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/title.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/report.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/testing/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/testing/compare.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/__init__.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/config.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/format.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/globals.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/series.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/shortcuts.py +0 -0
- {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/template.py +0 -0
|
@@ -54,12 +54,25 @@ artifacts = ["src/tesorotools/assets/fonts/*.otf"]
|
|
|
54
54
|
include = ["src/tesorotools"]
|
|
55
55
|
artifacts = ["src/tesorotools/assets/fonts/*.otf"]
|
|
56
56
|
|
|
57
|
+
[tool.coverage.run]
|
|
58
|
+
source = ["tesorotools"]
|
|
59
|
+
# Único módulo excluido: el cliente LSEG/Refinitiv es un wrapper de un SDK
|
|
60
|
+
# externo propietario (`import lseg.data`) que necesita sesión y credenciales
|
|
61
|
+
# en vivo; cubrirlo al 100% exigiría mocks frágiles atados a la API del SDK.
|
|
62
|
+
# Todo lo demás (incluida la resolución de dependencias de series derivadas)
|
|
63
|
+
# se cubre con tests reales.
|
|
64
|
+
omit = ["*/providers/lseg.py"]
|
|
65
|
+
|
|
57
66
|
[tool.coverage.report]
|
|
58
67
|
exclude_lines = [
|
|
68
|
+
'pragma: no cover',
|
|
59
69
|
'if __name__ == "__main__"',
|
|
60
70
|
'^\s*\.\.\.$',
|
|
61
71
|
'if TYPE_CHECKING',
|
|
72
|
+
'raise NotImplementedError',
|
|
73
|
+
'@(abc\.)?abstractmethod',
|
|
62
74
|
]
|
|
75
|
+
fail_under = 100
|
|
63
76
|
|
|
64
77
|
[tool.ruff]
|
|
65
78
|
line-length = 80
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Image artists.
|
|
2
2
|
|
|
3
|
-
Five chart classes
|
|
4
|
-
|
|
3
|
+
Five chart classes and one shared helpers module. Importing
|
|
4
|
+
``_common`` applies the house style (palette + fonts) as a side
|
|
5
|
+
effect, so the cycle is active no matter which artist is imported.
|
|
5
6
|
|
|
6
7
|
Each class follows the same shape:
|
|
7
8
|
|
|
@@ -24,8 +25,6 @@ live under :mod:`tesorotools.render`; this package is
|
|
|
24
25
|
strictly image output.
|
|
25
26
|
"""
|
|
26
27
|
|
|
27
|
-
import matplotlib.style
|
|
28
|
-
|
|
29
28
|
from tesorotools.artists._common import Artist, Format, Legend
|
|
30
29
|
from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
|
|
31
30
|
from tesorotools.artists.line_plot import LinePlot
|
|
@@ -34,9 +33,6 @@ from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
|
|
|
34
33
|
from tesorotools.artists.type_curve import TypeCurve
|
|
35
34
|
from tesorotools.artists.vector_plot import VectorPlot
|
|
36
35
|
from tesorotools.artists.waterfall import Waterfall
|
|
37
|
-
from tesorotools.utils.globals import STYLE_SHEET
|
|
38
|
-
|
|
39
|
-
matplotlib.style.use(STYLE_SHEET)
|
|
40
36
|
|
|
41
37
|
__all__ = [
|
|
42
38
|
"Artist",
|
|
@@ -33,28 +33,60 @@ from matplotlib.dates import AutoDateLocator, ConciseDateFormatter
|
|
|
33
33
|
from matplotlib.dates import (
|
|
34
34
|
date2num, # type: ignore[reportUnknownVariableType]
|
|
35
35
|
)
|
|
36
|
+
from matplotlib.dates import (
|
|
37
|
+
num2date, # type: ignore[reportUnknownVariableType]
|
|
38
|
+
)
|
|
36
39
|
from matplotlib.figure import Figure
|
|
37
40
|
from matplotlib.font_manager import FontProperties
|
|
38
41
|
from matplotlib.legend import Legend as MplLegend
|
|
39
|
-
from matplotlib.ticker import FixedLocator, FuncFormatter
|
|
42
|
+
from matplotlib.ticker import FixedLocator, FuncFormatter, MaxNLocator
|
|
40
43
|
from yaml.nodes import MappingNode
|
|
41
44
|
|
|
42
45
|
from tesorotools.utils.matplotlib import (
|
|
43
46
|
PLOT_CONFIG,
|
|
47
|
+
apply_house_style,
|
|
44
48
|
format_annotation,
|
|
45
|
-
load_fonts,
|
|
46
49
|
)
|
|
47
50
|
from tesorotools.utils.template import TemplateLoader
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
# Apply the full house look (colour cycle + grid + tick sizes from the
|
|
53
|
+
# stylesheet, then the bundled fonts) once, here at import: every image
|
|
54
|
+
# artist imports this module, so the cycle is active no matter which
|
|
55
|
+
# artist (or caller) triggers the import first.
|
|
56
|
+
apply_house_style()
|
|
50
57
|
|
|
51
58
|
AX_CONFIG: dict[str, Any] = PLOT_CONFIG["ax"]
|
|
52
59
|
FIG_CONFIG: dict[str, Any] = PLOT_CONFIG["figure"]
|
|
53
60
|
COMPACT_CONFIG: dict[str, Any] = PLOT_CONFIG.get("compact", {})
|
|
61
|
+
SAVE_CONFIG: dict[str, Any] = PLOT_CONFIG.get("save", {})
|
|
54
62
|
|
|
55
63
|
CM_TO_IN: float = 1.0 / 2.54
|
|
56
64
|
|
|
57
65
|
|
|
66
|
+
def dynamic_dpi(fig: Figure) -> int:
|
|
67
|
+
"""Save DPI adapted to the figure's physical size.
|
|
68
|
+
|
|
69
|
+
The figure DPI (``figure.dpi`` in ``plots.yaml``) drives *layout* and
|
|
70
|
+
is deliberately high; saving every PNG at it wastes memory on large
|
|
71
|
+
figures (a 16 cm chart at 500 dpi is ~3150 px wide). Instead the save
|
|
72
|
+
DPI scales **inversely with size** so the long side lands near
|
|
73
|
+
``save.target_px`` pixels: big figures save at fewer dpi (lighter
|
|
74
|
+
files), small ones at more. It is clamped to ``[save.min_dpi,
|
|
75
|
+
figure.dpi]`` -- never below ``save.min_dpi`` (so the result is always
|
|
76
|
+
print-sharp, never pixelated) and never above the layout dpi (above
|
|
77
|
+
which extra pixels are pure waste).
|
|
78
|
+
"""
|
|
79
|
+
target_px: float = float(SAVE_CONFIG.get("target_px", 2000))
|
|
80
|
+
min_dpi: float = float(SAVE_CONFIG.get("min_dpi", 300))
|
|
81
|
+
max_dpi: float = float(FIG_CONFIG.get("dpi", 500))
|
|
82
|
+
width_in, height_in = (float(v) for v in fig.get_size_inches())
|
|
83
|
+
long_in = max(width_in, height_in)
|
|
84
|
+
if long_in <= 0: # pragma: no cover - defensive: a real figure
|
|
85
|
+
# never has a zero/negative side; guards against a 0/0 division.
|
|
86
|
+
return int(max_dpi)
|
|
87
|
+
return int(round(min(max_dpi, max(min_dpi, target_px / long_in))))
|
|
88
|
+
|
|
89
|
+
|
|
58
90
|
# ----------------------------------------------------------------------
|
|
59
91
|
# Structural contract for image artists
|
|
60
92
|
# ----------------------------------------------------------------------
|
|
@@ -224,6 +256,11 @@ def auto_ncol(
|
|
|
224
256
|
ax: Axes,
|
|
225
257
|
labels: list[str],
|
|
226
258
|
available_width_px: float | None = None,
|
|
259
|
+
*,
|
|
260
|
+
fontsize: float | None = None,
|
|
261
|
+
handlelength: float | None = None,
|
|
262
|
+
handletextpad: float | None = None,
|
|
263
|
+
columnspacing: float | None = None,
|
|
227
264
|
) -> int:
|
|
228
265
|
"""Choose the maximum legend ``ncol`` that fits the axes.
|
|
229
266
|
|
|
@@ -236,6 +273,12 @@ def auto_ncol(
|
|
|
236
273
|
Pass ``available_width_px`` when the legend will live in
|
|
237
274
|
``fig.legend`` (figure-wide space) instead of
|
|
238
275
|
``ax.legend``; defaults to the axes width.
|
|
276
|
+
|
|
277
|
+
The keyword overrides (``fontsize`` / ``handlelength`` /
|
|
278
|
+
``handletextpad`` / ``columnspacing``) let a caller measure
|
|
279
|
+
against a legend styled differently from ``rcParams`` -- the
|
|
280
|
+
compact mode passes its smaller legend font and shorter
|
|
281
|
+
handles here so the column count matches what is drawn.
|
|
239
282
|
"""
|
|
240
283
|
fig = ax.get_figure()
|
|
241
284
|
if fig is None:
|
|
@@ -245,18 +288,35 @@ def auto_ncol(
|
|
|
245
288
|
if available_width_px is None:
|
|
246
289
|
available_width_px = ax.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
|
|
247
290
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
)
|
|
291
|
+
legend_fs: Any = (
|
|
292
|
+
fontsize if fontsize is not None else plt.rcParams["legend.fontsize"]
|
|
293
|
+
)
|
|
294
|
+
hl: float = (
|
|
295
|
+
handlelength
|
|
296
|
+
if handlelength is not None
|
|
297
|
+
else float(plt.rcParams["legend.handlelength"])
|
|
298
|
+
)
|
|
299
|
+
htp: float = (
|
|
300
|
+
handletextpad
|
|
301
|
+
if handletextpad is not None
|
|
302
|
+
else float(plt.rcParams["legend.handletextpad"])
|
|
303
|
+
)
|
|
304
|
+
cs: float = (
|
|
305
|
+
columnspacing
|
|
306
|
+
if columnspacing is not None
|
|
307
|
+
else float(plt.rcParams["legend.columnspacing"])
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
fontsize_pt: float = FontProperties(size=legend_fs).get_size_in_points()
|
|
251
311
|
fs_px: float = fontsize_pt * fig.dpi / 72.0
|
|
252
|
-
handle_px: float =
|
|
253
|
-
pad_px: float =
|
|
254
|
-
col_pad_px: float =
|
|
312
|
+
handle_px: float = hl * fs_px
|
|
313
|
+
pad_px: float = htp * fs_px
|
|
314
|
+
col_pad_px: float = cs * fs_px
|
|
255
315
|
|
|
256
316
|
max_label_px = 0.0
|
|
257
317
|
for label in labels:
|
|
258
318
|
t = ax.text( # type: ignore[reportUnknownMemberType]
|
|
259
|
-
0, 0, label
|
|
319
|
+
0, 0, label, fontsize=legend_fs
|
|
260
320
|
)
|
|
261
321
|
bbox = t.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
|
|
262
322
|
max_label_px = max(max_label_px, bbox.width)
|
|
@@ -334,6 +394,92 @@ def compact_font_pt() -> float | None:
|
|
|
334
394
|
return None if raw is None else float(raw)
|
|
335
395
|
|
|
336
396
|
|
|
397
|
+
def compact_legend_font_pt() -> float:
|
|
398
|
+
"""Font size (points) for the compact legend -- never ``None``.
|
|
399
|
+
|
|
400
|
+
Falls back ``legend_font_pt`` -> ``font_pt`` -> ``9`` so the legend
|
|
401
|
+
never silently reverts to the stylesheet's body size (the original
|
|
402
|
+
"11 pt legend" bug): the legend must be measured and drawn at the
|
|
403
|
+
same, smaller size for the column count to be right.
|
|
404
|
+
"""
|
|
405
|
+
return float(
|
|
406
|
+
COMPACT_CONFIG.get("legend_font_pt", COMPACT_CONFIG.get("font_pt", 9))
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def compact_legend_kwargs() -> dict[str, Any]:
|
|
411
|
+
"""Legend styling for compact charts, from the ``compact`` config.
|
|
412
|
+
|
|
413
|
+
Returns ``fontsize`` / ``handlelength`` / ``handletextpad`` /
|
|
414
|
+
``columnspacing`` as concrete values (never ``None``), so the metrics
|
|
415
|
+
used to *measure* the column count in :func:`auto_ncol` match exactly
|
|
416
|
+
the metrics used to *draw* the legend in :func:`legend_above` -- the
|
|
417
|
+
invariant that prevents an over-counted ``ncol``. The smaller font
|
|
418
|
+
and shorter handles let several wide labels share one legend row.
|
|
419
|
+
"""
|
|
420
|
+
return {
|
|
421
|
+
"fontsize": compact_legend_font_pt(),
|
|
422
|
+
"handlelength": float(COMPACT_CONFIG.get("legend_handlelength", 1.2)),
|
|
423
|
+
"handletextpad": float(COMPACT_CONFIG.get("legend_handletextpad", 0.4)),
|
|
424
|
+
"columnspacing": float(COMPACT_CONFIG.get("legend_columnspacing", 0.8)),
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def compact_legend_max_ncol() -> int | None:
|
|
429
|
+
"""Hard cap on the compact legend column count (``None`` if unset)."""
|
|
430
|
+
raw: Any = COMPACT_CONFIG.get("legend_max_ncol")
|
|
431
|
+
return None if raw is None else int(raw)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def compact_headroom_pad_pt() -> float:
|
|
435
|
+
"""Points of clearance to leave above the top value label (0 if unset)."""
|
|
436
|
+
return float(COMPACT_CONFIG.get("annotate_headroom_pad_pt", 0))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class _StartLabeledConcise(ConciseDateFormatter):
|
|
440
|
+
"""``ConciseDateFormatter`` that spells out the first (start) tick.
|
|
441
|
+
|
|
442
|
+
When the compact date axis pins the first datum as the leading tick
|
|
443
|
+
(see :func:`compact_date_axis`) that date is rarely a clean month
|
|
444
|
+
boundary, so the concise formatter would label it with a bare day
|
|
445
|
+
number (e.g. ``31``). This subclass relabels just that tick so the
|
|
446
|
+
chart's origin is unambiguous: a January start shows the **year only**
|
|
447
|
+
(``2020`` -- narrow, like the other year ticks), any other month shows
|
|
448
|
+
``month year`` (``jul. 2020``). Every other tick keeps the concise
|
|
449
|
+
label.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
def __init__(self, locator: Any, start_num: float) -> None:
|
|
453
|
+
# show_offset=False: don't append the trailing "year-month" offset
|
|
454
|
+
# text (it duplicated the month of the last tick, e.g. "jul." plus
|
|
455
|
+
# "2026-jul."). With it off, ConciseDateFormatter shows the year
|
|
456
|
+
# inline at year-boundary ticks instead.
|
|
457
|
+
super().__init__( # type: ignore[reportUnknownMemberType]
|
|
458
|
+
locator, show_offset=False
|
|
459
|
+
)
|
|
460
|
+
self._start_num = start_num
|
|
461
|
+
|
|
462
|
+
def format_ticks(self, values: Any) -> Any:
|
|
463
|
+
labels: list[str] = list(super().format_ticks(values))
|
|
464
|
+
for i, value in enumerate(values):
|
|
465
|
+
moment = num2date(value)
|
|
466
|
+
if abs(float(value) - self._start_num) < 0.5: # within half a day
|
|
467
|
+
if moment.month == 1:
|
|
468
|
+
# January start: the year alone is unambiguous and narrow,
|
|
469
|
+
# so it won't collide with the next (year) tick.
|
|
470
|
+
labels[i] = str(moment.year)
|
|
471
|
+
else:
|
|
472
|
+
# locale's %b may already carry a trailing dot ("ene.");
|
|
473
|
+
# strip it so we don't end up with "jul.. 2020".
|
|
474
|
+
month = moment.strftime("%b").lower().rstrip(".")
|
|
475
|
+
labels[i] = f"{month}. {moment.year}"
|
|
476
|
+
elif moment.month == 1:
|
|
477
|
+
# year boundary: show the year ("2026") instead of "ene." so
|
|
478
|
+
# the reader sees the year change without a trailing offset.
|
|
479
|
+
labels[i] = str(moment.year)
|
|
480
|
+
return labels
|
|
481
|
+
|
|
482
|
+
|
|
337
483
|
def compact_date_axis(
|
|
338
484
|
ax: Axes,
|
|
339
485
|
data_min: Any,
|
|
@@ -341,15 +487,18 @@ def compact_date_axis(
|
|
|
341
487
|
*,
|
|
342
488
|
maxticks: int = 4,
|
|
343
489
|
) -> None:
|
|
344
|
-
"""Compact date x-axis: few horizontal ticks,
|
|
490
|
+
"""Compact date x-axis: few horizontal ticks, anchored to the data span.
|
|
345
491
|
|
|
346
492
|
Ticks are computed over the **data span** ``[data_min,
|
|
347
493
|
data_max]`` and pinned with a ``FixedLocator``, so none
|
|
348
494
|
float in the right-hand margin that the value labels open
|
|
349
495
|
up -- the last tick lands as close to the final data
|
|
350
|
-
point as the tick step allows.
|
|
351
|
-
|
|
352
|
-
|
|
496
|
+
point as the tick step allows. When ``compact.date_show_start``
|
|
497
|
+
is true (the default) the first data point is pinned as the
|
|
498
|
+
leading tick, so the reader can see exactly when the series
|
|
499
|
+
begins. Requires the series to have been plotted with
|
|
500
|
+
``x_compat=True`` (pandas' own date plotting ignores this
|
|
501
|
+
formatter).
|
|
353
502
|
"""
|
|
354
503
|
lo: float = float(cast(Any, date2num(data_min)))
|
|
355
504
|
hi: float = float(cast(Any, date2num(data_max)))
|
|
@@ -357,19 +506,80 @@ def compact_date_axis(
|
|
|
357
506
|
candidates: list[float] = cast(
|
|
358
507
|
"list[float]", locator.tick_values(data_min, data_max)
|
|
359
508
|
)
|
|
360
|
-
|
|
361
|
-
if
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
509
|
+
interior: list[float] = [t for t in candidates if lo < t <= hi]
|
|
510
|
+
if COMPACT_CONFIG.get("date_show_start", True):
|
|
511
|
+
# pin the first data point as the leading tick so the reader sees
|
|
512
|
+
# exactly when the series starts (the natural calendar tick often
|
|
513
|
+
# falls just *before* the first datum and is clipped by margins=0).
|
|
514
|
+
# Drop any auto tick that would crowd the start label.
|
|
515
|
+
min_gap: float = (hi - lo) * 0.10
|
|
516
|
+
interior = [t for t in interior if t - lo >= min_gap]
|
|
517
|
+
ticks: list[float] = [lo, *interior]
|
|
518
|
+
else:
|
|
519
|
+
ticks = [t for t in candidates if lo <= t <= hi] or [lo, hi]
|
|
520
|
+
show_start = bool(COMPACT_CONFIG.get("date_show_start", True))
|
|
521
|
+
|
|
522
|
+
def _set_ticks(values: list[float]) -> None:
|
|
523
|
+
loc = FixedLocator(values)
|
|
524
|
+
ax.xaxis.set_major_locator(loc)
|
|
525
|
+
ax.xaxis.set_major_formatter(
|
|
526
|
+
_StartLabeledConcise(loc, lo)
|
|
527
|
+
if show_start
|
|
528
|
+
else ConciseDateFormatter(loc)
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
_set_ticks(ticks)
|
|
366
532
|
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
367
533
|
axis="x", rotation=0
|
|
368
534
|
)
|
|
535
|
+
|
|
536
|
+
# A priori de-overlap: render the labels, measure their boxes, and drop
|
|
537
|
+
# any tick whose label would collide with the previous kept one (the
|
|
538
|
+
# start tick is always kept). Catches a wide start label (e.g. a January
|
|
539
|
+
# start widened to a year, or "jul. 2020") crowding the next tick.
|
|
540
|
+
fig = ax.get_figure()
|
|
541
|
+
if fig is not None and len(ticks) > 1:
|
|
542
|
+
fig.canvas.draw() # type: ignore[reportUnknownMemberType]
|
|
543
|
+
renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
|
|
544
|
+
boxes = [
|
|
545
|
+
lbl.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
|
|
546
|
+
for lbl in ax.get_xticklabels()
|
|
547
|
+
]
|
|
548
|
+
if len(boxes) == len(ticks):
|
|
549
|
+
pad_px = 2.0
|
|
550
|
+
kept: list[float] = [ticks[0]]
|
|
551
|
+
last_x1: float = boxes[0].x1
|
|
552
|
+
for tick, box in zip(ticks[1:], boxes[1:]):
|
|
553
|
+
if box.x0 <= last_x1 + pad_px:
|
|
554
|
+
continue # collides with previous kept label -> drop
|
|
555
|
+
kept.append(tick)
|
|
556
|
+
last_x1 = box.x1
|
|
557
|
+
if len(kept) < len(ticks):
|
|
558
|
+
_set_ticks(kept)
|
|
559
|
+
|
|
369
560
|
for label in ax.get_xticklabels():
|
|
370
561
|
label.set_horizontalalignment("center")
|
|
371
562
|
|
|
372
563
|
|
|
564
|
+
def compact_y_axis(ax: Axes) -> None:
|
|
565
|
+
"""Denser y-ticks for compact charts (more reference numbers).
|
|
566
|
+
|
|
567
|
+
Compact charts are read at body-text size, so Matplotlib's default
|
|
568
|
+
of a couple of y-numbers on a short axis is too sparse. Bump the
|
|
569
|
+
count to ``compact.y_max_ticks`` "nice" steps (1/2/2.5/5/10 decades)
|
|
570
|
+
so the reader gets more gridline references -- e.g. every 20 instead
|
|
571
|
+
of every 50. Leaves the default locator untouched when the knob is
|
|
572
|
+
unset, and never changes the y-tick *formatter* set by
|
|
573
|
+
:func:`style_spines`.
|
|
574
|
+
"""
|
|
575
|
+
raw: Any = COMPACT_CONFIG.get("y_max_ticks")
|
|
576
|
+
if raw is None:
|
|
577
|
+
return
|
|
578
|
+
ax.yaxis.set_major_locator(
|
|
579
|
+
MaxNLocator(nbins=int(raw), steps=[1, 2, 2.5, 5, 10])
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
373
583
|
def legend_above(
|
|
374
584
|
ax: Axes,
|
|
375
585
|
handles: list[Any],
|
|
@@ -377,26 +587,35 @@ def legend_above(
|
|
|
377
587
|
*,
|
|
378
588
|
ncol: int,
|
|
379
589
|
fontsize: float | None = None,
|
|
590
|
+
handlelength: float | None = None,
|
|
591
|
+
handletextpad: float | None = None,
|
|
592
|
+
columnspacing: float | None = None,
|
|
380
593
|
) -> MplLegend:
|
|
381
|
-
"""Place the legend **above
|
|
382
|
-
|
|
383
|
-
Uses ``
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
594
|
+
"""Place the legend in a band **above the axes, spanning the figure**.
|
|
595
|
+
|
|
596
|
+
Uses ``loc="outside upper center"`` so constrained layout reserves a
|
|
597
|
+
strip at the top of the *figure* and the legend may use the whole
|
|
598
|
+
figure width -- wider than the narrow compact axes, whose left edge
|
|
599
|
+
is eaten by the y-tick numbers and whose right edge is eaten by the
|
|
600
|
+
end-of-series value labels. Spanning the figure lets the maximum
|
|
601
|
+
number of columns share one row, which **minimises the vertical
|
|
602
|
+
space the legend steals from the plot** (fewer rows = taller chart).
|
|
603
|
+
|
|
604
|
+
``fontsize`` / ``handlelength`` / ``handletextpad`` /
|
|
605
|
+
``columnspacing`` style the legend (``None`` keeps the
|
|
606
|
+
``rcParams`` default); the compact mode passes a smaller
|
|
607
|
+
font and shorter handles so more labels fit per row.
|
|
389
608
|
"""
|
|
390
|
-
|
|
391
|
-
return
|
|
609
|
+
fig = ax.get_figure()
|
|
610
|
+
return fig.legend( # type: ignore[reportUnknownMemberType, reportOptionalMemberAccess]
|
|
392
611
|
handles,
|
|
393
612
|
labels,
|
|
394
|
-
loc="
|
|
395
|
-
bbox_to_anchor=tuple(bbox),
|
|
396
|
-
mode="expand",
|
|
397
|
-
borderaxespad=0.0,
|
|
613
|
+
loc="outside upper center",
|
|
398
614
|
ncol=ncol,
|
|
399
615
|
fontsize=fontsize,
|
|
616
|
+
handlelength=handlelength,
|
|
617
|
+
handletextpad=handletextpad,
|
|
618
|
+
columnspacing=columnspacing,
|
|
400
619
|
frameon=False, # no box: less clutter in the tight compact figure
|
|
401
620
|
)
|
|
402
621
|
|
|
@@ -452,13 +671,15 @@ def style_spines(
|
|
|
452
671
|
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
453
672
|
which="minor", size=0, width=0
|
|
454
673
|
)
|
|
674
|
+
# Set the tick-mark colour via tick_params (not by recolouring each
|
|
675
|
+
# Line2D): this stores it in the axis' tick kwargs, so ANY tick
|
|
676
|
+
# regenerated after this call inherits it. Compact mode swaps in a
|
|
677
|
+
# denser y-locator later, which rebuilds the tick objects; recolouring
|
|
678
|
+
# the lines directly here would leave those new ticks the default black
|
|
679
|
+
# (only the relocated ones would stay grey -- hence a lone black tick).
|
|
455
680
|
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
456
|
-
axis="both", which="major"
|
|
681
|
+
axis="both", which="major", color=color
|
|
457
682
|
)
|
|
458
|
-
for tick in ax.get_xticklines():
|
|
459
|
-
tick.set_markeredgecolor(color)
|
|
460
|
-
for tick in ax.get_yticklines():
|
|
461
|
-
tick.set_markeredgecolor(color)
|
|
462
683
|
|
|
463
684
|
|
|
464
685
|
def style_baseline(
|
|
@@ -500,6 +721,7 @@ def annotate_last_values(
|
|
|
500
721
|
weight: str | None = None,
|
|
501
722
|
fontsize: float | None = None,
|
|
502
723
|
right_pad_px: float = 10.0,
|
|
724
|
+
headroom_pad_pt: float = 0.0,
|
|
503
725
|
) -> None:
|
|
504
726
|
"""Label the last non-NaN value of each column on the right.
|
|
505
727
|
|
|
@@ -520,6 +742,13 @@ def annotate_last_values(
|
|
|
520
742
|
``right_pad_px`` (e.g. on compact charts) to open more
|
|
521
743
|
blank space on the right and keep the labels clear of both
|
|
522
744
|
the data lines and the right-hand y-tick labels.
|
|
745
|
+
|
|
746
|
+
``headroom_pad_pt`` is the clearance kept above the top
|
|
747
|
+
label: if the packed stack would reach past the top
|
|
748
|
+
y-limit, that limit grows enough to keep this many points
|
|
749
|
+
free and the labels are re-packed at the new scale, so a
|
|
750
|
+
tall stack never spills into a legend above the axes (nor
|
|
751
|
+
is clipped at the top).
|
|
523
752
|
"""
|
|
524
753
|
fig = ax.get_figure()
|
|
525
754
|
if fig is None:
|
|
@@ -563,14 +792,39 @@ def annotate_last_values(
|
|
|
563
792
|
sample.remove()
|
|
564
793
|
|
|
565
794
|
entries.sort(key=lambda e: e[1])
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
795
|
+
|
|
796
|
+
def _pack(top_data: float) -> list[float]:
|
|
797
|
+
# Set the final y-scale BEFORE packing. Spacing is enforced in display
|
|
798
|
+
# space, so it must be measured at the scale the labels are drawn at;
|
|
799
|
+
# growing the y-limit *after* packing (the old approach) rescaled the
|
|
800
|
+
# data points and quietly reopened the overlaps packing should remove.
|
|
801
|
+
ax.set_ylim(top=top_data)
|
|
802
|
+
fig.canvas.draw() # type: ignore[reportUnknownMemberType]
|
|
803
|
+
packed: list[float] = []
|
|
804
|
+
prev = -float("inf")
|
|
805
|
+
for _, value, _, _ in entries:
|
|
806
|
+
y_disp: float = trans.transform((0, value))[1] # type: ignore[reportUnknownArgumentType]
|
|
807
|
+
if y_disp - prev < text_height:
|
|
808
|
+
y_disp = prev + text_height
|
|
809
|
+
packed.append(y_disp)
|
|
810
|
+
prev = y_disp
|
|
811
|
+
return packed
|
|
812
|
+
|
|
813
|
+
_, ymax = ax.get_ylim()
|
|
814
|
+
placements = _pack(ymax)
|
|
815
|
+
|
|
816
|
+
# The stack packs upward; if it reaches past the top y-limit it renders
|
|
817
|
+
# over the legend (compact, legend above) or is clipped (normal). Grow the
|
|
818
|
+
# y-limit so the top label keeps headroom_pad_pt of clearance, then RE-PACK
|
|
819
|
+
# so the spacing is exact at the new (final) scale.
|
|
820
|
+
pad_px = headroom_pad_pt * fig.dpi / 72.0
|
|
821
|
+
top_axes_px: float = trans.transform((0, ymax))[1] # type: ignore[reportUnknownArgumentType]
|
|
822
|
+
needed_px = placements[-1] + text_height / 2 + pad_px
|
|
823
|
+
if needed_px > top_axes_px:
|
|
824
|
+
new_top: float = trans.inverted().transform( # type: ignore[reportUnknownArgumentType]
|
|
825
|
+
(0, needed_px)
|
|
826
|
+
)[1]
|
|
827
|
+
placements = _pack(new_top)
|
|
574
828
|
|
|
575
829
|
max_label_width = 0.0
|
|
576
830
|
for (date, val, _, color), packed_y in zip(entries, placements):
|
|
@@ -593,7 +847,8 @@ def annotate_last_values(
|
|
|
593
847
|
|
|
594
848
|
# labels spill right of the data, so widen the x-limit by the widest
|
|
595
849
|
# label plus right_pad_px to keep them unclipped (and, with a larger
|
|
596
|
-
# pad, clear of the right-hand y-tick labels).
|
|
850
|
+
# pad, clear of the right-hand y-tick labels). x is independent of the
|
|
851
|
+
# y-limit set above, so this stays correct.
|
|
597
852
|
inv = trans.inverted()
|
|
598
853
|
x0: float = inv.transform((0, 0))[0] # type: ignore[reportUnknownArgumentType]
|
|
599
854
|
x1: float = inv.transform((max_label_width + right_pad_px, 0))[0] # type: ignore[reportUnknownArgumentType]
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/barh_plot.py
RENAMED
|
@@ -36,6 +36,7 @@ from tesorotools.artists._common import (
|
|
|
36
36
|
Legend,
|
|
37
37
|
adjust_figure_for_plot_size,
|
|
38
38
|
auto_ncol,
|
|
39
|
+
dynamic_dpi,
|
|
39
40
|
resolve_data,
|
|
40
41
|
)
|
|
41
42
|
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
@@ -370,7 +371,7 @@ class HorizontalBarChart:
|
|
|
370
371
|
_style_zero_baseline(ax, **AX_CONFIG["baseline"])
|
|
371
372
|
|
|
372
373
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
373
|
-
self.out_path
|
|
374
|
+
self.out_path, dpi=dynamic_dpi(fig)
|
|
374
375
|
)
|
|
375
376
|
plt.close(fig)
|
|
376
377
|
return ax
|
|
@@ -572,7 +573,7 @@ class GroupedBarChart:
|
|
|
572
573
|
adjust_figure_for_plot_size(fig, ax, self.plot_size)
|
|
573
574
|
|
|
574
575
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
575
|
-
self.out_path
|
|
576
|
+
self.out_path, dpi=dynamic_dpi(fig)
|
|
576
577
|
)
|
|
577
578
|
plt.close(fig)
|
|
578
579
|
return ax
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/line_plot.py
RENAMED
|
@@ -32,8 +32,13 @@ from tesorotools.artists._common import (
|
|
|
32
32
|
compact_date_axis,
|
|
33
33
|
compact_figsize,
|
|
34
34
|
compact_font_pt,
|
|
35
|
+
compact_headroom_pad_pt,
|
|
36
|
+
compact_legend_kwargs,
|
|
37
|
+
compact_legend_max_ncol,
|
|
38
|
+
compact_y_axis,
|
|
35
39
|
declutter_spines,
|
|
36
40
|
draw_vlines,
|
|
41
|
+
dynamic_dpi,
|
|
37
42
|
legend_above,
|
|
38
43
|
style_baseline,
|
|
39
44
|
style_spines,
|
|
@@ -65,6 +70,17 @@ class LinePlot:
|
|
|
65
70
|
(color, linestyle, ...).
|
|
66
71
|
* ``baseline`` -- draw an axhline at 0 (or 100 when
|
|
67
72
|
``base_100``).
|
|
73
|
+
* ``ref_lines`` -- arbitrary horizontal reference lines;
|
|
74
|
+
a list of dicts ``{"y": float, "label"?: str,
|
|
75
|
+
"color"?, "linestyle"?, "linewidth"?, "in_legend"?:
|
|
76
|
+
bool}``. Dashed and above the data by default; an entry
|
|
77
|
+
with ``in_legend: true`` and a ``label`` also shows in
|
|
78
|
+
the legend (e.g. a long-run PER mean, a vol threshold).
|
|
79
|
+
* ``show_means`` / ``means_window`` -- draw each series'
|
|
80
|
+
historical mean as a dotted same-colour line (the old
|
|
81
|
+
``medias=True``); the mean is taken over ``means_window``
|
|
82
|
+
(a ``(start, end)`` pair) or the whole series. Not
|
|
83
|
+
meaningful together with ``base_100``.
|
|
68
84
|
* ``compact`` -- render at a fixed physical size (7.4 cm
|
|
69
85
|
wide *including* the legend, from ``plots.yaml``) for
|
|
70
86
|
placing two charts per row inside a Word table. Moves
|
|
@@ -99,6 +115,9 @@ class LinePlot:
|
|
|
99
115
|
plot_size: tuple[float, float] | None = None,
|
|
100
116
|
vlines: list[dict[str, Any]] | None = None,
|
|
101
117
|
compact: bool = False,
|
|
118
|
+
ref_lines: list[dict[str, Any]] | None = None,
|
|
119
|
+
show_means: bool = False,
|
|
120
|
+
means_window: tuple[Any, Any] | None = None,
|
|
102
121
|
) -> None:
|
|
103
122
|
if out_path.suffix != ".png":
|
|
104
123
|
raise ValueError(f"out_path must be .png: {out_path}")
|
|
@@ -121,6 +140,9 @@ class LinePlot:
|
|
|
121
140
|
self.plot_size = plot_size
|
|
122
141
|
self.vlines = vlines or []
|
|
123
142
|
self.compact = compact
|
|
143
|
+
self.ref_lines = ref_lines or []
|
|
144
|
+
self.show_means = show_means
|
|
145
|
+
self.means_window = means_window
|
|
124
146
|
|
|
125
147
|
@classmethod
|
|
126
148
|
def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
|
|
@@ -143,7 +165,8 @@ class LinePlot:
|
|
|
143
165
|
``base_100_date``, ``annotate``, ``annotate_color``,
|
|
144
166
|
``baseline``, ``fmt`` (``!format``), ``legend``
|
|
145
167
|
(``!legend``), ``figsize``, ``series_styles``,
|
|
146
|
-
``plot_size``, ``vlines``, ``compact
|
|
168
|
+
``plot_size``, ``vlines``, ``compact``, ``ref_lines``,
|
|
169
|
+
``show_means``, ``means_window``.
|
|
147
170
|
|
|
148
171
|
Example
|
|
149
172
|
-------
|
|
@@ -261,6 +284,9 @@ class LinePlot:
|
|
|
261
284
|
if self.compact
|
|
262
285
|
else 1.6 * plt.rcParams["font.size"] * fig.dpi / 72.0
|
|
263
286
|
),
|
|
287
|
+
headroom_pad_pt=(
|
|
288
|
+
compact_headroom_pad_pt() if self.compact else 0.0
|
|
289
|
+
),
|
|
264
290
|
)
|
|
265
291
|
|
|
266
292
|
style_spines(
|
|
@@ -273,6 +299,48 @@ class LinePlot:
|
|
|
273
299
|
reference = 100 if self.base_100 else 0
|
|
274
300
|
style_baseline(ax, reference, **AX_CONFIG["baseline"])
|
|
275
301
|
|
|
302
|
+
# arbitrary horizontal reference lines (e.g. a PER long-run mean, a
|
|
303
|
+
# volatility threshold). Dashed and in front of the data by default;
|
|
304
|
+
# only those flagged in_legend with a label enter the legend -- they
|
|
305
|
+
# are picked up automatically by get_legend_handles_labels() below.
|
|
306
|
+
for ref_line in self.ref_lines:
|
|
307
|
+
ref_label = ref_line.get("label")
|
|
308
|
+
in_legend = bool(ref_line.get("in_legend"))
|
|
309
|
+
ax.axhline( # type: ignore[reportUnknownMemberType]
|
|
310
|
+
ref_line["y"],
|
|
311
|
+
color=ref_line.get("color", AX_CONFIG["baseline"]["color"]),
|
|
312
|
+
linestyle=ref_line.get("linestyle", "--"),
|
|
313
|
+
linewidth=ref_line.get("linewidth", 0.9),
|
|
314
|
+
zorder=2.5,
|
|
315
|
+
label=ref_label if (in_legend and ref_label) else None,
|
|
316
|
+
)
|
|
317
|
+
lo, hi = ax.get_ylim()
|
|
318
|
+
ax.set_ylim(min(ref_line["y"], lo), max(ref_line["y"], hi))
|
|
319
|
+
|
|
320
|
+
# per-series historical mean as a dotted same-colour line (the old
|
|
321
|
+
# `medias=True`): the mean is taken over means_window (or the whole
|
|
322
|
+
# series) and scaled to match the plotted axis. Not meaningful with
|
|
323
|
+
# base_100, where the rebased axis no longer matches the raw mean.
|
|
324
|
+
if self.show_means:
|
|
325
|
+
mean_lines = {str(line.get_label()): line for line in ax.lines}
|
|
326
|
+
for col in plot_data.columns:
|
|
327
|
+
window: Any = (
|
|
328
|
+
self.data[col]
|
|
329
|
+
if self.means_window is None
|
|
330
|
+
else self.data.loc[slice(*self.means_window), col]
|
|
331
|
+
)
|
|
332
|
+
mean_val = float(window.dropna().mean()) * self.scale
|
|
333
|
+
line = mean_lines.get(self.series[col])
|
|
334
|
+
color = line.get_color() if line is not None else "black"
|
|
335
|
+
ax.axhline( # type: ignore[reportUnknownMemberType]
|
|
336
|
+
mean_val,
|
|
337
|
+
color=color,
|
|
338
|
+
linestyle=":",
|
|
339
|
+
linewidth=0.8,
|
|
340
|
+
alpha=0.55,
|
|
341
|
+
zorder=1,
|
|
342
|
+
)
|
|
343
|
+
|
|
276
344
|
if self.compact:
|
|
277
345
|
# pin the date ticks to the data span (after annotate widened the
|
|
278
346
|
# x-limit) so the last tick sits next to the final data point and
|
|
@@ -283,33 +351,45 @@ class LinePlot:
|
|
|
283
351
|
# spines. Normal mode keeps the house right-hand y-axis and box.
|
|
284
352
|
ax.yaxis.tick_left()
|
|
285
353
|
declutter_spines(ax)
|
|
354
|
+
# denser y-numbers (e.g. every 20 instead of every 50) so the
|
|
355
|
+
# small chart still has enough reference gridlines.
|
|
356
|
+
compact_y_axis(ax)
|
|
286
357
|
if font_pt is not None:
|
|
287
358
|
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
288
359
|
axis="both", labelsize=font_pt
|
|
289
360
|
)
|
|
290
361
|
|
|
291
362
|
if self.legend is not None:
|
|
292
|
-
|
|
363
|
+
# measure and draw from the actual labelled artists, so any
|
|
364
|
+
# in-legend reference lines are counted in the column fit too.
|
|
293
365
|
handles, label_strs = ax.get_legend_handles_labels()
|
|
294
366
|
if self.compact:
|
|
295
|
-
# legend ABOVE the plot,
|
|
296
|
-
# auto-fitted to
|
|
297
|
-
#
|
|
298
|
-
#
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
367
|
+
# legend in a band ABOVE the plot, spanning the FIGURE width.
|
|
368
|
+
# ncol is auto-fitted to that full width (not the narrow axes)
|
|
369
|
+
# with the compact legend metrics (small font, short handles)
|
|
370
|
+
# so the maximum number of labels share a row -- fewer rows
|
|
371
|
+
# means the legend steals less height from the chart. Then
|
|
372
|
+
# hard-capped by legend_max_ncol. Override via !legend {ncol}.
|
|
373
|
+
legend_kw = compact_legend_kwargs()
|
|
374
|
+
if self.legend.ncol is not None:
|
|
375
|
+
ncol = self.legend.ncol
|
|
376
|
+
else:
|
|
377
|
+
fig_w_px: float = fig.get_size_inches()[0] * fig.dpi
|
|
378
|
+
ncol = auto_ncol(
|
|
379
|
+
ax, label_strs, available_width_px=fig_w_px, **legend_kw
|
|
380
|
+
)
|
|
381
|
+
max_ncol = compact_legend_max_ncol()
|
|
382
|
+
if max_ncol is not None:
|
|
383
|
+
ncol = min(ncol, max_ncol)
|
|
384
|
+
legend_above(ax, handles, label_strs, ncol=ncol, **legend_kw)
|
|
307
385
|
else:
|
|
308
386
|
fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
|
|
309
387
|
ncol = (
|
|
310
388
|
self.legend.ncol
|
|
311
389
|
if self.legend.ncol is not None
|
|
312
|
-
else auto_ncol(
|
|
390
|
+
else auto_ncol(
|
|
391
|
+
ax, label_strs, available_width_px=fig_width_px
|
|
392
|
+
)
|
|
313
393
|
)
|
|
314
394
|
fig.legend( # type: ignore[reportUnknownMemberType]
|
|
315
395
|
handles,
|
|
@@ -335,17 +415,16 @@ class LinePlot:
|
|
|
335
415
|
"""Persist *fig* as a PNG and return the path written.
|
|
336
416
|
|
|
337
417
|
Defaults to ``self.out_path``; pass ``path`` to
|
|
338
|
-
redirect
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
418
|
+
redirect. ``dpi`` overrides the save resolution;
|
|
419
|
+
when omitted it is derived from the figure size via
|
|
420
|
+
:func:`dynamic_dpi` (size-adapted, never below the
|
|
421
|
+
sharpness floor) instead of the high layout DPI, so
|
|
422
|
+
large figures do not balloon the PNG.
|
|
342
423
|
"""
|
|
343
424
|
target: Path = path if path is not None else self.out_path
|
|
344
|
-
|
|
345
|
-
if dpi is not None:
|
|
346
|
-
save_kwargs["dpi"] = dpi
|
|
425
|
+
save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
|
|
347
426
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
348
|
-
target,
|
|
427
|
+
target, dpi=save_dpi
|
|
349
428
|
)
|
|
350
429
|
return target
|
|
351
430
|
|
|
@@ -39,7 +39,12 @@ from matplotlib.figure import Figure
|
|
|
39
39
|
from matplotlib.patches import Rectangle
|
|
40
40
|
from yaml.nodes import MappingNode
|
|
41
41
|
|
|
42
|
-
from tesorotools.artists._common import
|
|
42
|
+
from tesorotools.artists._common import (
|
|
43
|
+
FIG_CONFIG,
|
|
44
|
+
Format,
|
|
45
|
+
dynamic_dpi,
|
|
46
|
+
resolve_data,
|
|
47
|
+
)
|
|
43
48
|
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
44
49
|
from tesorotools.utils.template import TemplateLoader
|
|
45
50
|
|
|
@@ -246,7 +251,7 @@ class MatrixChart:
|
|
|
246
251
|
|
|
247
252
|
self._style_axes(ax, row_ids, col_ids)
|
|
248
253
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
249
|
-
self.out_path
|
|
254
|
+
self.out_path, dpi=dynamic_dpi(fig)
|
|
250
255
|
)
|
|
251
256
|
plt.close(fig)
|
|
252
257
|
return ax
|
|
@@ -18,6 +18,7 @@ from tesorotools.artists._common import (
|
|
|
18
18
|
resolve_data,
|
|
19
19
|
adjust_figure_for_plot_size,
|
|
20
20
|
auto_ncol,
|
|
21
|
+
dynamic_dpi,
|
|
21
22
|
style_baseline,
|
|
22
23
|
style_spines,
|
|
23
24
|
)
|
|
@@ -170,7 +171,7 @@ class StackedAreaPlot:
|
|
|
170
171
|
adjust_figure_for_plot_size(fig, ax, self.plot_size)
|
|
171
172
|
|
|
172
173
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
173
|
-
self.out_path
|
|
174
|
+
self.out_path, dpi=dynamic_dpi(fig)
|
|
174
175
|
)
|
|
175
176
|
plt.close(fig)
|
|
176
177
|
return ax
|
|
@@ -415,7 +416,7 @@ class StackedBarPlot:
|
|
|
415
416
|
adjust_figure_for_plot_size(fig, ax, self.plot_size)
|
|
416
417
|
|
|
417
418
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
418
|
-
self.out_path
|
|
419
|
+
self.out_path, dpi=dynamic_dpi(fig)
|
|
419
420
|
)
|
|
420
421
|
plt.close(fig)
|
|
421
422
|
return ax
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/type_curve.py
RENAMED
|
@@ -16,6 +16,7 @@ from tesorotools.artists._common import (
|
|
|
16
16
|
Format,
|
|
17
17
|
Legend,
|
|
18
18
|
resolve_data,
|
|
19
|
+
dynamic_dpi,
|
|
19
20
|
style_baseline,
|
|
20
21
|
style_spines,
|
|
21
22
|
)
|
|
@@ -259,7 +260,7 @@ class TypeCurve:
|
|
|
259
260
|
ncol=ncol,
|
|
260
261
|
)
|
|
261
262
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
262
|
-
self.out_path
|
|
263
|
+
self.out_path, dpi=dynamic_dpi(fig)
|
|
263
264
|
)
|
|
264
265
|
plt.close(fig)
|
|
265
266
|
return ax
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/vector_plot.py
RENAMED
|
@@ -39,6 +39,7 @@ from tesorotools.artists._common import (
|
|
|
39
39
|
AX_CONFIG,
|
|
40
40
|
FIG_CONFIG,
|
|
41
41
|
Format,
|
|
42
|
+
dynamic_dpi,
|
|
42
43
|
resolve_data,
|
|
43
44
|
)
|
|
44
45
|
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
@@ -260,7 +261,7 @@ class VectorPlot:
|
|
|
260
261
|
|
|
261
262
|
self._style_axes(ax, xs, ys)
|
|
262
263
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
263
|
-
self.out_path
|
|
264
|
+
self.out_path, dpi=dynamic_dpi(fig)
|
|
264
265
|
)
|
|
265
266
|
plt.close(fig)
|
|
266
267
|
return ax
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/waterfall.py
RENAMED
|
@@ -35,6 +35,7 @@ from tesorotools.artists._common import (
|
|
|
35
35
|
compact_figsize,
|
|
36
36
|
compact_font_pt,
|
|
37
37
|
declutter_spines,
|
|
38
|
+
dynamic_dpi,
|
|
38
39
|
resolve_data,
|
|
39
40
|
style_spines,
|
|
40
41
|
)
|
|
@@ -304,13 +305,16 @@ class Waterfall:
|
|
|
304
305
|
path: Path | None = None,
|
|
305
306
|
dpi: int | None = None,
|
|
306
307
|
) -> Path:
|
|
307
|
-
"""Persist *fig* as a PNG and return the path written.
|
|
308
|
+
"""Persist *fig* as a PNG and return the path written.
|
|
309
|
+
|
|
310
|
+
``dpi`` overrides the save resolution; when omitted it is
|
|
311
|
+
derived from the figure size via :func:`dynamic_dpi`
|
|
312
|
+
(size-adapted, never below the sharpness floor).
|
|
313
|
+
"""
|
|
308
314
|
target: Path = path if path is not None else self.out_path
|
|
309
|
-
|
|
310
|
-
if dpi is not None:
|
|
311
|
-
save_kwargs["dpi"] = dpi
|
|
315
|
+
save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
|
|
312
316
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
313
|
-
target,
|
|
317
|
+
target, dpi=save_dpi
|
|
314
318
|
)
|
|
315
319
|
return target
|
|
316
320
|
|
|
@@ -4,9 +4,18 @@ style:
|
|
|
4
4
|
font: Cabinet Grotesk
|
|
5
5
|
|
|
6
6
|
figure:
|
|
7
|
-
dpi: 500
|
|
7
|
+
dpi: 500 # DPI de LAYOUT (medidas, px de fuente, ancho de leyenda)
|
|
8
8
|
layout: constrained
|
|
9
9
|
|
|
10
|
+
# DPI de GUARDADO dinámico: se adapta al tamaño físico de la figura para no
|
|
11
|
+
# malgastar memoria. El lado largo se acota a ~target_px (las figuras grandes
|
|
12
|
+
# se guardan con menos dpi -> ficheros más ligeros), pero nunca por debajo de
|
|
13
|
+
# min_dpi (nitidez de impresión: jamás pixelado) ni por encima del dpi de
|
|
14
|
+
# layout (más píxeles serían desperdicio).
|
|
15
|
+
save:
|
|
16
|
+
target_px: 2000
|
|
17
|
+
min_dpi: 300
|
|
18
|
+
|
|
10
19
|
ax:
|
|
11
20
|
spines:
|
|
12
21
|
color: gray
|
|
@@ -75,17 +84,26 @@ line:
|
|
|
75
84
|
|
|
76
85
|
# Modo "compact": gráfico pensado para ir dentro de una tabla del Word,
|
|
77
86
|
# dos por fila, a tamaño físico real (sin que Word lo reduzca). El ancho
|
|
78
|
-
# es TOTAL e INCLUYE la leyenda, que se coloca
|
|
79
|
-
#
|
|
87
|
+
# es TOTAL e INCLUYE la leyenda, que se coloca en una banda ENCIMA que
|
|
88
|
+
# abarca el ancho COMPLETO de la figura. Dimensiones en centímetros.
|
|
80
89
|
compact:
|
|
81
90
|
width_cm: 7.4 # ancho total de la figura (gráfico + leyenda)
|
|
82
91
|
height_cm: 5.5 # alto total de la figura
|
|
83
92
|
# Tamaño de letra del gráfico EN PUNTOS. Como la figura se renderiza a su
|
|
84
93
|
# tamaño físico real (7,4 cm) y se embebe sin reducir, estos puntos son los
|
|
85
94
|
# mismos que verá el Word: ponlo IGUAL al texto corrido del documento.
|
|
86
|
-
font_pt: 11
|
|
87
|
-
# Leyenda
|
|
88
|
-
|
|
95
|
+
font_pt: 11 # ticks + etiquetas de valor (tamaño de cuerpo)
|
|
96
|
+
# Leyenda en banda superior, a ancho de figura. ncol se calcula como el
|
|
97
|
+
# MÁXIMO de columnas que caben en ese ancho (menos filas = menos espacio
|
|
98
|
+
# robado al gráfico), topado por legend_max_ncol.
|
|
99
|
+
legend_font_pt: 9 # leyenda algo MENOR que el texto de cuerpo
|
|
100
|
+
legend_handlelength: 1.2 # línea de muestra corta: caben más etiquetas/fila
|
|
101
|
+
legend_handletextpad: 0.4 # hueco línea-texto en la leyenda
|
|
102
|
+
legend_columnspacing: 0.8 # hueco entre columnas de la leyenda
|
|
103
|
+
legend_max_ncol: 3 # tope duro de columnas de la leyenda
|
|
104
|
+
annotate_headroom_pad_pt: 4 # hueco por encima de la etiqueta de valor más alta
|
|
105
|
+
date_show_start: true # fija la fecha del primer dato como primer tick X
|
|
106
|
+
y_max_ticks: 10 # nº máx. de números en el eje Y (más referencias)
|
|
89
107
|
|
|
90
108
|
table:
|
|
91
109
|
style: Light Shading Accent 1
|
|
@@ -2,9 +2,10 @@ from typing import Any
|
|
|
2
2
|
|
|
3
3
|
import matplotlib
|
|
4
4
|
import matplotlib.font_manager
|
|
5
|
+
import matplotlib.style
|
|
5
6
|
|
|
6
7
|
from .config import read_config
|
|
7
|
-
from .globals import FONTS, PLOT_CONFIG_FILE
|
|
8
|
+
from .globals import FONTS, PLOT_CONFIG_FILE, STYLE_SHEET
|
|
8
9
|
|
|
9
10
|
# this should only be done once
|
|
10
11
|
PLOT_CONFIG: dict[str, Any] = read_config(PLOT_CONFIG_FILE)
|
|
@@ -17,6 +18,23 @@ def load_fonts() -> None:
|
|
|
17
18
|
matplotlib.rcParams["font.family"] = PLOT_CONFIG["style"]["font"]
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
def apply_house_style() -> None:
|
|
22
|
+
"""Apply the full tesorotools look: stylesheet first, then fonts.
|
|
23
|
+
|
|
24
|
+
``matplotlib.style.use`` loads the colour cycle (``C0``/``C1``/... =
|
|
25
|
+
the Tesoro palette), grid and tick sizes from ``tesoro.mplstyle``;
|
|
26
|
+
:func:`load_fonts` then registers the bundled OTFs and sets
|
|
27
|
+
``font.family``. Fonts are applied **last** so the registered family
|
|
28
|
+
always wins over any ``font.*`` key the stylesheet might carry.
|
|
29
|
+
|
|
30
|
+
Without this the artists only ran :func:`load_fonts`, so the palette
|
|
31
|
+
cycle was never activated and lines fell back to Matplotlib's default
|
|
32
|
+
blue/orange unless the caller set colours explicitly.
|
|
33
|
+
"""
|
|
34
|
+
matplotlib.style.use(str(STYLE_SHEET))
|
|
35
|
+
load_fonts()
|
|
36
|
+
|
|
37
|
+
|
|
20
38
|
# this is not really matplotlib specific, so it should be elsewhere
|
|
21
39
|
def format_annotation(value: float, decimals: int, units: str) -> str:
|
|
22
40
|
decimal_formatted: str = f"{value:_.{decimals}f}".replace(".", ",").replace(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/README.md
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/tesoro.mplstyle
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/data_sources/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/data_sources/debug.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/database/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/dependencies/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/dependencies/node.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/dependencies/resolution.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/diagnose.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/imf_irfcl.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/content.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/images.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/section.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/subtitle.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/table.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/text.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/title.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|