tesorotools-python 0.1.1__py3-none-any.whl → 0.1.2__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
 
@@ -20,6 +20,9 @@ Each class follows the same shape:
20
20
  Plotly figure (all but ``IntradayPlot``);
21
21
  :func:`plotly_unsupported` lists the keys it drops.
22
22
 
23
+ :func:`share_y_axes` gives the charts that declare the same
24
+ ``y_group`` one common y scale.
25
+
23
26
  Shared layout, annotation and styling helpers (plus the
24
27
  ``Format`` and ``Legend`` config holders) live in
25
28
  :mod:`tesorotools.artists._common`.
@@ -42,6 +45,7 @@ from tesorotools.artists.box_plot import BoxPlot
42
45
  from tesorotools.artists.intraday_plot import IntradayPlot
43
46
  from tesorotools.artists.line_plot import LinePlot
44
47
  from tesorotools.artists.matrix import MatrixChart
48
+ from tesorotools.artists.sharing import share_y_axes
45
49
  from tesorotools.artists.shock_plot import ShockChart
46
50
  from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
47
51
  from tesorotools.artists.type_curve import TypeCurve
@@ -67,4 +71,5 @@ __all__ = [
67
71
  "VectorPlot",
68
72
  "Waterfall",
69
73
  "plotly_unsupported",
74
+ "share_y_axes",
70
75
  ]
@@ -23,6 +23,7 @@ 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
 
@@ -1299,6 +1300,26 @@ def style_spines(
1299
1300
  )
1300
1301
 
1301
1302
 
