tesorotools-python 0.0.44__py3-none-any.whl → 0.0.46__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 +6 -0
- tesorotools/artists/__init__.py +4 -0
- tesorotools/artists/matrix.py +302 -0
- tesorotools/artists/vector_plot.py +315 -0
- tesorotools/assets/plots.yaml +24 -0
- tesorotools/providers/__init__.py +11 -4
- tesorotools/providers/imf_irfcl.py +242 -0
- {tesorotools_python-0.0.44.dist-info → tesorotools_python-0.0.46.dist-info}/METADATA +4 -1
- {tesorotools_python-0.0.44.dist-info → tesorotools_python-0.0.46.dist-info}/RECORD +10 -7
- {tesorotools_python-0.0.44.dist-info → tesorotools_python-0.0.46.dist-info}/WHEEL +0 -0
tesorotools/__init__.py
CHANGED
|
@@ -49,9 +49,11 @@ from tesorotools.artists import (
|
|
|
49
49
|
HorizontalBarChart,
|
|
50
50
|
Legend,
|
|
51
51
|
LinePlot,
|
|
52
|
+
MatrixChart,
|
|
52
53
|
StackedAreaPlot,
|
|
53
54
|
StackedBarPlot,
|
|
54
55
|
TypeCurve,
|
|
56
|
+
VectorPlot,
|
|
55
57
|
)
|
|
56
58
|
from tesorotools.orchestration import CompositeRegistry, iter_contexts
|
|
57
59
|
from tesorotools.providers.base import (
|
|
@@ -79,6 +81,8 @@ def _register_builtins() -> None:
|
|
|
79
81
|
register_artist("barh", HorizontalBarChart)
|
|
80
82
|
register_artist("grouped_barh", GroupedBarChart)
|
|
81
83
|
register_artist("type_curve", TypeCurve)
|
|
84
|
+
register_artist("vector_plot", VectorPlot)
|
|
85
|
+
register_artist("matrix", MatrixChart)
|
|
82
86
|
|
|
83
87
|
register_tag("format", Format)
|
|
84
88
|
register_tag("legend", Legend)
|
|
@@ -111,6 +115,7 @@ __all__ = [
|
|
|
111
115
|
"Legend",
|
|
112
116
|
"LinePlot",
|
|
113
117
|
"LSEGProvider",
|
|
118
|
+
"MatrixChart",
|
|
114
119
|
"RegistryProtocol",
|
|
115
120
|
"Report",
|
|
116
121
|
"Section",
|
|
@@ -121,6 +126,7 @@ __all__ = [
|
|
|
121
126
|
"Text",
|
|
122
127
|
"Title",
|
|
123
128
|
"TypeCurve",
|
|
129
|
+
"VectorPlot",
|
|
124
130
|
"YamlConstructor",
|
|
125
131
|
"all_artists",
|
|
126
132
|
"all_providers",
|
tesorotools/artists/__init__.py
CHANGED
|
@@ -29,8 +29,10 @@ import matplotlib.style
|
|
|
29
29
|
from tesorotools.artists._common import Artist, Format, Legend
|
|
30
30
|
from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
|
|
31
31
|
from tesorotools.artists.line_plot import LinePlot
|
|
32
|
+
from tesorotools.artists.matrix import MatrixChart
|
|
32
33
|
from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
|
|
33
34
|
from tesorotools.artists.type_curve import TypeCurve
|
|
35
|
+
from tesorotools.artists.vector_plot import VectorPlot
|
|
34
36
|
from tesorotools.utils.globals import STYLE_SHEET
|
|
35
37
|
|
|
36
38
|
matplotlib.style.use(STYLE_SHEET)
|
|
@@ -42,7 +44,9 @@ __all__ = [
|
|
|
42
44
|
"HorizontalBarChart",
|
|
43
45
|
"Legend",
|
|
44
46
|
"LinePlot",
|
|
47
|
+
"MatrixChart",
|
|
45
48
|
"StackedAreaPlot",
|
|
46
49
|
"StackedBarPlot",
|
|
47
50
|
"TypeCurve",
|
|
51
|
+
"VectorPlot",
|
|
48
52
|
]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Annotated matrix / heatmap-table artist.
|
|
2
|
+
|
|
3
|
+
Defines :class:`MatrixChart`, the YAML-tag-aware artist
|
|
4
|
+
registered as ``!matrix``.
|
|
5
|
+
|
|
6
|
+
The chart is a grid of cells -- the "quadrant" or
|
|
7
|
+
segmentation table where each cell is shaded by its value and
|
|
8
|
+
carries a large number plus an optional descriptive label
|
|
9
|
+
(e.g. financial knowledge x digital skill -> "Capacidad
|
|
10
|
+
alta"). Nothing in the implementation is tied to the 2x2
|
|
11
|
+
case: the grid is driven by an ``n_rows x n_cols`` DataFrame,
|
|
12
|
+
so any rectangular layout works (a 3x3 segmentation, a
|
|
13
|
+
5-bucket x 4-bucket transition matrix, ...).
|
|
14
|
+
|
|
15
|
+
Input is a flat ``pd.DataFrame`` indexed by row id with one
|
|
16
|
+
column per column id -- the same wide shape
|
|
17
|
+
:class:`GroupedBarChart` consumes -- so ``rows`` selects and
|
|
18
|
+
labels the index and ``cols`` selects and labels the columns.
|
|
19
|
+
Cell shading uses a sequential colormap normalised over the
|
|
20
|
+
selected values; text colour flips to stay legible on dark
|
|
21
|
+
cells. ``NaN`` cells are left blank (no fill, no text), which
|
|
22
|
+
covers triangular or sparse matrices.
|
|
23
|
+
|
|
24
|
+
Styling constants come from ``PLOT_CONFIG['matrix']``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Self
|
|
31
|
+
|
|
32
|
+
import matplotlib as mpl
|
|
33
|
+
import matplotlib.pyplot as plt
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pandas as pd
|
|
36
|
+
from matplotlib.axes import Axes
|
|
37
|
+
from matplotlib.colors import Colormap, Normalize
|
|
38
|
+
from matplotlib.figure import Figure
|
|
39
|
+
from matplotlib.patches import Rectangle
|
|
40
|
+
from yaml.nodes import MappingNode
|
|
41
|
+
|
|
42
|
+
from tesorotools.artists._common import FIG_CONFIG, Format, resolve_data
|
|
43
|
+
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
44
|
+
from tesorotools.utils.template import TemplateLoader
|
|
45
|
+
|
|
46
|
+
__all__ = ["MatrixChart"]
|
|
47
|
+
|
|
48
|
+
MATRIX_CONFIG: dict[str, Any] = PLOT_CONFIG["matrix"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _text_color(rgba: tuple[float, float, float, float]) -> str:
|
|
52
|
+
"""Pick a legible text colour for a cell filled with *rgba*.
|
|
53
|
+
|
|
54
|
+
Uses relative luminance: dark fills get the light text
|
|
55
|
+
colour, light fills the dark one.
|
|
56
|
+
"""
|
|
57
|
+
r, g, b, _a = rgba
|
|
58
|
+
luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
|
59
|
+
if luminance < MATRIX_CONFIG["luminance_threshold"]:
|
|
60
|
+
return MATRIX_CONFIG["light_text"]
|
|
61
|
+
return MATRIX_CONFIG["dark_text"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MatrixChart:
|
|
65
|
+
"""Annotated heatmap-table artist (any ``n_rows x n_cols``).
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
out_path
|
|
70
|
+
``.png`` path the rendered chart is saved to.
|
|
71
|
+
data, data_path
|
|
72
|
+
Provide one. A wide DataFrame indexed by row id with
|
|
73
|
+
one column per column id.
|
|
74
|
+
rows
|
|
75
|
+
``id -> label`` for the grid rows, top-to-bottom.
|
|
76
|
+
cols
|
|
77
|
+
``id -> label`` for the grid columns, left-to-right.
|
|
78
|
+
cell_labels
|
|
79
|
+
Optional ``{row_id: {col_id: text}}`` descriptive text
|
|
80
|
+
drawn under each value. Missing entries draw the
|
|
81
|
+
value only.
|
|
82
|
+
scale
|
|
83
|
+
Multiplier applied to the values (e.g. ``100`` to turn
|
|
84
|
+
fractions into percentages).
|
|
85
|
+
fmt
|
|
86
|
+
:class:`Format` for the big per-cell value.
|
|
87
|
+
cmap
|
|
88
|
+
Matplotlib colormap name for the shading (default
|
|
89
|
+
``PLOT_CONFIG['matrix']['cmap']``).
|
|
90
|
+
vmin, vmax
|
|
91
|
+
Value range mapped to the colormap ends. Default to
|
|
92
|
+
the min / max of the selected values.
|
|
93
|
+
xlabel, ylabel
|
|
94
|
+
Axis titles (the column / row variable names).
|
|
95
|
+
figsize
|
|
96
|
+
Override the figure size; defaults to a square so
|
|
97
|
+
cells read as tiles.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
out_path: Path,
|
|
103
|
+
*,
|
|
104
|
+
data: pd.DataFrame | None = None,
|
|
105
|
+
data_path: Path | None = None,
|
|
106
|
+
rows: dict[str, str],
|
|
107
|
+
cols: dict[str, str],
|
|
108
|
+
cell_labels: dict[str, dict[str, str]] | None = None,
|
|
109
|
+
scale: float = 1,
|
|
110
|
+
fmt: Format | None = None,
|
|
111
|
+
cmap: str | None = None,
|
|
112
|
+
vmin: float | None = None,
|
|
113
|
+
vmax: float | None = None,
|
|
114
|
+
xlabel: str = "",
|
|
115
|
+
ylabel: str = "",
|
|
116
|
+
figsize: tuple[float, float] | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
if out_path.suffix != ".png":
|
|
119
|
+
raise ValueError(f"out_path must be .png: {out_path}")
|
|
120
|
+
if not rows or not cols:
|
|
121
|
+
raise ValueError("MatrixChart needs at least one row and column")
|
|
122
|
+
self.out_path = out_path
|
|
123
|
+
self.data = resolve_data(data, data_path)
|
|
124
|
+
self.rows = rows
|
|
125
|
+
self.cols = cols
|
|
126
|
+
self.cell_labels = cell_labels or {}
|
|
127
|
+
self.scale = scale
|
|
128
|
+
self.fmt = fmt or Format()
|
|
129
|
+
self.cmap = cmap or MATRIX_CONFIG["cmap"]
|
|
130
|
+
self.vmin = vmin
|
|
131
|
+
self.vmax = vmax
|
|
132
|
+
self.xlabel = xlabel
|
|
133
|
+
self.ylabel = ylabel
|
|
134
|
+
self.figsize = figsize
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
|
|
138
|
+
"""Build a :class:`MatrixChart` from the ``!matrix`` YAML tag.
|
|
139
|
+
|
|
140
|
+
Required keys
|
|
141
|
+
-------------
|
|
142
|
+
``out_path``
|
|
143
|
+
``.png`` destination path.
|
|
144
|
+
``data_path``
|
|
145
|
+
``.feather`` file with a DataFrame indexed by row
|
|
146
|
+
id and one column per column id.
|
|
147
|
+
``rows`` / ``cols``
|
|
148
|
+
``id -> label`` mappings selecting and ordering the
|
|
149
|
+
grid rows (top-to-bottom) and columns
|
|
150
|
+
(left-to-right).
|
|
151
|
+
|
|
152
|
+
Optional keys (forwarded as-is to ``__init__``)
|
|
153
|
+
----------------------------------------------
|
|
154
|
+
``cell_labels`` (``{row_id: {col_id: text}}``),
|
|
155
|
+
``scale``, ``fmt`` (``!format``), ``cmap``, ``vmin`` /
|
|
156
|
+
``vmax``, ``xlabel`` / ``ylabel``, ``figsize``.
|
|
157
|
+
|
|
158
|
+
Example
|
|
159
|
+
-------
|
|
160
|
+
.. code-block:: yaml
|
|
161
|
+
|
|
162
|
+
capacidad: !matrix
|
|
163
|
+
out_path: out/capacidad.png
|
|
164
|
+
data_path: data/capacidad.feather
|
|
165
|
+
xlabel: "Habilidad digital"
|
|
166
|
+
ylabel: "Conocimientos financieros"
|
|
167
|
+
fmt: !format {decimals: 1, units: "%"}
|
|
168
|
+
rows:
|
|
169
|
+
altos: "Altos"
|
|
170
|
+
bajos: "Bajos"
|
|
171
|
+
cols:
|
|
172
|
+
baja: "Baja"
|
|
173
|
+
alta: "Alta"
|
|
174
|
+
cell_labels:
|
|
175
|
+
altos: {baja: "Solo conocimiento financiero", alta: "Capacidad alta"}
|
|
176
|
+
bajos: {baja: "Capacidad baja", alta: "Solo habilidad digital"}
|
|
177
|
+
"""
|
|
178
|
+
cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
|
|
179
|
+
node, deep=True
|
|
180
|
+
)
|
|
181
|
+
cfg.pop("id")
|
|
182
|
+
cfg["out_path"] = Path(cfg["out_path"])
|
|
183
|
+
if "data_path" in cfg:
|
|
184
|
+
cfg["data_path"] = Path(cfg["data_path"])
|
|
185
|
+
return cls(**cfg)
|
|
186
|
+
|
|
187
|
+
def plot(self) -> Axes:
|
|
188
|
+
"""Render the chart and persist it to ``self.out_path``."""
|
|
189
|
+
row_ids = list(self.rows)
|
|
190
|
+
col_ids = list(self.cols)
|
|
191
|
+
missing_rows = [r for r in row_ids if r not in self.data.index]
|
|
192
|
+
missing_cols = [c for c in col_ids if c not in self.data.columns]
|
|
193
|
+
if missing_rows or missing_cols:
|
|
194
|
+
raise KeyError(
|
|
195
|
+
"MatrixChart ids not found in data — "
|
|
196
|
+
f"rows: {missing_rows}, cols: {missing_cols}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
matrix = self.data.loc[row_ids, col_ids] * self.scale
|
|
200
|
+
n_rows = len(row_ids)
|
|
201
|
+
|
|
202
|
+
values_arr = matrix.to_numpy(dtype=float)
|
|
203
|
+
valid = values_arr[~np.isnan(values_arr)]
|
|
204
|
+
vmin = self.vmin if self.vmin is not None else float(valid.min())
|
|
205
|
+
vmax = self.vmax if self.vmax is not None else float(valid.max())
|
|
206
|
+
norm = Normalize(vmin=vmin, vmax=vmax)
|
|
207
|
+
cmap: Colormap = mpl.colormaps[self.cmap]
|
|
208
|
+
|
|
209
|
+
fig_kw = dict(FIG_CONFIG)
|
|
210
|
+
fig_kw["figsize"] = self.figsize if self.figsize is not None else (6, 6)
|
|
211
|
+
fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
|
|
212
|
+
**fig_kw
|
|
213
|
+
)
|
|
214
|
+
ax: Axes = fig.add_subplot()
|
|
215
|
+
|
|
216
|
+
gap: float = MATRIX_CONFIG["gap"]
|
|
217
|
+
for i, row_id in enumerate(row_ids):
|
|
218
|
+
# Row 0 sits at the top, so its cell row is the highest y.
|
|
219
|
+
y_row = n_rows - 1 - i
|
|
220
|
+
for j, col_id in enumerate(col_ids):
|
|
221
|
+
value = float(values_arr[i, j])
|
|
222
|
+
if np.isnan(value):
|
|
223
|
+
continue
|
|
224
|
+
rgba: tuple[float, float, float, float] = cmap(norm(value))
|
|
225
|
+
ax.add_patch(
|
|
226
|
+
Rectangle(
|
|
227
|
+
(j + gap / 2, y_row + gap / 2),
|
|
228
|
+
1 - gap,
|
|
229
|
+
1 - gap,
|
|
230
|
+
facecolor=rgba,
|
|
231
|
+
edgecolor="none",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
self._annotate_cell(ax, j + 0.5, y_row + 0.5, value, rgba)
|
|
235
|
+
label = self.cell_labels.get(row_id, {}).get(col_id)
|
|
236
|
+
if label:
|
|
237
|
+
ax.text( # type: ignore[reportUnknownMemberType]
|
|
238
|
+
j + 0.5,
|
|
239
|
+
y_row + 0.5 - MATRIX_CONFIG["label_offset"],
|
|
240
|
+
label,
|
|
241
|
+
ha="center",
|
|
242
|
+
va="center",
|
|
243
|
+
color=_text_color(rgba),
|
|
244
|
+
fontsize=MATRIX_CONFIG["label_fontsize"],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
self._style_axes(ax, row_ids, col_ids)
|
|
248
|
+
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
249
|
+
self.out_path
|
|
250
|
+
)
|
|
251
|
+
plt.close(fig)
|
|
252
|
+
return ax
|
|
253
|
+
|
|
254
|
+
def _annotate_cell(
|
|
255
|
+
self,
|
|
256
|
+
ax: Axes,
|
|
257
|
+
cx: float,
|
|
258
|
+
cy: float,
|
|
259
|
+
value: float,
|
|
260
|
+
rgba: tuple[float, float, float, float],
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Draw the big formatted value near a cell's centre."""
|
|
263
|
+
ax.text( # type: ignore[reportUnknownMemberType]
|
|
264
|
+
cx,
|
|
265
|
+
cy + MATRIX_CONFIG["value_offset"],
|
|
266
|
+
format_annotation(value, self.fmt.decimals, self.fmt.units),
|
|
267
|
+
ha="center",
|
|
268
|
+
va="center",
|
|
269
|
+
color=_text_color(rgba),
|
|
270
|
+
fontsize=MATRIX_CONFIG["value_fontsize"],
|
|
271
|
+
fontweight=MATRIX_CONFIG["value_weight"],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _style_axes(
|
|
275
|
+
self, ax: Axes, row_ids: list[str], col_ids: list[str]
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Category ticks, axis titles and a frameless look."""
|
|
278
|
+
n_rows, n_cols = len(row_ids), len(col_ids)
|
|
279
|
+
ax.set_xlim(0, n_cols)
|
|
280
|
+
ax.set_ylim(0, n_rows)
|
|
281
|
+
ax.set_aspect("auto")
|
|
282
|
+
|
|
283
|
+
ax.set_xticks([j + 0.5 for j in range(n_cols)]) # type: ignore[reportUnknownMemberType]
|
|
284
|
+
ax.set_xticklabels( # type: ignore[reportUnknownMemberType]
|
|
285
|
+
[self.cols[c] for c in col_ids]
|
|
286
|
+
)
|
|
287
|
+
# Row 0 is drawn at the top, so its tick is the highest y.
|
|
288
|
+
ax.set_yticks([n_rows - 1 - i + 0.5 for i in range(n_rows)]) # type: ignore[reportUnknownMemberType]
|
|
289
|
+
ax.set_yticklabels( # type: ignore[reportUnknownMemberType]
|
|
290
|
+
[self.rows[r] for r in row_ids]
|
|
291
|
+
)
|
|
292
|
+
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
293
|
+
axis="both", length=0
|
|
294
|
+
)
|
|
295
|
+
for spine in ax.spines.values():
|
|
296
|
+
spine.set_visible(False)
|
|
297
|
+
ax.set_xlabel( # type: ignore[reportUnknownMemberType]
|
|
298
|
+
self.xlabel
|
|
299
|
+
)
|
|
300
|
+
ax.set_ylabel( # type: ignore[reportUnknownMemberType]
|
|
301
|
+
self.ylabel
|
|
302
|
+
)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Vector (arrow) chart artist.
|
|
2
|
+
|
|
3
|
+
Defines :class:`VectorPlot`, the YAML-tag-aware artist
|
|
4
|
+
registered as ``!vector_plot``.
|
|
5
|
+
|
|
6
|
+
Each entity is drawn as an arrow in a 2D plane from a start
|
|
7
|
+
point to an end point, so a single chart shows how *two*
|
|
8
|
+
variables moved between two periods at once. The common case
|
|
9
|
+
-- arrows fanning out from the origin, as in the classic
|
|
10
|
+
"public debt vs. bond yield since 2020" chart -- falls out
|
|
11
|
+
when the data already holds the *change* in each variable and
|
|
12
|
+
no start columns are given: the start defaults to ``(0, 0)``.
|
|
13
|
+
|
|
14
|
+
Unlike the time-series artists, the input DataFrame is indexed
|
|
15
|
+
by entity code (one row per arrow) and the two axes carry two
|
|
16
|
+
different variables, each with its own optional :class:`Format`
|
|
17
|
+
(``x_fmt`` / ``y_fmt``). Computing the period-over-period
|
|
18
|
+
change is the pipeline's job; this artist only renders the
|
|
19
|
+
pre-computed coordinates.
|
|
20
|
+
|
|
21
|
+
Layout and the shared ``Format`` / ``Legend`` config holders
|
|
22
|
+
live in :mod:`tesorotools.artists._common`; arrow styling
|
|
23
|
+
constants come from ``PLOT_CONFIG['vector']``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Self
|
|
30
|
+
|
|
31
|
+
import matplotlib.pyplot as plt
|
|
32
|
+
import pandas as pd
|
|
33
|
+
from matplotlib.axes import Axes
|
|
34
|
+
from matplotlib.figure import Figure
|
|
35
|
+
from matplotlib.ticker import FuncFormatter
|
|
36
|
+
from yaml.nodes import MappingNode
|
|
37
|
+
|
|
38
|
+
from tesorotools.artists._common import (
|
|
39
|
+
AX_CONFIG,
|
|
40
|
+
FIG_CONFIG,
|
|
41
|
+
Format,
|
|
42
|
+
resolve_data,
|
|
43
|
+
)
|
|
44
|
+
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
45
|
+
from tesorotools.utils.template import TemplateLoader
|
|
46
|
+
|
|
47
|
+
__all__ = ["VectorPlot"]
|
|
48
|
+
|
|
49
|
+
VECTOR_CONFIG: dict[str, Any] = PLOT_CONFIG["vector"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class VectorPlot:
|
|
53
|
+
"""Arrow chart showing the change in two variables.
|
|
54
|
+
|
|
55
|
+
Each entity in ``series`` is rendered as an arrow from a
|
|
56
|
+
start point to an end point. With only ``x`` / ``y``
|
|
57
|
+
given, arrows start at the origin and their tips encode
|
|
58
|
+
the change in each variable (the usual "since date X"
|
|
59
|
+
chart). Provide ``x_start`` / ``y_start`` to draw arrows
|
|
60
|
+
between two arbitrary points instead (period-to-period
|
|
61
|
+
migration in the plane).
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
out_path
|
|
66
|
+
``.png`` destination path.
|
|
67
|
+
data / data_path
|
|
68
|
+
The source DataFrame (in memory) or a ``.feather``
|
|
69
|
+
file; exactly one. Indexed by entity code, one row
|
|
70
|
+
per arrow.
|
|
71
|
+
series
|
|
72
|
+
``entity_code -> display_label`` mapping. Selects and
|
|
73
|
+
orders the rows drawn; the codes must appear in the
|
|
74
|
+
DataFrame index.
|
|
75
|
+
x, y
|
|
76
|
+
Column names holding the arrow-tip coordinates.
|
|
77
|
+
x_start, y_start
|
|
78
|
+
Optional column names for the arrow tails. When
|
|
79
|
+
omitted the tail sits at ``0`` on that axis.
|
|
80
|
+
x_scale, y_scale
|
|
81
|
+
Per-axis multipliers applied to the raw values (e.g.
|
|
82
|
+
``100`` to turn rates into basis points).
|
|
83
|
+
xlabel, ylabel
|
|
84
|
+
Axis titles.
|
|
85
|
+
x_fmt, y_fmt
|
|
86
|
+
Per-axis :class:`Format` controlling tick decimals and
|
|
87
|
+
units.
|
|
88
|
+
figsize
|
|
89
|
+
Optional ``(width, height)`` in inches.
|
|
90
|
+
series_styles
|
|
91
|
+
Per-entity matplotlib kwargs for the arrow; ``color``
|
|
92
|
+
and ``linestyle`` are honoured (``linestyle: dashed``
|
|
93
|
+
reproduces the dashed arrows used for outliers).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
out_path: Path,
|
|
99
|
+
*,
|
|
100
|
+
data: pd.DataFrame | None = None,
|
|
101
|
+
data_path: Path | None = None,
|
|
102
|
+
series: dict[str, str],
|
|
103
|
+
x: str,
|
|
104
|
+
y: str,
|
|
105
|
+
x_start: str | None = None,
|
|
106
|
+
y_start: str | None = None,
|
|
107
|
+
x_scale: float = 1,
|
|
108
|
+
y_scale: float = 1,
|
|
109
|
+
xlabel: str = "",
|
|
110
|
+
ylabel: str = "",
|
|
111
|
+
x_fmt: Format | None = None,
|
|
112
|
+
y_fmt: Format | None = None,
|
|
113
|
+
figsize: tuple[float, float] | None = None,
|
|
114
|
+
series_styles: dict[str, dict[str, Any]] | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
if out_path.suffix != ".png":
|
|
117
|
+
raise ValueError(f"out_path must be .png: {out_path}")
|
|
118
|
+
if not series:
|
|
119
|
+
raise ValueError("VectorPlot needs at least one entity in `series`")
|
|
120
|
+
self.out_path = out_path
|
|
121
|
+
self.data = resolve_data(data, data_path)
|
|
122
|
+
self.series = series
|
|
123
|
+
self.x = x
|
|
124
|
+
self.y = y
|
|
125
|
+
self.x_start = x_start
|
|
126
|
+
self.y_start = y_start
|
|
127
|
+
self.x_scale = x_scale
|
|
128
|
+
self.y_scale = y_scale
|
|
129
|
+
self.xlabel = xlabel
|
|
130
|
+
self.ylabel = ylabel
|
|
131
|
+
self.x_fmt = x_fmt or Format()
|
|
132
|
+
self.y_fmt = y_fmt or Format()
|
|
133
|
+
self.figsize = figsize
|
|
134
|
+
self.series_styles = series_styles or {}
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
|
|
138
|
+
"""Build a :class:`VectorPlot` from the ``!vector_plot`` YAML tag.
|
|
139
|
+
|
|
140
|
+
Required keys
|
|
141
|
+
-------------
|
|
142
|
+
``out_path``
|
|
143
|
+
``.png`` destination path.
|
|
144
|
+
``data_path``
|
|
145
|
+
``.feather`` file indexed by entity code, holding
|
|
146
|
+
the arrow-tip columns.
|
|
147
|
+
``series``
|
|
148
|
+
``entity_code -> display_label`` mapping; the codes
|
|
149
|
+
must appear in the DataFrame index.
|
|
150
|
+
``x`` / ``y``
|
|
151
|
+
Column names for the arrow-tip coordinates.
|
|
152
|
+
|
|
153
|
+
Optional keys (forwarded as-is to ``__init__``)
|
|
154
|
+
----------------------------------------------
|
|
155
|
+
``x_start`` / ``y_start``, ``x_scale`` / ``y_scale``,
|
|
156
|
+
``xlabel`` / ``ylabel``, ``x_fmt`` / ``y_fmt``
|
|
157
|
+
(``!format``), ``figsize``, ``series_styles``.
|
|
158
|
+
|
|
159
|
+
Example
|
|
160
|
+
-------
|
|
161
|
+
.. code-block:: yaml
|
|
162
|
+
|
|
163
|
+
debt_vs_yield: !vector_plot
|
|
164
|
+
out_path: out/debt_yield.png
|
|
165
|
+
data_path: data/debt_yield.feather
|
|
166
|
+
x: debt_change
|
|
167
|
+
y: yield_change
|
|
168
|
+
xlabel: "Deuda pública (% PIB), dif. desde ene-2020"
|
|
169
|
+
ylabel: "Var. tir 10A desde ene-2020"
|
|
170
|
+
x_fmt: !format {decimals: 1, units: "%"}
|
|
171
|
+
y_fmt: !format {decimals: 1}
|
|
172
|
+
series:
|
|
173
|
+
ES: "ES"
|
|
174
|
+
DE: "DE"
|
|
175
|
+
IT: "IT"
|
|
176
|
+
series_styles:
|
|
177
|
+
IT: {linestyle: dashed}
|
|
178
|
+
"""
|
|
179
|
+
cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
|
|
180
|
+
node, deep=True
|
|
181
|
+
)
|
|
182
|
+
cfg.pop("id")
|
|
183
|
+
cfg["out_path"] = Path(cfg["out_path"])
|
|
184
|
+
if "data_path" in cfg:
|
|
185
|
+
cfg["data_path"] = Path(cfg["data_path"])
|
|
186
|
+
return cls(**cfg)
|
|
187
|
+
|
|
188
|
+
def _coords(self, code: str) -> tuple[float, float, float, float]:
|
|
189
|
+
"""Return the ``(x0, y0, x1, y1)`` arrow for *code*, scaled."""
|
|
190
|
+
row: pd.Series[Any] = self.data.loc[code] # type: ignore[assignment]
|
|
191
|
+
x1 = float(row[self.x]) * self.x_scale
|
|
192
|
+
y1 = float(row[self.y]) * self.y_scale
|
|
193
|
+
x0 = float(row[self.x_start]) * self.x_scale if self.x_start else 0.0
|
|
194
|
+
y0 = float(row[self.y_start]) * self.y_scale if self.y_start else 0.0
|
|
195
|
+
return x0, y0, x1, y1
|
|
196
|
+
|
|
197
|
+
def plot(self) -> Axes:
|
|
198
|
+
needed = [self.x, self.y]
|
|
199
|
+
if self.x_start is not None:
|
|
200
|
+
needed.append(self.x_start)
|
|
201
|
+
if self.y_start is not None:
|
|
202
|
+
needed.append(self.y_start)
|
|
203
|
+
missing = [c for c in needed if c not in self.data.columns]
|
|
204
|
+
if missing:
|
|
205
|
+
raise KeyError(
|
|
206
|
+
f"VectorPlot columns not found in data: {missing}. "
|
|
207
|
+
f"Available: {list(self.data.columns)}"
|
|
208
|
+
)
|
|
209
|
+
unknown = [c for c in self.series if c not in self.data.index]
|
|
210
|
+
if unknown:
|
|
211
|
+
raise KeyError(
|
|
212
|
+
f"VectorPlot series codes not in data index: {unknown}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
fig_kw = dict(FIG_CONFIG)
|
|
216
|
+
if self.figsize is not None:
|
|
217
|
+
fig_kw["figsize"] = self.figsize
|
|
218
|
+
fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
|
|
219
|
+
**fig_kw
|
|
220
|
+
)
|
|
221
|
+
ax: Axes = fig.add_subplot()
|
|
222
|
+
|
|
223
|
+
cycle: list[str] = plt.rcParams["axes.prop_cycle"].by_key()["color"]
|
|
224
|
+
xs: list[float] = [0.0]
|
|
225
|
+
ys: list[float] = [0.0]
|
|
226
|
+
for i, code in enumerate(self.series):
|
|
227
|
+
x0, y0, x1, y1 = self._coords(code)
|
|
228
|
+
xs.extend((x0, x1))
|
|
229
|
+
ys.extend((y0, y1))
|
|
230
|
+
style = self.series_styles.get(code, {})
|
|
231
|
+
color: Any = style.get("color", cycle[i % len(cycle)])
|
|
232
|
+
linestyle: Any = style.get("linestyle", "solid")
|
|
233
|
+
ax.annotate( # type: ignore[reportUnknownMemberType]
|
|
234
|
+
"",
|
|
235
|
+
xy=(x1, y1),
|
|
236
|
+
xytext=(x0, y0),
|
|
237
|
+
arrowprops={
|
|
238
|
+
"arrowstyle": VECTOR_CONFIG["arrowstyle"],
|
|
239
|
+
"color": color,
|
|
240
|
+
"linestyle": linestyle,
|
|
241
|
+
"linewidth": VECTOR_CONFIG["linewidth"],
|
|
242
|
+
"mutation_scale": VECTOR_CONFIG["mutation_scale"],
|
|
243
|
+
"shrinkA": 0,
|
|
244
|
+
"shrinkB": 0,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
pad: float = VECTOR_CONFIG["label_pad"]
|
|
248
|
+
ax.annotate( # type: ignore[reportUnknownMemberType]
|
|
249
|
+
self.series[code],
|
|
250
|
+
xy=(x1, y1),
|
|
251
|
+
xytext=(
|
|
252
|
+
pad if x1 >= x0 else -pad,
|
|
253
|
+
pad if y1 >= y0 else -pad,
|
|
254
|
+
),
|
|
255
|
+
textcoords="offset points",
|
|
256
|
+
color=color,
|
|
257
|
+
ha="left" if x1 >= x0 else "right",
|
|
258
|
+
va="bottom" if y1 >= y0 else "top",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
self._style_axes(ax, xs, ys)
|
|
262
|
+
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
263
|
+
self.out_path
|
|
264
|
+
)
|
|
265
|
+
plt.close(fig)
|
|
266
|
+
return ax
|
|
267
|
+
|
|
268
|
+
def _style_axes(self, ax: Axes, xs: list[float], ys: list[float]) -> None:
|
|
269
|
+
"""Frame, origin cross-hairs, tick formatters and labels."""
|
|
270
|
+
margin: float = VECTOR_CONFIG["margin"]
|
|
271
|
+
_set_limits(ax.set_xlim, xs, margin)
|
|
272
|
+
_set_limits(ax.set_ylim, ys, margin)
|
|
273
|
+
|
|
274
|
+
center = dict(VECTOR_CONFIG["center_line"])
|
|
275
|
+
ax.axvline( # type: ignore[reportUnknownMemberType]
|
|
276
|
+
x=0, **center
|
|
277
|
+
)
|
|
278
|
+
ax.axhline( # type: ignore[reportUnknownMemberType]
|
|
279
|
+
y=0, **AX_CONFIG["baseline"]
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
spines = AX_CONFIG["spines"]
|
|
283
|
+
for spine in ax.spines.values():
|
|
284
|
+
spine.set_color(spines["color"])
|
|
285
|
+
spine.set_linewidth(spines["linewidth"])
|
|
286
|
+
|
|
287
|
+
def _fmt_x(v: float, _pos: int) -> str:
|
|
288
|
+
return format_annotation(v, self.x_fmt.decimals, self.x_fmt.units)
|
|
289
|
+
|
|
290
|
+
def _fmt_y(v: float, _pos: int) -> str:
|
|
291
|
+
return format_annotation(v, self.y_fmt.decimals, self.y_fmt.units)
|
|
292
|
+
|
|
293
|
+
ax.xaxis.set_major_formatter(FuncFormatter(_fmt_x))
|
|
294
|
+
ax.yaxis.set_major_formatter(FuncFormatter(_fmt_y))
|
|
295
|
+
ax.set_xlabel( # type: ignore[reportUnknownMemberType]
|
|
296
|
+
self.xlabel
|
|
297
|
+
)
|
|
298
|
+
ax.set_ylabel( # type: ignore[reportUnknownMemberType]
|
|
299
|
+
self.ylabel
|
|
300
|
+
)
|
|
301
|
+
for tick in (*ax.get_xticklines(), *ax.get_yticklines()):
|
|
302
|
+
tick.set_markeredgecolor(spines["color"])
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _set_limits(setter: Any, values: list[float], margin: float) -> None:
|
|
306
|
+
"""Set an axis range to span *values* with *margin* padding.
|
|
307
|
+
|
|
308
|
+
The origin is always included (the caller seeds ``values``
|
|
309
|
+
with ``0``). A degenerate span collapses to ``±1`` so the
|
|
310
|
+
arrow stays visible.
|
|
311
|
+
"""
|
|
312
|
+
lo, hi = min(values), max(values)
|
|
313
|
+
span = hi - lo
|
|
314
|
+
pad = span * margin if span > 0 else 1.0
|
|
315
|
+
setter(lo - pad, hi + pad)
|
tesorotools/assets/plots.yaml
CHANGED
|
@@ -29,6 +29,30 @@ type_curve:
|
|
|
29
29
|
marker: D
|
|
30
30
|
color: C0
|
|
31
31
|
|
|
32
|
+
vector:
|
|
33
|
+
linewidth: 2
|
|
34
|
+
arrowstyle: "-|>"
|
|
35
|
+
mutation_scale: 14 # arrow-head size in points
|
|
36
|
+
label_pad: 5 # gap (points) between arrow head and its label
|
|
37
|
+
margin: 0.15 # fraction of the data range left as padding
|
|
38
|
+
center_line:
|
|
39
|
+
color: gray
|
|
40
|
+
linestyle: dashed
|
|
41
|
+
linewidth: 1
|
|
42
|
+
zorder: 1
|
|
43
|
+
|
|
44
|
+
matrix:
|
|
45
|
+
cmap: Blues
|
|
46
|
+
gap: 0.04 # white gutter between cells (fraction of a cell)
|
|
47
|
+
value_fontsize: 26
|
|
48
|
+
label_fontsize: 13
|
|
49
|
+
value_weight: bold
|
|
50
|
+
light_text: white # value/label colour on dark cells
|
|
51
|
+
dark_text: "#1a1a1a" # value/label colour on light cells
|
|
52
|
+
luminance_threshold: 0.55
|
|
53
|
+
value_offset: 0.08 # value sits this fraction of a cell above centre
|
|
54
|
+
label_offset: 0.14 # label sits this fraction of a cell below centre
|
|
55
|
+
|
|
32
56
|
barh:
|
|
33
57
|
highlight_factor: 0.4
|
|
34
58
|
padding: 5
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Public provider API.
|
|
2
2
|
|
|
3
|
-
``BdeProvider``, ``EcbProvider
|
|
4
|
-
the optional ``[bde]`` / ``[ecb]`` /
|
|
5
|
-
imported lazily through
|
|
6
|
-
``tesorotools.providers`` itself does not
|
|
3
|
+
``BdeProvider``, ``EcbProvider``, ``ImfIrfclProvider`` and
|
|
4
|
+
``LSEGProvider`` depend on the optional ``[bde]`` / ``[ecb]`` /
|
|
5
|
+
``[imf]`` / ``[lseg]`` extras and are imported lazily through
|
|
6
|
+
``__getattr__``; importing ``tesorotools.providers`` itself does not
|
|
7
|
+
require those extras.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from typing import TYPE_CHECKING, Any
|
|
@@ -17,12 +18,14 @@ from tesorotools.providers.base import (
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
19
|
from tesorotools.providers.bde import BdeProvider
|
|
19
20
|
from tesorotools.providers.ecb import EcbProvider
|
|
21
|
+
from tesorotools.providers.imf_irfcl import ImfIrfclProvider
|
|
20
22
|
from tesorotools.providers.lseg import LSEGProvider
|
|
21
23
|
|
|
22
24
|
__all__ = [
|
|
23
25
|
"BdeProvider",
|
|
24
26
|
"DataProvider",
|
|
25
27
|
"EcbProvider",
|
|
28
|
+
"ImfIrfclProvider",
|
|
26
29
|
"LSEGProvider",
|
|
27
30
|
"RegistryProtocol",
|
|
28
31
|
"bootstrap_providers",
|
|
@@ -38,6 +41,10 @@ def __getattr__(name: str) -> Any:
|
|
|
38
41
|
from tesorotools.providers.ecb import EcbProvider
|
|
39
42
|
|
|
40
43
|
return EcbProvider
|
|
44
|
+
if name == "ImfIrfclProvider":
|
|
45
|
+
from tesorotools.providers.imf_irfcl import ImfIrfclProvider
|
|
46
|
+
|
|
47
|
+
return ImfIrfclProvider
|
|
41
48
|
if name == "LSEGProvider":
|
|
42
49
|
from tesorotools.providers.lseg import LSEGProvider
|
|
43
50
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""IMF IRFCL data provider via the official SDMX 3.0 REST API.
|
|
2
|
+
|
|
3
|
+
Downloads time series from the IMF's IRFCL dataflow (International
|
|
4
|
+
Reserves and Foreign Currency Liquidity), which publishes official
|
|
5
|
+
reserve assets per country, gold included. No authentication
|
|
6
|
+
required.
|
|
7
|
+
|
|
8
|
+
Install with the ``imf`` optional extra::
|
|
9
|
+
|
|
10
|
+
uv pip install "tesorotools-python[imf]"
|
|
11
|
+
|
|
12
|
+
Indicators (verified against the API in jun-2026):
|
|
13
|
+
|
|
14
|
+
* ``IRFCLDT1_IRFCL56V_FTO`` — gold, VOLUME in fine troy ounces. This
|
|
15
|
+
is the **physical stock** (despite the IMF label saying "millions of
|
|
16
|
+
ounces", the raw value comes in ounces: Spain ≈ 9,053,750 oz ≈
|
|
17
|
+
281.6 t).
|
|
18
|
+
* ``IRFCLDT1_IRFCL56_USD`` — gold value in USD (approx. market value).
|
|
19
|
+
* ``IRFCLDT1_IRFCL65_USD`` — TOTAL official reserve assets in USD
|
|
20
|
+
(denominator of the % gold represents over reserves).
|
|
21
|
+
|
|
22
|
+
API base
|
|
23
|
+
--------
|
|
24
|
+
``https://api.imf.org/external/sdmx/3.0/data/dataflow/IMF.STA/IRFCL/~/{key}``
|
|
25
|
+
|
|
26
|
+
Code convention (4 dimensions of DSD_IRFCL_PUB)
|
|
27
|
+
-----------------------------------------------
|
|
28
|
+
::
|
|
29
|
+
|
|
30
|
+
{COUNTRY}.{INDICATOR}.{SECTOR}.{FREQUENCY}
|
|
31
|
+
|
|
32
|
+
* COUNTRY ISO-3 country code (``ESP``, ``USA``, ``DEU`` …).
|
|
33
|
+
* INDICATOR the IRFCL indicator (``IRFCLDT1_IRFCL56V_FTO`` …).
|
|
34
|
+
* SECTOR ``*`` (wildcard). Countries report gold under different
|
|
35
|
+
sectors (Spain ``S1X``, the US ``S1XS1311`` …); the
|
|
36
|
+
wildcard captures them all. When a key returns several
|
|
37
|
+
series (same gold total under different sectors) they are
|
|
38
|
+
collapsed by date in :meth:`_fetch_one` (last write wins;
|
|
39
|
+
values coincide).
|
|
40
|
+
* FREQUENCY ``M`` (monthly; reserves are published every month).
|
|
41
|
+
|
|
42
|
+
Keys are passed verbatim to the API; this provider does not parse them.
|
|
43
|
+
|
|
44
|
+
Date label
|
|
45
|
+
----------
|
|
46
|
+
IRFCL labels the period and the observation is the end-of-period
|
|
47
|
+
balance. We map to the real end of period:
|
|
48
|
+
|
|
49
|
+
* ``"2026"`` → 2026-12-31
|
|
50
|
+
* ``"2026-Q1"`` → 2026-03-31
|
|
51
|
+
* ``"2026-M04"`` → 2026-04-30
|
|
52
|
+
|
|
53
|
+
Corporate TLS
|
|
54
|
+
-------------
|
|
55
|
+
``truststore`` is enabled on import (if installed) so the provider
|
|
56
|
+
works behind MINECO's TLS proxy without ``verify=False``.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import logging
|
|
62
|
+
from typing import ClassVar
|
|
63
|
+
|
|
64
|
+
import pandas as pd
|
|
65
|
+
import requests
|
|
66
|
+
|
|
67
|
+
from tesorotools.providers.base import DataProvider
|
|
68
|
+
|
|
69
|
+
logger = logging.getLogger(__name__)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _enable_truststore_once() -> bool:
|
|
73
|
+
"""Enable truststore (the OS cert store) if available."""
|
|
74
|
+
try:
|
|
75
|
+
import truststore # type: ignore[import-not-found]
|
|
76
|
+
except ImportError:
|
|
77
|
+
return False
|
|
78
|
+
truststore.inject_into_ssl() # pyright: ignore[reportUnknownMemberType]
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_TRUSTSTORE_ACTIVE = _enable_truststore_once()
|
|
83
|
+
|
|
84
|
+
_API_BASE = (
|
|
85
|
+
"https://api.imf.org/external/sdmx/3.0/data/dataflow/IMF.STA/IRFCL/~"
|
|
86
|
+
)
|
|
87
|
+
_DEFAULT_TIMEOUT = 60
|
|
88
|
+
_DEFAULT_START = "2000"
|
|
89
|
+
_HEADERS = {"Accept": "application/vnd.sdmx.data+json"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ImfIrfclProvider(DataProvider):
|
|
93
|
+
"""Provider that downloads IMF IRFCL series via SDMX 3.0.
|
|
94
|
+
|
|
95
|
+
One HTTP request per key. Series with no observations (a country
|
|
96
|
+
that does not report that indicator) are skipped with a warning
|
|
97
|
+
instead of aborting the whole download.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
timeout
|
|
102
|
+
Max seconds per HTTP request.
|
|
103
|
+
session
|
|
104
|
+
Optional pre-built ``requests.Session``. Useful for tests or
|
|
105
|
+
for custom retry policies.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
PROVIDER_NAME: ClassVar[str] = "imf_irfcl"
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
114
|
+
session: requests.Session | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._timeout = timeout
|
|
117
|
+
self._session = session or requests.Session()
|
|
118
|
+
|
|
119
|
+
def fetch(
|
|
120
|
+
self,
|
|
121
|
+
codes: list[str],
|
|
122
|
+
start: str | None = None,
|
|
123
|
+
end: str | None = None,
|
|
124
|
+
) -> pd.DataFrame:
|
|
125
|
+
"""Download a list of IRFCL keys and return a wide DataFrame.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
codes
|
|
130
|
+
List of IRFCL keys (``"{COUNTRY}.{INDICATOR}.{SECTOR}.{FREQ}"``).
|
|
131
|
+
start
|
|
132
|
+
Start period (e.g. ``"2024"``). If ``None`` defaults to
|
|
133
|
+
``"2000"``.
|
|
134
|
+
end
|
|
135
|
+
End date as ISO string. If ``None`` up to latest.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
pd.DataFrame
|
|
140
|
+
Wide DataFrame. Index = dates (tz-naive), columns = the
|
|
141
|
+
requested codes. Missing observations are NaN.
|
|
142
|
+
"""
|
|
143
|
+
if not codes:
|
|
144
|
+
return pd.DataFrame()
|
|
145
|
+
|
|
146
|
+
cols: dict[str, pd.Series[float]] = {}
|
|
147
|
+
for code in codes:
|
|
148
|
+
try:
|
|
149
|
+
series = self._fetch_one(code, start=start)
|
|
150
|
+
except requests.RequestException as e:
|
|
151
|
+
logger.warning("IRFCL: failed downloading %s (%s)", code, e)
|
|
152
|
+
continue
|
|
153
|
+
if series is not None and not series.empty:
|
|
154
|
+
cols[code] = series
|
|
155
|
+
|
|
156
|
+
if not cols:
|
|
157
|
+
return pd.DataFrame()
|
|
158
|
+
|
|
159
|
+
df = pd.DataFrame(cols).sort_index()
|
|
160
|
+
df.index.name = "date"
|
|
161
|
+
if end:
|
|
162
|
+
df = df.loc[df.index <= pd.Timestamp(end)]
|
|
163
|
+
return df
|
|
164
|
+
|
|
165
|
+
def is_available(self) -> bool:
|
|
166
|
+
"""Check whether the IMF API responds.
|
|
167
|
+
|
|
168
|
+
Hits a small ESP gold-volume query.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
r = self._session.get(
|
|
172
|
+
f"{_API_BASE}/ESP.IRFCLDT1_IRFCL56V_FTO.*.M?startPeriod=2024",
|
|
173
|
+
timeout=10,
|
|
174
|
+
headers=_HEADERS,
|
|
175
|
+
)
|
|
176
|
+
return r.status_code == 200
|
|
177
|
+
except requests.RequestException:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def _fetch_one(
|
|
181
|
+
self, code: str, start: str | None = None
|
|
182
|
+
) -> "pd.Series[float] | None":
|
|
183
|
+
"""Download a single IRFCL series and return it as a Series."""
|
|
184
|
+
url = f"{_API_BASE}/{code}?startPeriod={start or _DEFAULT_START}"
|
|
185
|
+
r = self._session.get(url, timeout=self._timeout, headers=_HEADERS)
|
|
186
|
+
if r.status_code != 200:
|
|
187
|
+
logger.warning("IRFCL: HTTP %d for %s", r.status_code, code)
|
|
188
|
+
return None
|
|
189
|
+
payload = r.json()
|
|
190
|
+
data_sets = payload.get("data", {}).get("dataSets", [])
|
|
191
|
+
structures = payload.get("data", {}).get("structures", [])
|
|
192
|
+
if not data_sets or not structures:
|
|
193
|
+
return None
|
|
194
|
+
time_dim = structures[0].get("dimensions", {}).get("observation", [])
|
|
195
|
+
if not time_dim:
|
|
196
|
+
return None
|
|
197
|
+
time_values = [t["value"] for t in time_dim[0].get("values", [])]
|
|
198
|
+
series_dict = data_sets[0].get("series", {})
|
|
199
|
+
|
|
200
|
+
data: dict[pd.Timestamp, float] = {}
|
|
201
|
+
for _, series in series_dict.items():
|
|
202
|
+
observations = series.get("observations", {})
|
|
203
|
+
for idx_str, payload_obs in observations.items():
|
|
204
|
+
if not payload_obs or payload_obs[0] is None:
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
value = float(payload_obs[0])
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
continue
|
|
210
|
+
idx = int(idx_str)
|
|
211
|
+
if idx >= len(time_values):
|
|
212
|
+
continue
|
|
213
|
+
ts = _parse_period(time_values[idx])
|
|
214
|
+
if ts is not None:
|
|
215
|
+
data[ts] = value
|
|
216
|
+
|
|
217
|
+
if not data:
|
|
218
|
+
return None
|
|
219
|
+
return pd.Series(data, dtype="float64").sort_index()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _parse_period(label: str) -> pd.Timestamp | None:
|
|
223
|
+
"""Convert an IRFCL label to the **real end of period**.
|
|
224
|
+
|
|
225
|
+
* ``"YYYY"`` → December 31 of that year.
|
|
226
|
+
* ``"YYYY-QN"`` → last day of the quarter.
|
|
227
|
+
* ``"YYYY-MNN"`` → last day of the month.
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
if "-Q" in label:
|
|
231
|
+
period = pd.Period(label.replace("-Q", "Q"), freq="Q")
|
|
232
|
+
return period.end_time.normalize()
|
|
233
|
+
if "-M" in label: # "YYYY-MNN"
|
|
234
|
+
year_str, month_str = label.split("-M")
|
|
235
|
+
period = pd.Period(
|
|
236
|
+
freq="M", year=int(year_str), month=int(month_str)
|
|
237
|
+
)
|
|
238
|
+
return period.end_time.normalize()
|
|
239
|
+
year = int(label)
|
|
240
|
+
return pd.Timestamp(year=year, month=12, day=31)
|
|
241
|
+
except (ValueError, TypeError):
|
|
242
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tesorotools-python
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.46
|
|
4
4
|
Requires-Python: >=3.13
|
|
5
5
|
Requires-Dist: babel>=2.17
|
|
6
6
|
Requires-Dist: matplotlib>=3.10
|
|
@@ -16,5 +16,8 @@ Provides-Extra: bde
|
|
|
16
16
|
Requires-Dist: requests>=2.31; extra == 'bde'
|
|
17
17
|
Provides-Extra: ecb
|
|
18
18
|
Requires-Dist: requests>=2.31; extra == 'ecb'
|
|
19
|
+
Provides-Extra: imf
|
|
20
|
+
Requires-Dist: requests>=2.31; extra == 'imf'
|
|
21
|
+
Requires-Dist: truststore>=0.10; extra == 'imf'
|
|
19
22
|
Provides-Extra: lseg
|
|
20
23
|
Requires-Dist: lseg-data>=2.1; extra == 'lseg'
|
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
tesorotools/__init__.py,sha256=
|
|
1
|
+
tesorotools/__init__.py,sha256=YsyE_aq7DdDs__6VPkKsy0exmXZpig0ieoD-ejuT0p0,3921
|
|
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
|
|
5
5
|
tesorotools/manifest.py,sha256=UeUURo_yQtCnXaYDUhNyxHaRlH4eNSh6szIYlv-PQZs,3346
|
|
6
6
|
tesorotools/orchestration.py,sha256=24C-LTM4uy7eckjXBjJbC2t92fg6yffl5H-E9Gm2W9M,3442
|
|
7
7
|
tesorotools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
tesorotools/artists/__init__.py,sha256=
|
|
8
|
+
tesorotools/artists/__init__.py,sha256=GR9_wnpH8dDCdIBIB-Psd4_NtdL9nu9vYpCHcU9IdUE,1630
|
|
9
9
|
tesorotools/artists/_common.py,sha256=y5XfDcsRxH3ydFgyphE2icppPAGsxUEPoxH5ikOoI3c,16403
|
|
10
10
|
tesorotools/artists/barh_plot.py,sha256=T68Vs8lPkhTVgkQZkK4X9s1-Eeipumm7Li7YOgItHxM,20187
|
|
11
11
|
tesorotools/artists/line_plot.py,sha256=EnILDic5T5keBOpKQ9Hzrb5ErcMEzNm8IFR_lXxINDE,9553
|
|
12
|
+
tesorotools/artists/matrix.py,sha256=mx7fRIydttXuZmDzj2jzN3cUm98oawAwjSvjS50xrpE,11005
|
|
12
13
|
tesorotools/artists/stacked.py,sha256=gcY0DNxDAnF9laGMLFMxYDuG95Ab8SykCQfPTzeQync,13447
|
|
13
14
|
tesorotools/artists/type_curve.py,sha256=7yidpNOs4Q7IFMwKRM2-6lNKRTV54A0ImM0DK1xSSoM,8968
|
|
15
|
+
tesorotools/artists/vector_plot.py,sha256=q0Rww-wUZuZQuB3IV9bQx8v0yrYWJj3Ns7oOrq-UwFo,11393
|
|
14
16
|
tesorotools/assets/README.md,sha256=EjDuA4Xf-Omn0Khp8erNXdW-iyWBzSM2FQFexdrS6Eg,285
|
|
15
|
-
tesorotools/assets/plots.yaml,sha256=
|
|
17
|
+
tesorotools/assets/plots.yaml,sha256=yfpK9TGznZzOqhNteM253Aq8sOmn8s8fKVerfAC-Ais,1426
|
|
16
18
|
tesorotools/assets/tesoro.mplstyle,sha256=HN7o04zzgwL2R6GeMvQlj-rBI_lutCwdvqwPKYyv9Tg,456
|
|
17
19
|
tesorotools/assets/fonts/CabinetGrotesk-Black.otf,sha256=b7M_yFxz5IcGtmWWPMzLVkfx4RLhVALQDFeoq4T1YD0,37728
|
|
18
20
|
tesorotools/assets/fonts/CabinetGrotesk-Bold.otf,sha256=86oM6a9QVAjZoEIFFcbCbcIVEwtWrMBX0ks7_CzaKS4,38148
|
|
@@ -39,10 +41,11 @@ tesorotools/pipeline/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hS
|
|
|
39
41
|
tesorotools/pipeline/diagnose.py,sha256=Ji_T3rAn-z4NhSdkpyPsnwQ1CTl4u5-1gnJZC8d1Sxg,1470
|
|
40
42
|
tesorotools/pipeline/engine.py,sha256=D4gKIv1bO6pw2gUMWJ7CVRv7e_kWWrWpZCzupeZCx0k,2929
|
|
41
43
|
tesorotools/pipeline/rules.py,sha256=OXi6dk2Gx63aO-SGjYnblhAah7tQIynNq2DKtfMGIEk,10041
|
|
42
|
-
tesorotools/providers/__init__.py,sha256=
|
|
44
|
+
tesorotools/providers/__init__.py,sha256=7iEs_BeNeVSMwzjsVd16b6GRmj3bgzMD30rNLEKwupo,1495
|
|
43
45
|
tesorotools/providers/base.py,sha256=6PA1HKhbc-Ehy27Ekm4wn-jWthEdL39Q9qxLCoqH_Jo,7666
|
|
44
46
|
tesorotools/providers/bde.py,sha256=wVfaKzgInTjThJ_twQ-ycA6FUMAKLx3BWL19P03LR0g,7226
|
|
45
47
|
tesorotools/providers/ecb.py,sha256=8UIW-NFLlGVIM7E2Sd_9UEWryt5wwilY3Hv13dnjY68,6422
|
|
48
|
+
tesorotools/providers/imf_irfcl.py,sha256=jGUDFGJBpNRsWKRI90BzI5AzIOmrSj6XwBS6ZUThc8A,8041
|
|
46
49
|
tesorotools/providers/lseg.py,sha256=paKdSxb1tWv26YCwMD0dEUfF4GLFJWzHukKII9WPges,30183
|
|
47
50
|
tesorotools/render/__init__.py,sha256=cqC5hoOwt_ZEE7gNG8nBbvf59YI99Lro8e18WsMKVT0,589
|
|
48
51
|
tesorotools/render/report.py,sha256=_7QnmDgUScRtUWSuaKYoTOVk2TTGUCkRWxAo1haxnek,1051
|
|
@@ -64,6 +67,6 @@ tesorotools/utils/matplotlib.py,sha256=lItlkJwJEDGALt0J1UZioTz2GOOFPPe7ffQ4Hzxl7
|
|
|
64
67
|
tesorotools/utils/series.py,sha256=AEf5DhneYjzQ0nvZD5b1IU3hop0Xgb3Bw2xWs_G3Lhw,1207
|
|
65
68
|
tesorotools/utils/shortcuts.py,sha256=9fpTLgfDFm6_1kh2W0Zk2atn3ZIZMvPfuOjlPpjVy4c,1718
|
|
66
69
|
tesorotools/utils/template.py,sha256=owYVB_dDlkIcKGmT6muH_4UjKDD7JYn_I1pxjQ9VIB8,5211
|
|
67
|
-
tesorotools_python-0.0.
|
|
68
|
-
tesorotools_python-0.0.
|
|
69
|
-
tesorotools_python-0.0.
|
|
70
|
+
tesorotools_python-0.0.46.dist-info/METADATA,sha256=9hoqw47cZa0TQiDwpT93d6TDnWHOCWi2nM_KxFuXhfU,724
|
|
71
|
+
tesorotools_python-0.0.46.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
72
|
+
tesorotools_python-0.0.46.dist-info/RECORD,,
|
|
File without changes
|