tesorotools-python 0.1.1__tar.gz → 0.1.3__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.
Files changed (135) hide show
  1. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/PKG-INFO +1 -1
  2. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/__init__.py +2 -0
  3. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/__init__.py +5 -0
  4. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/_common.py +32 -3
  5. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/bar_line.py +145 -13
  6. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/line_plot.py +32 -1
  7. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/plotly_backend.py +32 -5
  8. tesorotools_python-0.1.3/src/tesorotools/artists/sharing.py +59 -0
  9. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/.gitignore +0 -0
  10. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/pyproject.toml +0 -0
  11. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/_build_context.py +0 -0
  12. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/_registry.py +0 -0
  13. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/barh_plot.py +0 -0
  14. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/box_plot.py +0 -0
  15. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/compact.py +0 -0
  16. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/intraday_plot.py +0 -0
  17. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/matrix.py +0 -0
  18. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/shock_plot.py +0 -0
  19. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/stacked.py +0 -0
  20. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/type_curve.py +0 -0
  21. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/vector_plot.py +0 -0
  22. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/artists/waterfall.py +0 -0
  23. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/README.md +0 -0
  24. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  25. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  26. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  27. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  28. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  29. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  30. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  31. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  32. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/fonts/README.md +0 -0
  33. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/plots.yaml +0 -0
  34. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/template.docx +0 -0
  35. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  36. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/data_sources/__init__.py +0 -0
  37. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/data_sources/debug.py +0 -0
  38. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/database/__init__.py +0 -0
  39. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/database/local.py +0 -0
  40. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/database/push.py +0 -0
  41. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/database/shared.py +0 -0
  42. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/dependencies/__init__.py +0 -0
  43. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/dependencies/node.py +0 -0
  44. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/dependencies/resolution.py +0 -0
  45. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/driver.py +0 -0
  46. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/__init__.py +0 -0
  47. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/cli.py +0 -0
  48. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/scaffold.py +0 -0
  49. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/article/main.tex.tmpl +0 -0
  50. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/article/sections/01-introduccion.tex +0 -0
  51. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/article/sections/02-analisis.tex +0 -0
  52. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/article/sections/03-conclusiones.tex +0 -0
  53. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/beamer/main.tex.tmpl +0 -0
  54. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/beamer/slides/01-contexto.tex +0 -0
  55. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/beamer/slides/02-analisis.tex +0 -0
  56. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/beamer/slides/03-conclusiones.tex +0 -0
  57. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/beamer/style/beamerthemeTesoro.sty +0 -0
  58. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/book/appendices/a-fuentes.tex +0 -0
  59. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/book/chapters/01-introduccion.tex +0 -0
  60. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/book/chapters/02-analisis.tex +0 -0
  61. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/book/frontmatter/resumen.tex +0 -0
  62. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/book/main.tex.tmpl +0 -0
  63. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/README.md.tmpl +0 -0
  64. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/dot-gitignore +0 -0
  65. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/dot-vscode/settings.json +0 -0
  66. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/figures/dot-gitkeep +0 -0
  67. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/fonts/README.md +0 -0
  68. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/latexmkrc +0 -0
  69. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/refs.bib +0 -0
  70. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/style/tesoro.sty +0 -0
  71. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/style/tesorobrand.sty.tmpl +0 -0
  72. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/latex/templates/common/style/tesorolocal.sty.tmpl +0 -0
  73. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/manifest.py +0 -0
  74. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/offsets/__init__.py +0 -0
  75. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/offsets/offsets.py +0 -0
  76. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/offsets/outliers.py +0 -0
  77. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/orchestration.py +0 -0
  78. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/palette.py +0 -0
  79. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/pipeline/__init__.py +0 -0
  80. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/pipeline/diagnose.py +0 -0
  81. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/pipeline/engine.py +0 -0
  82. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/pipeline/rules.py +0 -0
  83. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/providers/__init__.py +0 -0
  84. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/providers/base.py +0 -0
  85. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/providers/bde.py +0 -0
  86. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/providers/ecb.py +0 -0
  87. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/providers/fred.py +0 -0
  88. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/providers/imf_irfcl.py +0 -0
  89. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/providers/lseg.py +0 -0
  90. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/py.typed +0 -0
  91. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/__init__.py +0 -0
  92. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/content/__init__.py +0 -0
  93. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/content/content.py +0 -0
  94. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/content/images.py +0 -0
  95. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/content/section.py +0 -0
  96. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/content/subtitle.py +0 -0
  97. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/content/table.py +0 -0
  98. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/content/text.py +0 -0
  99. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/content/title.py +0 -0
  100. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/document.py +0 -0
  101. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/render/report.py +0 -0
  102. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/scaffold.py +0 -0
  103. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/testing/__init__.py +0 -0
  104. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/testing/compare.py +0 -0
  105. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/utils/__init__.py +0 -0
  106. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/utils/config.py +0 -0
  107. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/utils/format.py +0 -0
  108. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/utils/globals.py +0 -0
  109. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/utils/matplotlib.py +0 -0
  110. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/utils/series.py +0 -0
  111. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/utils/shortcuts.py +0 -0
  112. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/utils/template.py +0 -0
  113. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/__init__.py +0 -0
  114. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/app.py +0 -0
  115. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/brand.py +0 -0
  116. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/cli.py +0 -0
  117. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/components.py +0 -0
  118. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/formatting.py +0 -0
  119. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/launcher.py +0 -0
  120. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/palette.py +0 -0
  121. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/plotting.py +0 -0
  122. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/scaffold.py +0 -0
  123. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/Dockerfile.tmpl +0 -0
  124. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/README.md.tmpl +0 -0
  125. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/dot-dockerignore.tmpl +0 -0
  126. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/dot-gitignore.tmpl +0 -0
  127. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/dot-streamlit/config.toml.tmpl +0 -0
  128. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/pyproject.toml.tmpl +0 -0
  129. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/src/$package/__init__.py.tmpl +0 -0
  130. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/src/$package/cli.py.tmpl +0 -0
  131. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/src/$package/main.py.tmpl +0 -0
  132. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/src/$package/sections.py.tmpl +0 -0
  133. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/tests/__init__.py.tmpl +0 -0
  134. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/templates/tests/test_app.py.tmpl +0 -0
  135. {tesorotools_python-0.1.1 → tesorotools_python-0.1.3}/src/tesorotools/web/theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tesorotools-python
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -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
 
