tesorotools-python 0.0.49__tar.gz → 0.0.50__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 (77) hide show
  1. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/PKG-INFO +1 -1
  2. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/__init__.py +13 -0
  3. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/__init__.py +2 -0
  4. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/line_plot.py +36 -7
  5. tesorotools_python-0.0.50/src/tesorotools/artists/shock_plot.py +333 -0
  6. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/type_curve.py +13 -2
  7. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/.gitignore +0 -0
  8. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/pyproject.toml +0 -0
  9. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/_build_context.py +0 -0
  10. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/_registry.py +0 -0
  11. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/_common.py +0 -0
  12. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/barh_plot.py +0 -0
  13. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/compact.py +0 -0
  14. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/matrix.py +0 -0
  15. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/stacked.py +0 -0
  16. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/vector_plot.py +0 -0
  17. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/artists/waterfall.py +0 -0
  18. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/README.md +0 -0
  19. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  20. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  21. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  22. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  23. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  24. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  25. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  26. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  27. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/fonts/README.md +0 -0
  28. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/plots.yaml +0 -0
  29. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/template.docx +0 -0
  30. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  31. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/data_sources/__init__.py +0 -0
  32. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/data_sources/debug.py +0 -0
  33. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/database/__init__.py +0 -0
  34. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/database/local.py +0 -0
  35. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/database/push.py +0 -0
  36. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/database/shared.py +0 -0
  37. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/dependencies/__init__.py +0 -0
  38. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/dependencies/node.py +0 -0
  39. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/dependencies/resolution.py +0 -0
  40. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/driver.py +0 -0
  41. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/manifest.py +0 -0
  42. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/offsets/__init__.py +0 -0
  43. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/offsets/offsets.py +0 -0
  44. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/offsets/outliers.py +0 -0
  45. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/orchestration.py +0 -0
  46. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/pipeline/__init__.py +0 -0
  47. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/pipeline/diagnose.py +0 -0
  48. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/pipeline/engine.py +0 -0
  49. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/pipeline/rules.py +0 -0
  50. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/providers/__init__.py +0 -0
  51. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/providers/base.py +0 -0
  52. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/providers/bde.py +0 -0
  53. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/providers/ecb.py +0 -0
  54. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/providers/imf_irfcl.py +0 -0
  55. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/providers/lseg.py +0 -0
  56. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/py.typed +0 -0
  57. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/__init__.py +0 -0
  58. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/content/__init__.py +0 -0
  59. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/content/content.py +0 -0
  60. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/content/images.py +0 -0
  61. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/content/section.py +0 -0
  62. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/content/subtitle.py +0 -0
  63. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/content/table.py +0 -0
  64. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/content/text.py +0 -0
  65. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/content/title.py +0 -0
  66. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/document.py +0 -0
  67. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/render/report.py +0 -0
  68. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/testing/__init__.py +0 -0
  69. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/testing/compare.py +0 -0
  70. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/utils/__init__.py +0 -0
  71. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/utils/config.py +0 -0
  72. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/utils/format.py +0 -0
  73. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/utils/globals.py +0 -0
  74. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/utils/matplotlib.py +0 -0
  75. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/utils/series.py +0 -0
  76. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/utils/shortcuts.py +0 -0
  77. {tesorotools_python-0.0.49 → tesorotools_python-0.0.50}/src/tesorotools/utils/template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.49
3
+ Version: 0.0.50
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -17,8 +17,17 @@ plural variants). See ``docs/extending.md`` for the
17
17
  recommended idioms.
