tesorotools-python 0.1.0__py3-none-any.whl → 0.1.3__py3-none-any.whl
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/__init__.py +2 -0
- tesorotools/artists/__init__.py +21 -4
- tesorotools/artists/_common.py +140 -7
- tesorotools/artists/bar_line.py +163 -14
- tesorotools/artists/barh_plot.py +46 -16
- tesorotools/artists/box_plot.py +16 -1
- tesorotools/artists/line_plot.py +55 -2
- tesorotools/artists/matrix.py +19 -9
- tesorotools/artists/plotly_backend.py +37 -8
- tesorotools/artists/sharing.py +59 -0
- tesorotools/artists/shock_plot.py +13 -1
- tesorotools/artists/stacked.py +47 -16
- tesorotools/artists/type_curve.py +21 -9
- tesorotools/artists/vector_plot.py +20 -9
- tesorotools/artists/waterfall.py +13 -1
- {tesorotools_python-0.1.0.dist-info → tesorotools_python-0.1.3.dist-info}/METADATA +1 -1
- {tesorotools_python-0.1.0.dist-info → tesorotools_python-0.1.3.dist-info}/RECORD +19 -18
- {tesorotools_python-0.1.0.dist-info → tesorotools_python-0.1.3.dist-info}/WHEEL +0 -0
- {tesorotools_python-0.1.0.dist-info → tesorotools_python-0.1.3.dist-info}/entry_points.txt +0 -0
tesorotools/__init__.py
CHANGED
|
@@ -72,6 +72,7 @@ from tesorotools.artists import (
|
|
|
72
72
|
TypeCurve,
|
|
73
73
|
VectorPlot,
|
|
74
74
|
Waterfall,
|
|
75
|
+
share_y_axes,
|
|
75
76
|
)
|
|
76
77
|
from tesorotools.orchestration import CompositeRegistry, iter_contexts
|
|
77
78
|
from tesorotools.providers.base import (
|
|
@@ -177,6 +178,7 @@ __all__ = [
|
|
|
177
178
|
"register_providers",
|
|
178
179
|
"register_tag",
|
|
179
180
|
"register_tags",
|
|
181
|
+
"share_y_axes",
|
|
180
182
|
]
|
|
181
183
|
|
|
182
184
|
|
tesorotools/artists/__init__.py
CHANGED
|
@@ -12,9 +12,16 @@ Each class follows the same shape:
|
|
|
12
12
|
``data_path`` (``.feather`` on disk), never both.
|
|
13
13
|
* ``from_yaml(loader, node)`` -- builds the same instance
|
|
14
14
|
from a ``!{name}`` YAML tag.
|
|
15
|
-
* ``
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
* ``build()`` -- renders the chart in memory and returns
|
|
16
|
+
``(Figure, Axes)``; ``save(fig, path=..., dpi=...)``
|
|
17
|
+
writes it; ``plot()`` does both and closes the figure.
|
|
18
|
+
Every artist exposes the three (see :class:`Artist`).
|
|
19
|
+
* ``to_plotly()`` -- the same chart as an interactive
|
|
20
|
+
Plotly figure (all but ``IntradayPlot``);
|
|
21
|
+
:func:`plotly_unsupported` lists the keys it drops.
|
|
22
|
+
|
|
23
|
+
:func:`share_y_axes` gives the charts that declare the same
|
|
24
|
+
``y_group`` one common y scale.
|
|
18
25
|
|
|
19
26
|
Shared layout, annotation and styling helpers (plus the
|
|
20
27
|
``Format`` and ``Legend`` config holders) live in
|
|
@@ -25,13 +32,20 @@ live under :mod:`tesorotools.render`; this package is
|
|
|
25
32
|
strictly image output.
|
|
26
33
|
"""
|
|
27
34
|
|
|
28
|
-
from tesorotools.artists._common import
|
|
35
|
+
from tesorotools.artists._common import (
|
|
36
|
+
Artist,
|
|
37
|
+
FigureLifecycle,
|
|
38
|
+
Format,
|
|
39
|
+
Legend,
|
|
40
|
+
plotly_unsupported,
|
|
41
|
+
)
|
|
29
42
|
from tesorotools.artists.bar_line import BarLinePlot
|
|
30
43
|
from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
|
|
31
44
|
from tesorotools.artists.box_plot import BoxPlot
|
|
32
45
|
from tesorotools.artists.intraday_plot import IntradayPlot
|
|
33
46
|
from tesorotools.artists.line_plot import LinePlot
|
|
34
47
|
from tesorotools.artists.matrix import MatrixChart
|
|
48
|
+
from tesorotools.artists.sharing import share_y_axes
|
|
35
49
|
from tesorotools.artists.shock_plot import ShockChart
|
|
36
50
|
from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
|
|
37
51
|
from tesorotools.artists.type_curve import TypeCurve
|
|
@@ -42,6 +56,7 @@ __all__ = [
|
|
|
42
56
|
"Artist",
|
|
43
57
|
"BarLinePlot",
|
|
44
58
|
"BoxPlot",
|
|
59
|
+
"FigureLifecycle",
|
|
45
60
|
"Format",
|
|
46
61
|
"GroupedBarChart",
|
|
47
62
|
"HorizontalBarChart",
|
|
@@ -55,4 +70,6 @@ __all__ = [
|
|
|
55
70
|
"TypeCurve",
|
|
56
71
|
"VectorPlot",
|
|
57
72
|
"Waterfall",
|
|
73
|
+
"plotly_unsupported",
|
|
74
|
+
"share_y_axes",
|
|
58
75
|
]
|
tesorotools/artists/_common.py
CHANGED
|
@@ -23,13 +23,18 @@ overrides only need to update ``plots.yaml``.
|
|
|
23
23
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
|
+
from collections.abc import Sequence
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
from typing import Any, Literal, Protocol, Self, cast, runtime_checkable
|
|
28
29
|
|
|
29
30
|
import matplotlib.pyplot as plt
|
|
30
31
|
import pandas as pd
|
|
31
32
|
from matplotlib.axes import Axes
|
|
32
|
-
from matplotlib.dates import
|
|
33
|
+
from matplotlib.dates import (
|
|
34
|
+
AutoDateLocator,
|
|
35
|
+
ConciseDateFormatter,
|
|
36
|
+
YearLocator,
|
|
37
|
+
)
|
|
33
38
|
from matplotlib.dates import (
|
|
34
39
|
date2num, # type: ignore[reportUnknownVariableType]
|
|
35
40
|
)
|
|
@@ -126,6 +131,70 @@ def dynamic_dpi(fig: Figure) -> int:
|
|
|
126
131
|
return int(round(min(max_dpi, max(min_dpi, target_px / long_in))))
|
|
127
132
|
|
|
128
133
|
|
|
134
|
+
def plotly_unsupported(artist: object) -> frozenset[str]:
|
|
135
|
+
"""Configuration keys *artist* accepts but ``to_plotly()`` drops.
|
|
136
|
+
|
|
137
|
+
*artist* is an artist class or instance. The keys change the PNG
|
|
138
|
+
but not the interactive figure; nested ones are dotted
|
|
139
|
+
(``"legend.ncol"``, ``"fmt.signed"``). A UI built on top can tell
|
|
140
|
+
the user why moving such a control leaves the figure as it is.
|
|
141
|
+
Needs no ``plotly``: it reads the class attribute
|
|
142
|
+
``PLOTLY_UNSUPPORTED``, which the test suite checks against real
|
|
143
|
+
renders.
|
|
144
|
+
"""
|
|
145
|
+
cls = artist if isinstance(artist, type) else type(artist)
|
|
146
|
+
if not hasattr(cls, "to_plotly"):
|
|
147
|
+
raise TypeError(f"{cls.__name__} has no to_plotly()")
|
|
148
|
+
declared: frozenset[str] | None = getattr(cls, "PLOTLY_UNSUPPORTED", None)
|
|
149
|
+
if declared is None:
|
|
150
|
+
raise TypeError(f"{cls.__name__} does not declare PLOTLY_UNSUPPORTED")
|
|
151
|
+
return frozenset(declared)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class FigureLifecycle:
|
|
155
|
+
"""``save()`` and ``plot()`` on top of a subclass's ``build()``.
|
|
156
|
+
|
|
157
|
+
Gives an artist the same three-step life cycle as the rest:
|
|
158
|
+
``build()`` returns the figure in memory, ``save()`` writes it
|
|
159
|
+
and ``plot()`` does both and closes the figure. A subclass only
|
|
160
|
+
implements ``build()``.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
out_path: Path
|
|
164
|
+
|
|
165
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
166
|
+
raise NotImplementedError
|
|
167
|
+
|
|
168
|
+
def save(
|
|
169
|
+
self,
|
|
170
|
+
fig: Figure,
|
|
171
|
+
*,
|
|
172
|
+
path: Path | None = None,
|
|
173
|
+
dpi: int | None = None,
|
|
174
|
+
) -> Path:
|
|
175
|
+
"""Persist *fig* as a PNG and return the path written.
|
|
176
|
+
|
|
177
|
+
Defaults to ``self.out_path``; pass ``path`` to redirect.
|
|
178
|
+
``dpi`` overrides the save resolution; when omitted it is
|
|
179
|
+
derived from the figure size via :func:`dynamic_dpi`.
|
|
180
|
+
"""
|
|
181
|
+
target: Path = path if path is not None else self.out_path
|
|
182
|
+
save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
|
|
183
|
+
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
184
|
+
target, dpi=save_dpi
|
|
185
|
+
)
|
|
186
|
+
return target
|
|
187
|
+
|
|
188
|
+
def plot(self) -> Axes:
|
|
189
|
+
"""Build the chart, persist it to ``self.out_path`` and close it."""
|
|
190
|
+
fig, ax = self.build()
|
|
191
|
+
try:
|
|
192
|
+
self.save(fig)
|
|
193
|
+
finally:
|
|
194
|
+
plt.close(fig)
|
|
195
|
+
return ax
|
|
196
|
+
|
|
197
|
+
|
|
129
198
|
# ----------------------------------------------------------------------
|
|
130
199
|
# Structural contract for image artists
|
|
131
200
|
# ----------------------------------------------------------------------
|
|
@@ -137,13 +206,21 @@ class Artist(Protocol):
|
|
|
137
206
|
|
|
138
207
|
The built-in artists and any third-party artist
|
|
139
208
|
registered via :func:`tesorotools.register_artist`
|
|
140
|
-
expose the same
|
|
209
|
+
expose the same surface:
|
|
141
210
|
|
|
142
211
|
* ``out_path: Path`` -- the ``.png`` destination.
|
|
143
212
|
* ``from_yaml(loader, node) -> Self`` -- the YAML
|
|
144
213
|
constructor bound to the ``!{name}`` tag.
|
|
145
|
-
* ``
|
|
146
|
-
|
|
214
|
+
* ``build() -> (Figure, Axes)`` -- render the chart in
|
|
215
|
+
memory, without touching the disk.
|
|
216
|
+
* ``save(fig, *, path=None, dpi=None) -> Path`` -- write
|
|
217
|
+
a built figure (to ``out_path`` unless ``path`` says
|
|
218
|
+
otherwise).
|
|
219
|
+
* ``plot() -> Axes`` -- ``build()`` + ``save()``, close
|
|
220
|
+
the figure and return the axes for inspection.
|
|
221
|
+
|
|
222
|
+
:class:`FigureLifecycle` provides ``save()`` and ``plot()``
|
|
223
|
+
for an artist that only implements ``build()``.
|
|
147
224
|
|
|
148
225
|
The Protocol is ``runtime_checkable`` so consumers can
|
|
149
226
|
use ``isinstance(obj, Artist)`` to validate a candidate
|
|
@@ -156,6 +233,16 @@ class Artist(Protocol):
|
|
|
156
233
|
@classmethod
|
|
157
234
|
def from_yaml(cls, loader: Any, node: MappingNode) -> Self: ...
|
|
158
235
|
|
|
236
|
+
def build(self) -> tuple[Figure, Axes]: ...
|
|
237
|
+
|
|
238
|
+
def save(
|
|
239
|
+
self,
|
|
240
|
+
fig: Figure,
|
|
241
|
+
*,
|
|
242
|
+
path: Path | None = None,
|
|
243
|
+
dpi: int | None = None,
|
|
244
|
+
) -> Path: ...
|
|
245
|
+
|
|
159
246
|
def plot(self) -> Axes: ...
|
|
160
247
|
|
|
161
248
|
|
|
@@ -580,6 +667,12 @@ def date_span_ticks(
|
|
|
580
667
|
10 % of the span is dropped so the two labels do not collide. Without
|
|
581
668
|
it the plain in-span candidates are returned (falling back to the two
|
|
582
669
|
endpoints when the locator offers nothing inside).
|
|
670
|
+
|
|
671
|
+
When the locator steps by several years, the pinned grid is anchored to
|
|
672
|
+
the **start year** instead of to multiples of the step: a 2-year step
|
|
673
|
+
over 2023-2027 gives 2023, 2025, 2027 rather than the pinned 2023 plus
|
|
674
|
+
the calendar 2024, 2026, whose uneven gaps (and missing odd years) read
|
|
675
|
+
as if the axis skipped a year.
|
|
583
676
|
"""
|
|
584
677
|
lo: float = float(cast(Any, date2num(data_min)))
|
|
585
678
|
hi: float = float(cast(Any, date2num(data_max)))
|
|
@@ -589,6 +682,18 @@ def date_span_ticks(
|
|
|
589
682
|
)
|
|
590
683
|
if not pin_start:
|
|
591
684
|
return [t for t in candidates if lo <= t <= hi] or [lo, hi]
|
|
685
|
+
chosen: Any = locator.get_locator( # type: ignore[reportUnknownMemberType]
|
|
686
|
+
data_min, data_max
|
|
687
|
+
)
|
|
688
|
+
if isinstance(chosen, YearLocator):
|
|
689
|
+
step = int(cast(Any, chosen.base).step)
|
|
690
|
+
if step > 1:
|
|
691
|
+
first_year: int = num2date(lo).year
|
|
692
|
+
last_year: int = num2date(hi).year
|
|
693
|
+
candidates = [
|
|
694
|
+
float(cast(Any, date2num(pd.Timestamp(year=y, month=1, day=1))))
|
|
695
|
+
for y in range(first_year, last_year + 1, step)
|
|
696
|
+
]
|
|
592
697
|
min_gap: float = (hi - lo) * 0.10
|
|
593
698
|
interior: list[float] = [
|
|
594
699
|
t for t in candidates if lo < t <= hi and t - lo >= min_gap
|
|
@@ -1069,6 +1174,7 @@ def place_legend(
|
|
|
1069
1174
|
compact: bool,
|
|
1070
1175
|
ncol: int | None = None,
|
|
1071
1176
|
position: str = "auto",
|
|
1177
|
+
entries: tuple[list[Any], list[str]] | None = None,
|
|
1072
1178
|
) -> MplLegend | None:
|
|
1073
1179
|
"""Place the figure legend, compact-aware, from the labelled artists.
|
|
1074
1180
|
|
|
@@ -1079,9 +1185,14 @@ def place_legend(
|
|
|
1079
1185
|
``rcParams`` metrics. ``position`` (``"above"`` / ``"below"`` /
|
|
1080
1186
|
``"auto"``) chooses the side through :func:`resolve_legend_position`, so
|
|
1081
1187
|
a compact chart can carry its legend under the plot instead of over it.
|
|
1188
|
+
``entries`` passes precomputed ``(handles, labels)`` instead of reading
|
|
1189
|
+
them off *ax* -- for charts whose series span several axes (a
|
|
1190
|
+
``twinx`` secondary y-axis), in the order the legend should list them.
|
|
1082
1191
|
Returns ``None`` (drawing nothing) when there are no labels.
|
|
1083
1192
|
"""
|
|
1084
|
-
handles, labels =
|
|
1193
|
+
handles, labels = (
|
|
1194
|
+
entries if entries is not None else ax.get_legend_handles_labels()
|
|
1195
|
+
)
|
|
1085
1196
|
if not labels:
|
|
1086
1197
|
return None
|
|
1087
1198
|
side = resolve_legend_position(position, compact=compact)
|
|
@@ -1195,6 +1306,26 @@ def style_spines(
|
|
|
1195
1306
|
)
|
|
1196
1307
|
|
|
1197
1308
|
|
|
1309
|
+
def check_y_limits(
|
|
1310
|
+
limits: Sequence[float] | None,
|
|
1311
|
+
) -> tuple[float, float] | None:
|
|
1312
|
+
"""Validate a ``y_limits`` pair and return it as ``(bottom, top)``.
|
|
1313
|
+
|
|
1314
|
+
YAML hands the pair over as a list, so any two-item sequence is
|
|
1315
|
+
accepted; ``None`` (no fixed range) passes through. Raises
|
|
1316
|
+
``ValueError`` unless there are exactly two numbers with
|
|
1317
|
+
``bottom < top``.
|
|
1318
|
+
"""
|
|
1319
|
+
if limits is None:
|
|
1320
|
+
return None
|
|
1321
|
+
if len(limits) != 2:
|
|
1322
|
+
raise ValueError(f"y_limits must be (bottom, top): {limits!r}")
|
|
1323
|
+
bottom, top = float(limits[0]), float(limits[1])
|
|
1324
|
+
if not bottom < top:
|
|
1325
|
+
raise ValueError(f"y_limits bottom must be below top: {limits!r}")
|
|
1326
|
+
return bottom, top
|
|
1327
|
+
|
|
1328
|
+
|
|
1198
1329
|
def style_baseline(
|
|
1199
1330
|
ax: Axes,
|
|
1200
1331
|
reference: float = 0,
|
|
@@ -1235,6 +1366,7 @@ def annotate_last_values(
|
|
|
1235
1366
|
fontsize: float | None = None,
|
|
1236
1367
|
right_pad_px: float = 10.0,
|
|
1237
1368
|
headroom_pad_pt: float = 0.0,
|
|
1369
|
+
grow_top: bool = True,
|
|
1238
1370
|
) -> None:
|
|
1239
1371
|
"""Label the last non-NaN value of each column on the right.
|
|
1240
1372
|
|
|
@@ -1261,7 +1393,8 @@ def annotate_last_values(
|
|
|
1261
1393
|
y-limit, that limit grows enough to keep this many points
|
|
1262
1394
|
free and the labels are re-packed at the new scale, so a
|
|
1263
1395
|
tall stack never spills into a legend above the axes (nor
|
|
1264
|
-
is clipped at the top).
|
|
1396
|
+
is clipped at the top). ``grow_top=False`` keeps the top
|
|
1397
|
+
y-limit as it is (a fixed or shared y range must not move).
|
|
1265
1398
|
"""
|
|
1266
1399
|
fig = ax.get_figure()
|
|
1267
1400
|
if fig is None:
|
|
@@ -1333,7 +1466,7 @@ def annotate_last_values(
|
|
|
1333
1466
|
pad_px = headroom_pad_pt * fig.dpi / 72.0
|
|
1334
1467
|
top_axes_px: float = trans.transform((0, ymax))[1] # type: ignore[reportUnknownArgumentType]
|
|
1335
1468
|
needed_px = placements[-1] + text_height / 2 + pad_px
|
|
1336
|
-
if needed_px > top_axes_px:
|
|
1469
|
+
if grow_top and needed_px > top_axes_px:
|
|
1337
1470
|
new_top: float = trans.inverted().transform( # type: ignore[reportUnknownArgumentType]
|
|
1338
1471
|
(0, needed_px)
|
|
1339
1472
|
)[1]
|
tesorotools/artists/bar_line.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"""Bar + line combo chart artist, registered as ``!bar_line``.
|
|
2
2
|
|
|
3
3
|
Defines :class:`BarLinePlot`: one time-series chart that draws some
|
|
4
|
-
series as **vertical bars** and others as **marked lines
|
|
5
|
-
shared y-axis. The canonical use is a secondary magnitude in
|
|
6
|
-
earnings *surprise* in p.p.) under a primary series as the
|
|
7
|
-
line (earnings *growth* in %), both read against the same 0
|
|
4
|
+
series as **vertical bars** and others as **marked lines**, by default
|
|
5
|
+
on a single shared y-axis. The canonical use is a secondary magnitude in
|
|
6
|
+
bars (an earnings *surprise* in p.p.) under a primary series as the
|
|
7
|
+
highlighted line (earnings *growth* in %), both read against the same 0
|
|
8
|
+
baseline. When the scales are too far apart to share an axis (an
|
|
9
|
+
exchange rate around 1.2 against an R^2 between 0 and 1), ``secondary``
|
|
10
|
+
moves any subset of the series, bars or lines, to a right-hand y-axis
|
|
11
|
+
with its own scale, format and title.
|
|
8
12
|
|
|
9
13
|
Unlike :class:`LinePlot`, the x-axis is **categorical**: one slot per
|
|
10
14
|
observation (``np.arange``) with date-formatted tick labels, so the bars
|
|
@@ -28,7 +32,7 @@ from __future__ import annotations
|
|
|
28
32
|
|
|
29
33
|
import locale
|
|
30
34
|
from pathlib import Path
|
|
31
|
-
from typing import Any, Self
|
|
35
|
+
from typing import Any, ClassVar, Self
|
|
32
36
|
|
|
33
37
|
import matplotlib.pyplot as plt
|
|
34
38
|
import numpy as np
|
|
@@ -63,7 +67,7 @@ BAR_LINE_CONFIG: dict[str, Any] = PLOT_CONFIG["bar_line"]
|
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
class BarLinePlot:
|
|
66
|
-
"""Combo chart: vertical bars and marked lines on
|
|
70
|
+
"""Combo chart: vertical bars and marked lines, optionally on two axes.
|
|
67
71
|
|
|
68
72
|
Parameters mirror the other time-series artists where they overlap
|
|
69
73
|
(``out_path``, ``data`` / ``data_path``, ``scale``, ``start_date`` /
|
|
@@ -87,8 +91,41 @@ class BarLinePlot:
|
|
|
87
91
|
``date_format`` is applied via ``strftime`` (lower-cased, e.g.
|
|
88
92
|
``"dic-21"``) to a DatetimeIndex; ``max_xticks`` thins the labels
|
|
89
93
|
(``None`` labels every slot).
|
|
94
|
+
* ``secondary`` -- ids (from ``bars`` and/or ``lines``) drawn against a
|
|
95
|
+
second y-axis on the right, with its own scale. Empty (the default)
|
|
96
|
+
keeps the single shared axis, ticked on the house side (right). With
|
|
97
|
+
a secondary axis the primary one moves to the left, its grid is the
|
|
98
|
+
only one drawn, and the legend still lists every series in declared
|
|
99
|
+
order (bars, then lines). It cannot hold every series: the primary
|
|
100
|
+
axis would be left empty.
|
|
101
|
+
* ``secondary_fmt`` -- tick format (``!format``) of the secondary axis;
|
|
102
|
+
defaults to ``fmt``.
|
|
103
|
+
* ``ylabel`` / ``secondary_ylabel`` -- y-axis titles (empty: none).
|
|
104
|
+
``ylabel`` sits on the primary axis' tick side.
|
|
105
|
+
|
|
106
|
+
``baseline`` draws the 0 line on every axis that holds bars (they grow
|
|
107
|
+
from it) or, with no bars at all, on the primary axis, so a lines-only
|
|
108
|
+
axis is never stretched down to 0.
|
|
90
109
|
"""
|
|
91
110
|
|
|
111
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
112
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
113
|
+
#: by ``tests/test_plotly_support.py``.
|
|
114
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
115
|
+
{
|
|
116
|
+
"bar_width",
|
|
117
|
+
"compact",
|
|
118
|
+
"figsize",
|
|
119
|
+
"fmt.decimals",
|
|
120
|
+
"font_pt",
|
|
121
|
+
"legend.ncol",
|
|
122
|
+
"max_xticks",
|
|
123
|
+
"plot_size",
|
|
124
|
+
"secondary_fmt.decimals",
|
|
125
|
+
"x_rotation",
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
92
129
|
def __init__(
|
|
93
130
|
self,
|
|
94
131
|
out_path: Path,
|
|
@@ -113,6 +150,10 @@ class BarLinePlot:
|
|
|
113
150
|
max_xticks: int | None = None,
|
|
114
151
|
compact: bool = False,
|
|
115
152
|
font_pt: float | None = None,
|
|
153
|
+
secondary: list[str] | None = None,
|
|
154
|
+
secondary_fmt: Format | None = None,
|
|
155
|
+
ylabel: str = "",
|
|
156
|
+
secondary_ylabel: str = "",
|
|
116
157
|
) -> None:
|
|
117
158
|
if out_path.suffix != ".png":
|
|
118
159
|
raise ValueError(f"out_path must be .png: {out_path}")
|
|
@@ -122,6 +163,17 @@ class BarLinePlot:
|
|
|
122
163
|
raise ValueError(
|
|
123
164
|
"BarLinePlot needs at least one series in `bars` or `lines`"
|
|
124
165
|
)
|
|
166
|
+
self.secondary = list(secondary or [])
|
|
167
|
+
unknown = [c for c in self.secondary if c not in self.bars | self.lines]
|
|
168
|
+
if unknown:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"`secondary` ids must be in `bars` or `lines`: {unknown}"
|
|
171
|
+
)
|
|
172
|
+
if set(self.secondary) >= {*self.bars, *self.lines}:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"`secondary` cannot hold every series: the primary axis "
|
|
175
|
+
"would be empty"
|
|
176
|
+
)
|
|
125
177
|
self.out_path = out_path
|
|
126
178
|
self.data = resolve_data(data, data_path)
|
|
127
179
|
self.scale = scale
|
|
@@ -140,6 +192,9 @@ class BarLinePlot:
|
|
|
140
192
|
self.max_xticks = max_xticks
|
|
141
193
|
self.compact = compact
|
|
142
194
|
self.font_pt = font_pt
|
|
195
|
+
self.secondary_fmt = secondary_fmt or self.fmt
|
|
196
|
+
self.ylabel = ylabel
|
|
197
|
+
self.secondary_ylabel = secondary_ylabel
|
|
143
198
|
|
|
144
199
|
@classmethod
|
|
145
200
|
def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
|
|
@@ -162,7 +217,8 @@ class BarLinePlot:
|
|
|
162
217
|
``fmt`` (``!format``), ``legend`` (``!legend``), ``figsize``,
|
|
163
218
|
``plot_size``, ``bar_styles`` / ``line_styles``, ``bar_width``,
|
|
164
219
|
``date_format``, ``x_rotation``, ``max_xticks``, ``compact``,
|
|
165
|
-
``font_pt
|
|
220
|
+
``font_pt``, ``secondary``, ``secondary_fmt`` (``!format``),
|
|
221
|
+
``ylabel`` / ``secondary_ylabel``.
|
|
166
222
|
|
|
167
223
|
Example
|
|
168
224
|
-------
|
|
@@ -177,6 +233,26 @@ class BarLinePlot:
|
|
|
177
233
|
surprise: "Sorpresa (pp)"
|
|
178
234
|
fmt: !format {decimals: 0}
|
|
179
235
|
legend: !legend {}
|
|
236
|
+
|
|
237
|
+
With scales too far apart for one axis, send some series to the
|
|
238
|
+
right-hand axis:
|
|
239
|
+
|
|
240
|
+
.. code-block:: yaml
|
|
241
|
+
|
|
242
|
+
eurusd_fair_value: !bar_line
|
|
243
|
+
out_path: out/eurusd_fair_value.png
|
|
244
|
+
data_path: data/eurusd.feather
|
|
245
|
+
bars:
|
|
246
|
+
r2: "R^2"
|
|
247
|
+
lines:
|
|
248
|
+
eurusd: "EURUSD"
|
|
249
|
+
fair_value: "Modelo valor justo (60 días)"
|
|
250
|
+
secondary: [r2]
|
|
251
|
+
fmt: !format {decimals: 2}
|
|
252
|
+
secondary_fmt: !format {decimals: 1}
|
|
253
|
+
ylabel: "EURUSD"
|
|
254
|
+
secondary_ylabel: "Coeficiente de determinación (R^2)"
|
|
255
|
+
legend: !legend {}
|
|
180
256
|
"""
|
|
181
257
|
cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
|
|
182
258
|
node, deep=True
|
|
@@ -270,9 +346,13 @@ class BarLinePlot:
|
|
|
270
346
|
**compact_figure_kwargs(self.compact, self.figsize)
|
|
271
347
|
)
|
|
272
348
|
ax: Axes = fig.add_subplot()
|
|
349
|
+
ax2: Axes | None = ax.twinx() if self.secondary else None
|
|
350
|
+
|
|
351
|
+
def target(col: str) -> Axes:
|
|
352
|
+
return ax2 if ax2 is not None and col in self.secondary else ax
|
|
273
353
|
|
|
274
354
|
# Bars first (behind the lines). Several bar series share each slot,
|
|
275
|
-
# dodged side by side; a single
|
|
355
|
+
# dodged side by side (across both axes); a single one is centred.
|
|
276
356
|
n_bars = len(self.bars)
|
|
277
357
|
each = self.bar_width / n_bars if n_bars else self.bar_width
|
|
278
358
|
default_alpha: float = float(BAR_LINE_CONFIG["bar"].get("alpha", 0.9))
|
|
@@ -282,7 +362,7 @@ class BarLinePlot:
|
|
|
282
362
|
style.setdefault("alpha", default_alpha)
|
|
283
363
|
offset = (j - (n_bars - 1) / 2) * each
|
|
284
364
|
values = plot_data[col].fillna(0).to_numpy(dtype=np.float64)
|
|
285
|
-
|
|
365
|
+
target(col).bar( # type: ignore[reportUnknownMemberType]
|
|
286
366
|
x + offset,
|
|
287
367
|
values,
|
|
288
368
|
width=each,
|
|
@@ -300,7 +380,7 @@ class BarLinePlot:
|
|
|
300
380
|
style.setdefault("marker", line_cfg.get("marker", "o"))
|
|
301
381
|
style.setdefault("linewidth", line_cfg.get("linewidth", 2))
|
|
302
382
|
style.setdefault("markersize", line_cfg.get("markersize", 5))
|
|
303
|
-
|
|
383
|
+
target(col).plot( # type: ignore[reportUnknownMemberType]
|
|
304
384
|
x,
|
|
305
385
|
plot_data[col].to_numpy(dtype=np.float64),
|
|
306
386
|
label=label,
|
|
@@ -319,14 +399,31 @@ class BarLinePlot:
|
|
|
319
399
|
# style_spines enables the y grid only (axis="y"), which is what these
|
|
320
400
|
# charts read against; the categorical x-axis stays gridless.
|
|
321
401
|
if self.baseline:
|
|
322
|
-
|
|
323
|
-
|
|
402
|
+
on_primary, on_secondary = self._baseline_on()
|
|
403
|
+
for axis, on in ((ax, on_primary), (ax2, on_secondary)):
|
|
404
|
+
if on and axis is not None:
|
|
405
|
+
style_baseline(axis, 0, **AX_CONFIG["baseline"])
|
|
406
|
+
|
|
407
|
+
side = "right"
|
|
408
|
+
if ax2 is not None:
|
|
409
|
+
side = "left"
|
|
410
|
+
ax.yaxis.tick_left()
|
|
411
|
+
self._style_secondary(ax, ax2, font_pt)
|
|
324
412
|
if self.compact:
|
|
325
|
-
style_compact_axes(ax, font_pt, side=
|
|
413
|
+
style_compact_axes(ax, font_pt, side=side)
|
|
414
|
+
if self.ylabel:
|
|
415
|
+
ax.yaxis.set_label_position(side) # type: ignore[reportArgumentType]
|
|
416
|
+
ax.set_ylabel( # type: ignore[reportUnknownMemberType]
|
|
417
|
+
self.ylabel, fontsize=font_pt
|
|
418
|
+
)
|
|
326
419
|
|
|
327
420
|
if self.legend is not None:
|
|
328
421
|
place_legend(
|
|
329
|
-
fig,
|
|
422
|
+
fig,
|
|
423
|
+
ax,
|
|
424
|
+
compact=self.compact,
|
|
425
|
+
entries=self._legend_entries(ax, ax2),
|
|
426
|
+
**legend_layout(self.legend),
|
|
330
427
|
)
|
|
331
428
|
|
|
332
429
|
if self.plot_size is not None and not self.compact:
|
|
@@ -334,6 +431,58 @@ class BarLinePlot:
|
|
|
334
431
|
|
|
335
432
|
return fig, ax
|
|
336
433
|
|
|
434
|
+
def _baseline_on(self) -> tuple[bool, bool]:
|
|
435
|
+
"""``(primary, secondary)``: the axes that get the 0 line.
|
|
436
|
+
|
|
437
|
+
Those holding bars (they grow from 0) or, with no bars at all, the
|
|
438
|
+
primary one. Shared by :meth:`build` and :meth:`to_plotly`.
|
|
439
|
+
"""
|
|
440
|
+
on_secondary = [c in self.secondary for c in self.bars]
|
|
441
|
+
secondary = any(on_secondary)
|
|
442
|
+
primary = not all(on_secondary) or not secondary
|
|
443
|
+
return primary, secondary
|
|
444
|
+
|
|
445
|
+
def _style_secondary(
|
|
446
|
+
self, ax: Axes, ax2: Axes, font_pt: float | None
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Style the right-hand axis: own format, no grid, title, z-order.
|
|
449
|
+
|
|
450
|
+
The twin is drawn above the primary by default, which would hide
|
|
451
|
+
the primary series behind secondary bars; when the secondary holds
|
|
452
|
+
bars the primary is brought forward with a transparent background.
|
|
453
|
+
"""
|
|
454
|
+
style_spines(
|
|
455
|
+
ax2,
|
|
456
|
+
decimals=self.secondary_fmt.decimals,
|
|
457
|
+
units=self.secondary_fmt.units,
|
|
458
|
+
**AX_CONFIG["spines"],
|
|
459
|
+
)
|
|
460
|
+
ax2.grid(False) # type: ignore[reportUnknownMemberType]
|
|
461
|
+
if self.compact:
|
|
462
|
+
style_compact_axes(ax2, font_pt, side="right")
|
|
463
|
+
if self.secondary_ylabel:
|
|
464
|
+
ax2.set_ylabel( # type: ignore[reportUnknownMemberType]
|
|
465
|
+
self.secondary_ylabel, fontsize=font_pt
|
|
466
|
+
)
|
|
467
|
+
if any(c in self.secondary for c in self.bars):
|
|
468
|
+
ax.set_zorder(ax2.get_zorder() + 1)
|
|
469
|
+
ax.patch.set_visible(False)
|
|
470
|
+
|
|
471
|
+
def _legend_entries(
|
|
472
|
+
self, ax: Axes, ax2: Axes | None
|
|
473
|
+
) -> tuple[list[Any], list[str]]:
|
|
474
|
+
"""Handles and labels of both axes, in declared order."""
|
|
475
|
+
handles, labels = ax.get_legend_handles_labels()
|
|
476
|
+
if ax2 is not None:
|
|
477
|
+
more_handles, more_labels = ax2.get_legend_handles_labels()
|
|
478
|
+
handles, labels = handles + more_handles, labels + more_labels
|
|
479
|
+
order = [*self.bars.values(), *self.lines.values()]
|
|
480
|
+
pairs = sorted(
|
|
481
|
+
zip(handles, labels, strict=True),
|
|
482
|
+
key=lambda pair: order.index(pair[1]),
|
|
483
|
+
)
|
|
484
|
+
return [h for h, _ in pairs], [label for _, label in pairs]
|
|
485
|
+
|
|
337
486
|
def save(
|
|
338
487
|
self,
|
|
339
488
|
fig: Figure,
|