@@ -1173,6 +1174,7 @@ def place_legend(
1173
1174
  compact: bool,
1174
1175
  ncol: int | None = None,
1175
1176
  position: str = "auto",
1177
+ entries: tuple[list[Any], list[str]] | None = None,
1176
1178
  ) -> MplLegend | None:
1177
1179
  """Place the figure legend, compact-aware, from the labelled artists.
1178
1180
 
@@ -1183,9 +1185,14 @@ def place_legend(
1183
1185
  ``rcParams`` metrics. ``position`` (``"above"`` / ``"below"`` /
1184
1186
  ``"auto"``) chooses the side through :func:`resolve_legend_position`, so
1185
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.
1186
1191
  Returns ``None`` (drawing nothing) when there are no labels.
1187
1192
  """
1188
- handles, labels = ax.get_legend_handles_labels()
1193
+ handles, labels = (
1194
+ entries if entries is not None else ax.get_legend_handles_labels()
1195
+ )
1189
1196
  if not labels:
1190
1197
  return None
1191
1198
  side = resolve_legend_position(position, compact=compact)
@@ -1299,6 +1306,26 @@ def style_spines(
1299
1306
  )
1300
1307
 
1301
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
+
1302
1329
  def style_baseline(
1303
1330
  ax: Axes,
1304
1331
  reference: float = 0,
@@ -1339,6 +1366,7 @@ def annotate_last_values(
1339
1366
  fontsize: float | None = None,
1340
1367
  right_pad_px: float = 10.0,
1341
1368
  headroom_pad_pt: float = 0.0,
1369
+ grow_top: bool = True,
1342
1370
  ) -> None:
1343
1371
  """Label the last non-NaN value of each column on the right.
1344
1372
 
@@ -1365,7 +1393,8 @@ def annotate_last_values(
1365
1393
  y-limit, that limit grows enough to keep this many points
1366
1394
  free and the labels are re-packed at the new scale, so a
1367
1395
  tall stack never spills into a legend above the axes (nor
1368
- 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).
1369
1398
  """
1370
1399
  fig = ax.get_figure()
1371
1400
  if fig is None:
@@ -1437,7 +1466,7 @@ def annotate_last_values(
1437
1466
  pad_px = headroom_pad_pt * fig.dpi / 72.0
1438
1467
  top_axes_px: float = trans.transform((0, ymax))[1] # type: ignore[reportUnknownArgumentType]
1439
1468
  needed_px = placements[-1] + text_height / 2 + pad_px
1440
- if needed_px > top_axes_px:
1469
+ if grow_top and needed_px > top_axes_px:
1441
1470
  new_top: float = trans.inverted().transform( # type: ignore[reportUnknownArgumentType]
1442
1471
  (0, needed_px)
1443
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
@@ -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,6 +91,21 @@ 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
 
92
111
  #: Keys that change the PNG but never the ``to_plotly()`` figure
@@ -102,6 +121,7 @@ class BarLinePlot:
102
121
  "legend.ncol",
103
122
  "max_xticks",
104
123
  "plot_size",
124
+ "secondary_fmt.decimals",
105
125
  "x_rotation",
106
126
  }
107
127
  )
@@ -130,6 +150,10 @@ class BarLinePlot:
130
150
  max_xticks: int | None = None,
131
151
  compact: bool = False,
132
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 = "",
133
157
  ) -> None:
134
158
  if out_path.suffix != ".png":
135
159
  raise ValueError(f"out_path must be .png: {out_path}")
@@ -139,6 +163,17 @@ class BarLinePlot:
139
163
  raise ValueError(
140
164
  "BarLinePlot needs at least one series in `bars` or `lines`"
141
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
+ )
142
177
  self.out_path = out_path
143
178
  self.data = resolve_data(data, data_path)
144
179
  self.scale = scale
@@ -157,6 +192,9 @@ class BarLinePlot:
157
192
  self.max_xticks = max_xticks
158
193
  self.compact = compact
159
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
160
198
 
161
199
  @classmethod
162
200
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -179,7 +217,8 @@ class BarLinePlot:
179
217
  ``fmt`` (``!format``), ``legend`` (``!legend``), ``figsize``,
180
218
  ``plot_size``, ``bar_styles`` / ``line_styles``, ``bar_width``,
181
219
  ``date_format``, ``x_rotation``, ``max_xticks``, ``compact``,
182
- ``font_pt``.
220
+ ``font_pt``, ``secondary``, ``secondary_fmt`` (``!format``),
221
+ ``ylabel`` / ``secondary_ylabel``.
183
222
 
184
223
  Example
185
224
  -------
@@ -194,6 +233,26 @@ class BarLinePlot:
194
233
  surprise: "Sorpresa (pp)"
195
234
  fmt: !format {decimals: 0}
196
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 {}
197
256
  """
198
257
  cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
199
258
  node, deep=True
@@ -287,9 +346,13 @@ class BarLinePlot:
287
346
  **compact_figure_kwargs(self.compact, self.figsize)
288
347
  )
289
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
290
353
 
291
354
  # Bars first (behind the lines). Several bar series share each slot,
292
- # dodged side by side; a single series is centred.
355
+ # dodged side by side (across both axes); a single one is centred.
293
356
  n_bars = len(self.bars)
294
357
  each = self.bar_width / n_bars if n_bars else self.bar_width
295
358
  default_alpha: float = float(BAR_LINE_CONFIG["bar"].get("alpha", 0.9))
@@ -299,7 +362,7 @@ class BarLinePlot:
299
362
  style.setdefault("alpha", default_alpha)
300
363
  offset = (j - (n_bars - 1) / 2) * each
301
364
  values = plot_data[col].fillna(0).to_numpy(dtype=np.float64)
302
- ax.bar( # type: ignore[reportUnknownMemberType]
365
+ target(col).bar( # type: ignore[reportUnknownMemberType]
303
366
  x + offset,
304
367
  values,
305
368
  width=each,
@@ -317,7 +380,7 @@ class BarLinePlot:
317
380
  style.setdefault("marker", line_cfg.get("marker", "o"))
318
381
  style.setdefault("linewidth", line_cfg.get("linewidth", 2))
319
382
  style.setdefault("markersize", line_cfg.get("markersize", 5))
320
- ax.plot( # type: ignore[reportUnknownMemberType]
383
+ target(col).plot( # type: ignore[reportUnknownMemberType]
321
384
  x,
322
385
  plot_data[col].to_numpy(dtype=np.float64),
323
386
  label=label,
@@ -336,14 +399,31 @@ class BarLinePlot:
336
399
  # style_spines enables the y grid only (axis="y"), which is what these
337
400
  # charts read against; the categorical x-axis stays gridless.
338
401
  if self.baseline:
339
- style_baseline(ax, 0, **AX_CONFIG["baseline"])
340
-
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)
341
412
  if self.compact:
342
- 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
+ )
343
419
 
344
420
  if self.legend is not None:
345
421
  place_legend(
346
- 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),
347
427
  )
348
428
 
349
429
  if self.plot_size is not None and not self.compact:
@@ -351,6 +431,58 @@ class BarLinePlot:
351
431
 
352
432
  return fig, ax
353
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
+
354
486
  def save(
355
487
  self,
356
488
  fig: Figure,
@@ -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
@@ -197,10 +197,11 @@ def _legend_side(fig: go.Figure, legend: Legend | None) -> None:
197
197
  )
198
198
 
199
199
 
200
- def _baseline(fig: go.Figure, reference: float) -> None:
201
- """Draw the house baseline (axhline) at *reference*."""
200
+ def _baseline(fig: go.Figure, reference: float, *, yref: str = "y") -> None:
201
+ """Draw the house baseline (axhline) at *reference* on axis *yref*."""
202
202
  fig.add_hline(
203
203
  y=reference,
204
+ yref=yref,
204
205
  line_color=AX_CONFIG["baseline"]["color"],
205
206
  line_width=AX_CONFIG["baseline"]["linewidth"],
206
207
  )
@@ -302,6 +303,9 @@ def line_plot_to_figure(chart: LinePlot) -> go.Figure:
302
303
  # toggles (mirrors the matplotlib artist).
303
304
  if chart.fmt.units and chart.axis_units:
304
305
  fig.update_yaxes(ticksuffix=chart.fmt.units)
306
+ # fixed or group-shared y range (see share_y_axes)
307
+ if chart.y_limits is not None:
308
+ fig.update_yaxes(range=list(chart.y_limits))
305
309
  _legend_side(fig, chart.legend)
306
310
  return fig
307
311
 
@@ -500,7 +504,8 @@ def bar_line_to_figure(chart: BarLinePlot) -> go.Figure:
500
504
  """Interactive ``Figure`` from a :class:`BarLinePlot`.