18
18
  """
19
19
 
20
+ from importlib.metadata import PackageNotFoundError, version
20
21
  from typing import TYPE_CHECKING, Any
21
22
 
23
+ try:
24
+ #: Installed distribution version (set by hatch-vcs from the git tag).
25
+ #: Falls back to ``"0.0.0"`` when the package is not installed (e.g.
26
+ #: running straight from a source checkout with no metadata).
27
+ __version__ = version("tesorotools-python")
28
+ except PackageNotFoundError: # pragma: no cover - source-only checkout
29
+ __version__ = "0.0.0"
30
+
22
31
  if TYPE_CHECKING:
23
32
  from tesorotools.providers.bde import BdeProvider
24
33
  from tesorotools.providers.ecb import EcbProvider
@@ -50,6 +59,7 @@ from tesorotools.artists import (
50
59
  Legend,
51
60
  LinePlot,
52
61
  MatrixChart,
62
+ ShockChart,
53
63
  StackedAreaPlot,
54
64
  StackedBarPlot,
55
65
  TypeCurve,
@@ -85,6 +95,7 @@ def _register_builtins() -> None:
85
95
  register_artist("type_curve", TypeCurve)
86
96
  register_artist("vector_plot", VectorPlot)
87
97
  register_artist("matrix", MatrixChart)
98
+ register_artist("shock_plot", ShockChart)
88
99
  register_artist("waterfall", Waterfall)
89
100
 
90
101
  register_tag("format", Format)
@@ -103,6 +114,7 @@ _register_builtins()
103
114
 
104
115
 
105
116
  __all__ = [
117
+ "__version__",
106
118
  "Artist",
107
119
  "BdeProvider",
108
120
  "BuildContext",
@@ -122,6 +134,7 @@ __all__ = [
122
134
  "RegistryProtocol",
123
135
  "Report",
124
136
  "Section",
137
+ "ShockChart",
125
138
  "StackedAreaPlot",
126
139
  "StackedBarPlot",
127
140
  "Subtitle",
@@ -29,6 +29,7 @@ from tesorotools.artists._common import Artist, Format, Legend
29
29
  from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
30
30
  from tesorotools.artists.line_plot import LinePlot
31
31
  from tesorotools.artists.matrix import MatrixChart
32
+ from tesorotools.artists.shock_plot import ShockChart
32
33
  from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
33
34
  from tesorotools.artists.type_curve import TypeCurve
34
35
  from tesorotools.artists.vector_plot import VectorPlot
@@ -42,6 +43,7 @@ __all__ = [
42
43
  "Legend",
43
44
  "LinePlot",
44
45
  "MatrixChart",
46
+ "ShockChart",
45
47
  "StackedAreaPlot",
46
48
  "StackedBarPlot",
47
49
  "TypeCurve",
@@ -57,8 +57,16 @@ class LinePlot:
57
57
  ``plot_size``). Line-specific knobs:
58
58
 
59
59
  * ``base_100`` / ``base_100_date`` -- rebase every series
60
- to 100 at the first row, or at the row nearest the
61
- given date.
60
+ *multiplicatively* to 100 at the first row, or at the
61
+ row nearest the given date (the index form).
62
+ * ``base_0`` / ``base_0_date`` -- rebase every series
63
+ *subtractively* to 0: the plotted value is the change
64
+ since the anchor row (first row, or the row nearest the
65
+ given date), ``(value - anchor)``. ``scale`` still maps
66
+ the raw change to the display unit (e.g. ``scale: 100``
67
+ with ``units: "p.b."`` for the move in basis points
68
+ since an event). Mutually exclusive with ``base_100``;
69
+ pair with ``baseline: true`` to draw the zero line.
62
70
  * ``annotate`` / ``annotate_color`` -- label the last
63
71
  non-NaN value next to each line; with packing so
64
72
  labels do not overlap.
@@ -118,6 +126,8 @@ class LinePlot:
118
126
  end_date: datetime.datetime | None = None,
119
127
  base_100: bool = False,
120
128
  base_100_date: datetime.datetime | str | None = None,
129
+ base_0: bool = False,
130
+ base_0_date: datetime.datetime | str | None = None,
121
131
  annotate: bool = False,
122
132
  annotate_color: str | None = None,
123
133
  baseline: bool = False,
@@ -136,6 +146,12 @@ class LinePlot:
136
146
  ) -> None:
137
147
  if out_path.suffix != ".png":
138
148
  raise ValueError(f"out_path must be .png: {out_path}")
149
+ if base_100 and base_0:
150
+ raise ValueError(
151
+ "base_100 and base_0 are mutually exclusive: a series is "
152
+ "either rebased multiplicatively (index) or subtractively "
153
+ "(change-since), not both"
154
+ )
139
155
  self.out_path = out_path
140
156
  self.data = resolve_data(data, data_path)
141
157
  self.series = series
@@ -145,6 +161,8 @@ class LinePlot:
145
161
  self.end_date = end_date
146
162
  self.base_100 = base_100
147
163
  self.base_100_date = base_100_date
164
+ self.base_0 = base_0
165
+ self.base_0_date = base_0_date
148
166
  self.annotate = annotate
149
167
  self.annotate_color = annotate_color
150
168
  self.baseline = baseline
@@ -179,7 +197,8 @@ class LinePlot:
179
197
  Optional keys (forwarded as-is to ``__init__``)
180
198
  ----------------------------------------------
181
199
  ``scale``, ``start_date``, ``end_date``, ``base_100``,
182
- ``base_100_date``, ``annotate``, ``annotate_color``,
200
+ ``base_100_date``, ``base_0``, ``base_0_date``,
201
+ ``annotate``, ``annotate_color``,
183
202
  ``baseline``, ``fmt`` (``!format``), ``legend``
184
203
  (``!legend``), ``figsize``, ``series_styles``,
185
204
  ``plot_size``, ``vlines``, ``compact``, ``font_pt``,
@@ -236,11 +255,18 @@ class LinePlot:
236
255
 
237
256
  plot_data = plot_data * self.scale
238
257
 
239
- if self.base_100:
240
- if self.base_100_date is None:
258
+ if self.base_100 or self.base_0:
259
+ # shared anchor lookup: the first row, or the row at the nearest
260
+ # index date to the requested anchor date. base_100 rebases
261
+ # multiplicatively (ratio -> index 100); base_0 subtractively
262
+ # (difference -> change since the anchor, baseline 0).
263
+ anchor_date = (
264
+ self.base_100_date if self.base_100 else self.base_0_date
265
+ )
266
+ if anchor_date is None:
241
267
  anchor: pd.Series[float] = plot_data.iloc[0, :]
242
268
  else:
243
- anchor_ts: pd.Timestamp = pd.to_datetime(self.base_100_date)
269
+ anchor_ts: pd.Timestamp = pd.to_datetime(anchor_date)
244
270
  idx_pos: int = self.data.index.get_indexer(
245
271
  pd.Index([anchor_ts]), method="nearest"
246
272
  )[0]
@@ -248,7 +274,10 @@ class LinePlot:
248
274
  self.data.iloc[idx_pos, :].loc[list(self.series.keys())]
249
275
  * self.scale
250
276
  )
251
- plot_data = plot_data / anchor * 100
277
+ if self.base_100:
278
+ plot_data = plot_data / anchor * 100
279
+ else:
280
+ plot_data = plot_data - anchor
252
281
 
253
282
  fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
254
283
  **compact_figure_kwargs(self.compact, self.figsize)
@@ -0,0 +1,333 @@
1
+ """Per-shock aftermath chart artist, registered as ``!shock_plot``.
2
+
3
+ Plots **one** asset across **N** shock events, each window reindexed to
4
+ days-relative-to-the-event so the aftermaths overlay on a common x-axis
5
+ (0 = the shock day). Historical shocks draw in ``C2`` with rotating
6
+ linestyles; the most recent (last in dict order) is highlighted in
7
+ ``C0`` (solid).
8
+
9
+ Two rebases, mirroring :class:`LinePlot`:
10
+
11
+ * ``base100`` (the default) -- normalise each window *multiplicatively*
12
+ to 100 at its anchor, so every aftermath reads as an index.
13
+ * ``base100: false`` -- the *subtractive* form: the plotted value is the
14
+ change since the anchor, ``(value - anchor)``, with ``scale`` mapping
15
+ the raw move to the display unit (e.g. ``scale: 100`` + ``units:
16
+ "p.b."`` for the move in basis points since the event). The 0 baseline
17
+ is drawn here.
18
+
19
+ Layout and styling come entirely from
20
+ :mod:`tesorotools.artists._common`, so the chart matches the other image
21
+ artists (``style_spines`` / ``style_baseline`` for the frame, the compact
22
+ toolkit for the two-per-row size, ``place_legend`` for legend packing).
23
+
24
+ Render lifecycle mirrors :class:`LinePlot`: ``build()`` returns
25
+ ``(Figure, Axes)`` in memory; ``save(fig)`` persists to ``out_path``;
26
+ ``plot()`` does both.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any, Self, cast
34
+
35
+ import matplotlib.pyplot as plt
36
+ import pandas as pd
37
+ from matplotlib.axes import Axes
38
+ from matplotlib.figure import Figure
39
+ from pandas.tseries.offsets import BDay
40
+ from yaml.nodes import MappingNode
41
+
42
+ from tesorotools.artists._common import (
43
+ AX_CONFIG,
44
+ Format,
45
+ Legend,
46
+ compact_figure_kwargs,
47
+ compact_font_pt,
48
+ dynamic_dpi,
49
+ place_legend,
50
+ resolve_data,
51
+ style_baseline,
52
+ style_compact_axes,
53
+ style_spines,
54
+ )
55
+ from tesorotools.utils.template import TemplateLoader
56
+
57
+ __all__ = ["ShockChart"]
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class _ShockEvent:
62
+ date: str
63
+ days_before: int
64
+ days_after: int
65
+
66
+
67
+ def _normalize_shocks(
68
+ shocks: dict[str, Any],
69
+ default_before: int,
70
+ default_after: int,
71
+ ) -> dict[str, _ShockEvent]:
72
+ """Translate the YAML ``shocks`` dict into per-event records.
73
+
74
+ Each entry maps a label to either the simple string form
75
+ (``"YYYY-MM-DD"``) -- in which case the chart-level ``shock_pre`` /
76
+ ``shock_cooldown`` apply -- or the extended dict form
77
+ (``{date, days_before, days_after}``) for per-shock window overrides.
78
+ """
79
+ out: dict[str, _ShockEvent] = {}
80
+ for name, spec in shocks.items():
81
+ if isinstance(spec, str):
82
+ out[name] = _ShockEvent(
83
+ date=spec,
84
+ days_before=default_before,
85
+ days_after=default_after,
86
+ )
87
+ elif isinstance(spec, dict):
88
+ spec_dict = cast("dict[str, Any]", spec)
89
+ if "date" not in spec_dict:
90
+ raise ValueError(
91
+ f"Shock {name!r}: extended form requires a 'date' key"
92
+ )
93
+ out[name] = _ShockEvent(
94
+ date=str(spec_dict["date"]),
95
+ days_before=int(spec_dict.get("days_before", default_before)),
96
+ days_after=int(spec_dict.get("days_after", default_after)),
97
+ )
98
+ else:
99
+ raise TypeError(
100
+ f"Shock {name!r}: spec must be a date string or a dict, "
101
+ f"got {type(spec).__name__}"
102
+ )
103
+ return out
104
+
105
+
106
+ class ShockChart:
107
+ """One asset reindexed across N shocks; x-axis = days from event.
108
+
109
+ Parameters mirror the other image artists where they overlap
110
+ (``out_path``, ``data`` / ``data_path``, ``scale``, ``fmt``,
111
+ ``legend``, ``figsize``, ``compact``). Shock-specific knobs:
112
+
113
+ * ``asset`` -- the single column of ``data`` to follow.
114
+ * ``shocks`` -- ``label -> spec`` mapping; each spec is either a
115
+ ``"YYYY-MM-DD"`` string (using the chart-level window) or a
116
+ ``{date, days_before, days_after}`` dict for per-shock overrides.
117
+ * ``shock_pre`` / ``shock_cooldown`` -- default window (business
118
+ days before / after the event) for the string-form shocks.
119
+ * ``base100`` -- ``True`` (default) rebases each window to 100
120
+ multiplicatively; ``False`` plots ``(value - anchor) * scale`` and
121
+ draws the 0 baseline (e.g. p.b. since the event).
122
+ * ``common_anchor`` -- rebase every window against one shock's anchor
123
+ value instead of each window's own anchor; must be a shock label.
124
+ """
125
+
126
+ def __init__(
127
+ self,
128
+ out_path: Path,
129
+ *,
130
+ data: pd.DataFrame | None = None,
131
+ data_path: Path | None = None,
132
+ asset: str,
133
+ shocks: dict[str, Any],
134
+ scale: float = 1,
135
+ shock_cooldown: int = 60,
136
+ shock_pre: int = 30,
137
+ base100: bool = True,
138
+ common_anchor: str | None = None,
139
+ fmt: Format | None = None,
140
+ legend: Legend | None = None,
141
+ figsize: tuple[float, float] | None = None,
142
+ compact: bool = False,
143
+ ) -> None:
144
+ if out_path.suffix != ".png":
145
+ raise ValueError(f"out_path must be .png: {out_path}")
146
+ self.out_path = out_path
147
+ self.data = resolve_data(data, data_path)
148
+ self.asset = asset
149
+ self.scale = scale
150
+ self.shock_cooldown = shock_cooldown
151
+ self.shock_pre = shock_pre
152
+ self.base100 = base100
153
+ self.fmt = fmt or Format()
154
+ self.legend = legend
155
+ self.figsize = figsize
156
+ self.compact = compact
157
+ self._events = _normalize_shocks(shocks, shock_pre, shock_cooldown)
158
+ if common_anchor is not None and common_anchor not in self._events:
159
+ raise ValueError(
160
+ f"common_anchor {common_anchor!r} must match one of the "
161
+ f"shock labels: {list(self._events)}"
162
+ )
163
+ self.common_anchor = common_anchor
164
+
165
+ @classmethod
166
+ def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
167
+ """Build a :class:`ShockChart` from the ``!shock_plot`` YAML tag.
168
+
169
+ Required keys
170
+ -------------
171
+ ``out_path``
172
+ ``.png`` destination path.
173
+ ``data_path``
174
+ ``.feather`` file with daily observations
175
+ (DatetimeIndex x asset columns).
176
+ ``asset``
177
+ column of the loaded DataFrame to follow.
178
+ ``shocks``
179
+ ``label -> spec`` mapping (string date or
180
+ ``{date, days_before, days_after}`` dict).
181
+
182
+ Optional keys (forwarded as-is to ``__init__``)
183
+ ----------------------------------------------
184
+ ``scale``, ``shock_pre``, ``shock_cooldown``,
185
+ ``base100``, ``common_anchor``, ``fmt`` (``!format``),
186
+ ``legend`` (``!legend``), ``figsize``, ``compact``.
187
+
188
+ Example
189
+ -------
190
+ .. code-block:: yaml
191
+
192
+ ust_10y_shocks: !shock_plot
193
+ out_path: out/ust_shocks.png
194
+ data_path: data/rates.feather
195
+ asset: UST10Y
196
+ base100: false
197
+ scale: 100
198
+ fmt: !format {decimals: 0, units: "p.b."}
199
+ legend: !legend {}
200
+ shocks:
201
+ "Elecciones EE.UU.": "2024-11-05"
202
+ "Liberation Day": {date: "2025-04-02", days_after: 90}
203
+ """
204
+ cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
205
+ node, deep=True
206
+ )
207
+ cfg.pop("id")
208
+ cfg["out_path"] = Path(cfg["out_path"])
209
+ if "data_path" in cfg:
210
+ cfg["data_path"] = Path(cfg["data_path"])
211
+ return cls(**cfg)
212
+
213
+ def build(self) -> tuple[Figure, Axes]:
214
+ """Render the chart in memory; return ``(fig, ax)`` without saving."""
215
+ selected: pd.Series[float] = self.data.loc[:, self.asset] * self.scale
216
+
217
+ if self.common_anchor is not None:
218
+ anchor_value = selected.loc[self._events[self.common_anchor].date]
219
+ else:
220
+ anchor_value = None
221
+
222
+ new_data: list[pd.Series[float]] = []
223
+ for shock_name, ev in self._events.items():
224
+ shock_dt = pd.to_datetime(ev.date, format="%Y-%m-%d")
225
+ date_range = pd.bdate_range(
226
+ start=shock_dt - BDay(ev.days_before),
227
+ periods=ev.days_before + ev.days_after + 1,
228
+ )
229
+ aftermath = selected.reindex(date_range)
230
+ anchor = (
231
+ anchor_value
232
+ if anchor_value is not None
233
+ else aftermath.loc[ev.date]
234
+ )
235
+ # base100: multiplicative index (scale cancels). Otherwise the
236
+ # subtractive form -- the change since the anchor -- where scale
237
+ # maps the raw move to the display unit (e.g. x100 for p.b.).
238
+ if self.base100:
239
+ aftermath = aftermath / anchor * 100
240
+ else:
241
+ aftermath = aftermath - anchor
242
+ aftermath = aftermath.reset_index(drop=True)
243
+ aftermath.index = aftermath.index - ev.days_before
244
+ aftermath.name = shock_name
245
+ new_data.append(aftermath)
246
+
247
+ consolidated = pd.concat(new_data, axis=1)
248
+ linestyles = ["dotted", "dashed", "dashdot", "solid"]
249
+
250
+ font_pt = compact_font_pt(None) if self.compact else None
251
+ fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
252
+ **compact_figure_kwargs(self.compact, self.figsize)
253
+ )
254
+ ax: Axes = fig.add_subplot()
255
+
256
+ # historical shocks in C2 with rotating linestyles; the most recent
257
+ # (last in dict order) highlighted in C0 solid.
258
+ for i, col in enumerate(consolidated.columns[:-1]):
259
+ ax.plot( # type: ignore[reportUnknownMemberType]
260
+ consolidated.index,
261
+ consolidated[col],
262
+ color="C2",
263
+ linestyle=linestyles[i % len(linestyles)],
264
+ label=col,
265
+ )
266
+ last_col = consolidated.columns[-1]
267
+ ax.plot( # type: ignore[reportUnknownMemberType]
268
+ consolidated.index,
269
+ consolidated[last_col],
270
+ color="C0",
271
+ label=last_col,
272
+ )
273
+
274
+ ax.axvline( # type: ignore[reportUnknownMemberType]
275
+ x=0, color="gray", linestyle="solid", alpha=0.2, label="_nolabel_"
276
+ )
277
+ xmin = -max(ev.days_before for ev in self._events.values())
278
+ xmax = max(ev.days_after for ev in self._events.values())
279
+ ax.set_xlim(xmin=xmin, xmax=xmax)
280
+ ax.set_xlabel( # type: ignore[reportUnknownMemberType]
281
+ "Días hábiles tras el evento",
282
+ labelpad=6 if self.compact else 15,
283
+ )
284
+
285
+ style_spines(
286
+ ax,
287
+ decimals=self.fmt.decimals,
288
+ units=self.fmt.units,
289
+ **AX_CONFIG["spines"],
290
+ )
291
+ # base100 anchors at 100; the subtractive form at 0.
292
+ style_baseline(
293
+ ax,
294
+ reference=100 if self.base100 else 0,
295
+ **AX_CONFIG["baseline"],
296
+ )
297
+
298
+ if self.compact:
299
+ style_compact_axes(ax, font_pt)
300
+
301
+ if self.legend is not None:
302
+ place_legend(fig, ax, compact=self.compact, ncol=self.legend.ncol)
303
+
304
+ return fig, ax
305
+
306
+ def save(
307
+ self,
308
+ fig: Figure,
309
+ *,
310
+ path: Path | None = None,
311
+ dpi: int | None = None,
312
+ ) -> Path:
313
+ """Persist *fig* as a PNG and return the path written.
314
+
315
+ Mirrors :meth:`LinePlot.save`: defaults to ``self.out_path``;
316
+ ``dpi`` overrides the save resolution, derived from the figure
317
+ size via :func:`dynamic_dpi` when omitted.
318
+ """
319
+ target: Path = path if path is not None else self.out_path
320
+ save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
321
+ fig.savefig( # type: ignore[reportUnknownMemberType]
322
+ target, dpi=save_dpi
323
+ )
324
+ return target
325
+
326
+ def plot(self) -> Axes:
327
+ """Build the chart and persist it to ``self.out_path``."""
328
+ fig, ax = self.build()
329
+ try:
330
+ self.save(fig)
331
+ finally:
332
+ plt.close(fig)
333
+ return ax
@@ -120,6 +120,7 @@ class TypeCurve:
120
120
  legend: Legend | None = None,
