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 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
 
@@ -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
- * ``plot()`` -- renders the chart and writes the PNG; some
16
- classes also expose ``build()`` / ``save()`` for
17
- fine-grained control.
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 Artist, Format, Legend
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
  ]
@@ -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 AutoDateLocator, ConciseDateFormatter
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 three-piece surface:
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
- * ``plot() -> Axes`` -- render the chart, persist it
146
- to ``out_path``, and return the axes for inspection.
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 = ax.get_legend_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]
@@ -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** on a single
5
- shared y-axis. The canonical use is a secondary magnitude in bars (an
6
- earnings *surprise* in p.p.) under a primary series as the highlighted
7
- line (earnings *growth* in %), both read against the same 0 baseline.
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 one shared axis.
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 series is centred.
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
- ax.bar( # type: ignore[reportUnknownMemberType]
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
- ax.plot( # type: ignore[reportUnknownMemberType]
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
- style_baseline(ax, 0, **AX_CONFIG["baseline"])
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="right")
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, ax, compact=self.compact, **legend_layout(self.legend)
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,