501
505
 
502
506
  Bars (grouped) and marked lines share one y-axis over a categorical
503
- x-axis of the same date-formatted labels the matplotlib artist uses.
507
+ x-axis of the same date-formatted labels the matplotlib artist uses;
508
+ the ``secondary`` series go to an overlaid right-hand ``yaxis2``.
504
509
  """
505
510
  plot_data = chart._prepare_data()
506
511
  palette = _house_palette()
@@ -512,6 +517,9 @@ def bar_line_to_figure(chart: BarLinePlot) -> go.Figure:
512
517
  default_alpha = float(BAR_LINE_CONFIG["bar"].get("alpha", 0.9))
513
518
  line_cfg = BAR_LINE_CONFIG["line"]
514
519
 
520
+ def yaxis(col: str) -> str:
521
+ return "y2" if col in chart.secondary else "y"
522
+
515
523
  for j, (col, label) in enumerate(chart.bars.items()):
516
524
  style = chart.bar_styles.get(col, {})
517
525
  color = (
@@ -522,6 +530,7 @@ def bar_line_to_figure(chart: BarLinePlot) -> go.Figure:
522
530
  x=x,
523
531
  y=plot_data[col].fillna(0),
524
532
  name=label,
533
+ yaxis=yaxis(col),
525
534
  marker_color=_color(color, style.get("alpha", default_alpha)),
526
535
  )
527
536
  )
@@ -538,6 +547,7 @@ def bar_line_to_figure(chart: BarLinePlot) -> go.Figure:
538
547
  y=plot_data[col],
539
548
  mode="lines+markers",
540
549
  name=label,
550
+ yaxis=yaxis(col),
541
551
  line={
542
552
  "color": _color(color),
543
553
  "width": style.get(
@@ -549,10 +559,27 @@ def bar_line_to_figure(chart: BarLinePlot) -> go.Figure:
549
559
  )
550
560
 
551
561
  fig.update_layout(barmode="group")
562
+ if chart.ylabel:
563
+ fig.update_layout(yaxis_title_text=chart.ylabel)
564
+ if chart.secondary:
565
+ fig.update_layout(
566
+ yaxis_side="left",
567
+ yaxis2={
568
+ "overlaying": "y",
569
+ "side": "right",
570
+ "showgrid": False,
571
+ "ticksuffix": chart.secondary_fmt.units,
572
+ "title_text": chart.secondary_ylabel,
573
+ },
574
+ )
552
575
  if chart.baseline:
553
- _baseline(fig, 0)
576
+ on_primary, on_secondary = chart._baseline_on()
577
+ if on_primary:
578
+ _baseline(fig, 0)
579
+ if on_secondary:
580
+ _baseline(fig, 0, yref="y2")
554
581
  if chart.fmt.units:
555
- fig.update_yaxes(ticksuffix=chart.fmt.units)
582
+ fig.update_layout(yaxis_ticksuffix=chart.fmt.units)
556
583
  _legend_side(fig, chart.legend)
557
584
  return fig
558
585
 
@@ -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