1303
+ def check_y_limits(
1304
+ limits: Sequence[float] | None,
1305
+ ) -> tuple[float, float] | None:
1306
+ """Validate a ``y_limits`` pair and return it as ``(bottom, top)``.
1307
+
1308
+ YAML hands the pair over as a list, so any two-item sequence is
1309
+ accepted; ``None`` (no fixed range) passes through. Raises
1310
+ ``ValueError`` unless there are exactly two numbers with
1311
+ ``bottom < top``.
1312
+ """
1313
+ if limits is None:
1314
+ return None
1315
+ if len(limits) != 2:
1316
+ raise ValueError(f"y_limits must be (bottom, top): {limits!r}")
1317
+ bottom, top = float(limits[0]), float(limits[1])
1318
+ if not bottom < top:
1319
+ raise ValueError(f"y_limits bottom must be below top: {limits!r}")
1320
+ return bottom, top
1321
+
1322
+
1302
1323
  def style_baseline(
1303
1324
  ax: Axes,
1304
1325
  reference: float = 0,
@@ -1339,6 +1360,7 @@ def annotate_last_values(
1339
1360
  fontsize: float | None = None,
1340
1361
  right_pad_px: float = 10.0,
1341
1362
  headroom_pad_pt: float = 0.0,
1363
+ grow_top: bool = True,
1342
1364
  ) -> None:
1343
1365
  """Label the last non-NaN value of each column on the right.
1344
1366
 
@@ -1365,7 +1387,8 @@ def annotate_last_values(
1365
1387
  y-limit, that limit grows enough to keep this many points
1366
1388
  free and the labels are re-packed at the new scale, so a
1367
1389
  tall stack never spills into a legend above the axes (nor
1368
- is clipped at the top).
1390
+ is clipped at the top). ``grow_top=False`` keeps the top
1391
+ y-limit as it is (a fixed or shared y range must not move).
1369
1392
  """
1370
1393
  fig = ax.get_figure()
1371
1394
  if fig is None:
@@ -1437,7 +1460,7 @@ def annotate_last_values(
1437
1460
  pad_px = headroom_pad_pt * fig.dpi / 72.0
1438
1461
  top_axes_px: float = trans.transform((0, ymax))[1] # type: ignore[reportUnknownArgumentType]
1439
1462
  needed_px = placements[-1] + text_height / 2 + pad_px
1440
- if needed_px > top_axes_px:
1463
+ if grow_top and needed_px > top_axes_px:
1441
1464
  new_top: float = trans.inverted().transform( # type: ignore[reportUnknownArgumentType]
1442
1465
  (0, needed_px)
1443
1466
  )[1]
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import datetime
13
13
  import locale
14
+ from collections.abc import Sequence
14
15
  from pathlib import Path
15
16
  from typing import Any, ClassVar, Self
16
17
 
@@ -30,6 +31,7 @@ from tesorotools.artists._common import (
30
31
  adjust_figure_for_plot_size,
31
32
  annotate_last_values,
32
33
  apply_date_axis,
34
+ check_y_limits,
33
35
  compact_date_axis,
34
36
  compact_figure_kwargs,
35
37
  compact_font_pt,
@@ -160,6 +162,15 @@ class LinePlot:
160
162
  **height** (keeping the width) so the plot area is not
161
163
  squashed by a large legend or rotated date labels. Skipped
162
164
  when ``plot_size`` is set (that already fixes the axes size).
165
+ * ``y_limits`` -- fix the y-axis to ``(bottom, top)`` on both
166
+ backends. The range is strict: the baseline, the reference
167
+ lines and the end-of-series labels no longer widen it.
168
+ * ``y_group`` -- name of a group of charts that must share the
169
+ same y scale (e.g. US and European equities, both base 100,
170
+ in two separate charts). It does nothing on its own:
171
+ :func:`tesorotools.artists.share_y_axes` measures every chart
172
+ of each group and sets the union of their ranges as their
173
+ ``y_limits``. Mutually exclusive with ``y_limits``.
163
174
 
164
175
  Render lifecycle: ``build()`` returns ``(Figure, Axes)``
165
176
  in memory; ``save(fig)`` persists to ``out_path``;
@@ -223,9 +234,16 @@ class LinePlot:
223
234
  ref_lines: list[dict[str, Any]] | None = None,
224
235
  show_means: bool = False,
225
236
  means_window: tuple[Any, Any] | None = None,
237
+ y_limits: Sequence[float] | None = None,
238
+ y_group: str | None = None,
226
239
  ) -> None:
227
240
  if out_path.suffix != ".png":
228
241
  raise ValueError(f"out_path must be .png: {out_path}")
242
+ if y_limits is not None and y_group is not None:
243
+ raise ValueError(
244
+ "y_limits and y_group are mutually exclusive: a group "
245
+ "computes its shared y_limits (see share_y_axes)"
246
+ )
229
247
  if base_100 and base_0:
230
248
  raise ValueError(
231
249
  "base_100 and base_0 are mutually exclusive: a series is "
@@ -269,6 +287,8 @@ class LinePlot:
269
287
  self.ref_lines = ref_lines or []
270
288
  self.show_means = show_means
271
289
  self.means_window = means_window
290
+ self.y_limits = check_y_limits(y_limits)
291
+ self.y_group = y_group
272
292
 
273
293
  @property
274
294
  def _pin_start_date(self) -> bool:
@@ -324,7 +344,8 @@ class LinePlot:
324
344
  ``axis_decimals``, ``axis_units``, ``annotate_units``,
325
345
  ``date_density``, ``date_rotation``, ``date_show_start``,
326
346
  ``adapt_height``,
327
- ``ref_lines``, ``show_means``, ``means_window``.
347
+ ``ref_lines``, ``show_means``, ``means_window``,
348
+ ``y_limits``, ``y_group``.
328
349
 
329
350
  Example
330
351
  -------
@@ -462,6 +483,11 @@ class LinePlot:
462
483
  # data point. annotate_last_values reopens room on the right.
463
484
  ax.margins(x=0)
464
485
 
486
+ # a fixed (or group-shared) y range goes in BEFORE the annotations:
487
+ # they pack their labels at the final scale.
488
+ if self.y_limits is not None:
489
+ ax.set_ylim(*self.y_limits)
490
+
465
491
  if self.vlines:
466
492
  draw_vlines(ax, self.vlines)
467
493
 
@@ -494,6 +520,7 @@ class LinePlot:
494
520
  headroom_pad_pt=(
495
521
  compact_headroom_pad_pt() if self.compact else 0.0
496
522
  ),
523
+ grow_top=self.y_limits is None,
497
524
  )
498
525
 
499
526
  style_spines(
@@ -536,6 +563,10 @@ class LinePlot:
536
563
  )
537
564
  lo, hi = ax.get_ylim()
538
565
  ax.set_ylim(min(ref_line["y"], lo), max(ref_line["y"], hi))
566
+ if self.y_limits is not None:
567
+ # the baseline and the reference lines widen the range to show
568
+ # themselves; a fixed range wins over them.
569
+ ax.set_ylim(*self.y_limits)
539
570
 
540
571
  # per-series historical mean as a dotted same-colour line (the old
541
572
  # `medias=True`): the mean is taken over means_window (or the whole
@@ -302,6 +302,9 @@ def line_plot_to_figure(chart: LinePlot) -> go.Figure:
302
302
  # toggles (mirrors the matplotlib artist).
303
303
  if chart.fmt.units and chart.axis_units:
304
304
  fig.update_yaxes(ticksuffix=chart.fmt.units)
305
+ # fixed or group-shared y range (see share_y_axes)
306
+ if chart.y_limits is not None:
307
+ fig.update_yaxes(range=list(chart.y_limits))
305
308
  _legend_side(fig, chart.legend)
306
309
  return fig
307
310
 
@@ -0,0 +1,59 @@
1
+ """One y scale shared by several separate charts.
2
+
3
+ Two charts that are read side by side (US and European equities, both
4
+ base 100) mislead when each picks its own y range: the same move looks
5
+ bigger in the flatter one. Charts that declare the same ``y_group``
6
+ are measured together here and all get the union of their ranges as a
7
+ fixed ``y_limits``, which both backends honour (``build()`` for the PNG,
8
+ ``to_plotly()`` for the interactive figure).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Iterable
14
+ from typing import Any
15
+
16
+ import matplotlib.pyplot as plt
17
+
18
+ __all__ = ["share_y_axes"]
19
+
20
+
21
+ def share_y_axes(charts: Iterable[Any]) -> dict[str, tuple[float, float]]:
22
+ """Give every chart of each ``y_group`` the same ``y_limits``.
23
+
24
+ *charts* is any iterable of artists, typically the values of the
25
+ mapping loaded from a charts YAML; the ones without a ``y_group``
26
+ (or with ``None``) are left untouched. Each grouped chart is built
27
+ once with its own natural range (the one it would draw alone,
28
+ including the room its end-of-series labels open), and the group
29
+ gets the lowest bottom and the highest top among its members. Call
30
+ it before ``plot()`` / ``to_plotly()``; calling it again measures
31
+ afresh, so it is safe after the data change.
32
+
33
+ Returns the shared ``(bottom, top)`` per group name.
34
+ """
35
+ groups: dict[str, list[Any]] = {}
36
+ for chart in charts:
37
+ group: Any = getattr(chart, "y_group", None)
38
+ if group is not None:
39
+ groups.setdefault(str(group), []).append(chart)
40
+
41
+ shared: dict[str, tuple[float, float]] = {}
42
+ for name, members in groups.items():
43
+ bottoms: list[float] = []
44
+ tops: list[float] = []
45
+ for chart in members:
46
+ # measure the chart's own range, not one shared earlier
47
+ chart.y_limits = None
48
+ fig, ax = chart.build()
49
+ try:
50
+ bottom, top = ax.get_ylim()
51
+ finally:
52
+ plt.close(fig)
53
+ bottoms.append(float(bottom))
54
+ tops.append(float(top))
55
+ limits = (min(bottoms), max(tops))
56
+ for chart in members:
57
+ chart.y_limits = limits
58
+ shared[name] = limits
59
+ return shared
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tesorotools-python
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -1,4 +1,4 @@
1
- tesorotools/__init__.py,sha256=YQGi1L0Uo1B1ZFmjXFRSUFZI4kx7jeLFpdoi5GbRkJM,5296
1
+ tesorotools/__init__.py,sha256=BV31YV_qabD4vdXzoKb89MxXO386YE0xFjXqpDZSXaE,5334
2
2
  tesorotools/_build_context.py,sha256=MH2AKjkwuubN__sBQoXbfTD1bh5RE6w9l5yUFxkJxy8,1982
3
3
  tesorotools/_registry.py,sha256=ZJOr7jACxykvzoepebZDiJdTcR37Q3X-DQ07lm9ZYB0,7975
4
4
  tesorotools/driver.py,sha256=qEVhLYanbG7VXe3WrCCD0RJV_0X4xzhalrfABPB1gSI,5016
@@ -7,16 +7,17 @@ tesorotools/orchestration.py,sha256=24C-LTM4uy7eckjXBjJbC2t92fg6yffl5H-E9Gm2W9M,
7
7
  tesorotools/palette.py,sha256=fkf1_AxweRzviwKLb5zSZnWwP_Cr4zEYoqJ4jqIfBoE,1944
8
8
  tesorotools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  tesorotools/scaffold.py,sha256=6mYNxLm_1CbEMjcYZ77m_2H1eTotbpatFJqhgyote0s,4578
10
- tesorotools/artists/__init__.py,sha256=1HJcBKpSbG_NQEf1Knna7-Bqj0Q0Rvr780HrKxESVhE,2326
11
- tesorotools/artists/_common.py,sha256=xeUq9UUQ6rogaYRiNnFhCATIY9gI28ICn7LydwCet4k,58394
10
+ tesorotools/artists/__init__.py,sha256=-w2IzVdT7iatJien_5JU9fHepx2kTcYcYgrjQXWZYRI,2492
11
+ tesorotools/artists/_common.py,sha256=k59YvyxIlTSEUPU6-koGksOdEKvDcwjhsdPV0LxRCLo,59272
12
12
  tesorotools/artists/bar_line.py,sha256=4urcPuHEfohl4WI3pkb2I__1WZ2W73lDiGoqlwFA27w,14477
13
13
  tesorotools/artists/barh_plot.py,sha256=n8DjIX1APCVS7HW4zzfh6ehggdgBqb6sezQHov1k7us,37520
14
14
  tesorotools/artists/box_plot.py,sha256=KPQjbY2H5JWJWF_icApqpvE1f6sE4e56neIS_dRL6w8,20373
15
15
  tesorotools/artists/compact.py,sha256=e_0tqkRSIgyiyMSwirJzj2TYSKojMxUHzug5tE_7toc,3003
16
16
  tesorotools/artists/intraday_plot.py,sha256=TO-lKwwbIPxx4T-ObYHNuGGR3F_-GZ6XQGwrYU6tyL8,13259
17
- tesorotools/artists/line_plot.py,sha256=rUe_naP8prLU8hoLM51AFcFoA_X1s8LwV28KRpSdkiE,29622
17
+ tesorotools/artists/line_plot.py,sha256=yqg7dHzzV_xfiwiWIlvNN6DVXQ9wjRBdOsiJYvG0iJY,31157
18
18
  tesorotools/artists/matrix.py,sha256=cOpj9ZO5RR0-RlIP55UmD70i-738ck8wZIqRiDO22Hw,11785
19
- tesorotools/artists/plotly_backend.py,sha256=cACSkk6-SlURsPF_nWfxAHGaNKsYfUVS8CPijLKjBAw,36924
19
+ tesorotools/artists/plotly_backend.py,sha256=yaRnZayxgTbNVuiJW1p5l10mo5UFMTslA_QK-nBkZsM,37067
20
+ tesorotools/artists/sharing.py,sha256=Tg4AYtv0ileKQmzBSgmT1GzxA97tdU8ji_arE0oGEZ4,2236
20
21
  tesorotools/artists/shock_plot.py,sha256=Vx9RRvRaJZIXh8G7oJSRRc1XtPgGkqA6xaVk4h4oJuE,13435
21
22
  tesorotools/artists/stacked.py,sha256=MyItTb8NMOpR6jDclqjmofhAB-L9mD1vnzGQjXUTFBc,17439
22
23
  tesorotools/artists/type_curve.py,sha256=0j1LjfS4Zdz4_W7qp-nY8VP0H-fENV5adUg4syAOvig,12570
@@ -129,7 +130,7 @@ tesorotools/web/templates/src/$package/main.py.tmpl,sha256=GXG3AA87WOjk5nTKA5Ywm
129
130
  tesorotools/web/templates/src/$package/sections.py.tmpl,sha256=4MNZ85AW5ERG1SrkVAXIwdmAMeQEBARCYdAMqUr37mg,2084
130
131
  tesorotools/web/templates/tests/__init__.py.tmpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
131
132
  tesorotools/web/templates/tests/test_app.py.tmpl,sha256=ZGfV5GPN6WsS3a2gCNLPWejkHTX2H_N9XpurIADuUxI,615
132
- tesorotools_python-0.1.1.dist-info/METADATA,sha256=HjCChGIyERohQDx-XrWope2gl9EJeC6CkQ5Ceb4-OLM,993
133
- tesorotools_python-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
134
- tesorotools_python-0.1.1.dist-info/entry_points.txt,sha256=PuNrObu4SVvajBHTASuWZPqwSsV7tPHwFJBzYk2nY5o,108
135
- tesorotools_python-0.1.1.dist-info/RECORD,,
133
+ tesorotools_python-0.1.2.dist-info/METADATA,sha256=ARJoWsUX5H4GXfcoU7EH_sVTr5aUusS-9OpkhfTVm-A,993
134
+ tesorotools_python-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
135
+ tesorotools_python-0.1.2.dist-info/entry_points.txt,sha256=PuNrObu4SVvajBHTASuWZPqwSsV7tPHwFJBzYk2nY5o,108
136
+ tesorotools_python-0.1.2.dist-info/RECORD,,