121
121
  figsize: tuple[float, float] | None = None,
122
122
  points_to_mark: list[str] | None = None,
123
+ baseline: bool = True,
123
124
  compact: bool = False,
124
125
  font_pt: float | None = None,
125
126
  ) -> None:
@@ -138,6 +139,7 @@ class TypeCurve:
138
139
  self.legend = legend
139
140
  self.figsize = figsize
140
141
  self.points_to_mark = points_to_mark or []
142
+ self.baseline = baseline
141
143
  self.compact = compact
142
144
  self.font_pt = font_pt
143
145
 
@@ -165,7 +167,11 @@ class TypeCurve:
165
167
  ``scale``, ``fmt`` (``!format``), ``legend``
166
168
  (``!legend``), ``figsize``, ``points_to_mark``
167
169
  (list of ``due_period_code`` to mark on the current
168
- line; codes must appear in ``series``), ``compact``
170
+ line; codes must appear in ``series``), ``baseline``
171
+ (``True`` -- the default -- draws the 0 line and pins
172
+ the y-axis to 0, right for a spread curve; ``False``
173
+ skips it so the y-axis auto-frames an all-positive
174
+ yield/level curve), ``compact``
169
175
  (fixed physical size for two charts per row; thins the
170
176
  45-degree tenor labels to the marked points -- or every
171
177
  other one -- so they do not collide on the narrow
@@ -284,7 +290,12 @@ class TypeCurve:
284
290
  style_compact_axes(ax, font_pt, side="right")
285
291
  else:
286
292
  _rotate_xticks(ax)
287
- style_baseline(ax, 0, **AX_CONFIG["baseline"])
293
+ # the 0 baseline both draws the zero line and extends the y-limits
294
+ # down to 0: right for a spread curve (centred on 0), wrong for an
295
+ # all-positive yield/level curve (it squashes the curve into the top
296
+ # half). baseline=False skips it so the y-axis auto-frames the data.
297
+ if self.baseline:
298
+ style_baseline(ax, 0, **AX_CONFIG["baseline"])
288
299
 
289
300
  if self.compact:
290
301
  legend_ncol = self.legend.ncol if self.legend else None