tesorotools-python 0.0.27__tar.gz → 0.0.31__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.
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/PKG-INFO +3 -1
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/pyproject.toml +2 -1
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/line_plot.py +120 -12
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/stacked.py +4 -2
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/engine.py +26 -2
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/rules.py +166 -0
- tesorotools_python-0.0.31/src/tesorotools/providers/ecb.py +215 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/.gitignore +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/barh.md +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/barh_plot.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/table.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/type_curve.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/README.md +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/README.md +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/plots.yaml +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/tesoro.mplstyle +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/convert.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/README.md +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/debug.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/lseg.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/database/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/database/local.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/database/push.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/dependencies/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/dependencies/node.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/dependencies/resolution.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/main.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/offsets/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/offsets/offsets.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/offsets/outliers.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/diagnose.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/providers/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/providers/base.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/providers/bde.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/py.typed +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/content.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/images.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/section.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/subtitle.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/table.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/text.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/title.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/report.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/__init__.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/config.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/format.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/globals.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/matplotlib.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/series.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/shortcuts.py +0 -0
- {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/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.
|
|
3
|
+
Version: 0.0.31
|
|
4
4
|
Requires-Python: >=3.13
|
|
5
5
|
Requires-Dist: babel>=2.17
|
|
6
6
|
Requires-Dist: eikon>=1.1
|
|
@@ -16,3 +16,5 @@ Requires-Dist: pyyaml>=6.0
|
|
|
16
16
|
Requires-Dist: sqlalchemy>=2.0
|
|
17
17
|
Provides-Extra: bde
|
|
18
18
|
Requires-Dist: requests>=2.31; extra == 'bde'
|
|
19
|
+
Provides-Extra: ecb
|
|
20
|
+
Requires-Dist: requests>=2.31; extra == 'ecb'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tesorotools-python"
|
|
3
3
|
requires-python = ">=3.13"
|
|
4
|
-
version = "0.0.
|
|
4
|
+
version = "0.0.31"
|
|
5
5
|
dependencies = [
|
|
6
6
|
# database and ORM
|
|
7
7
|
"psycopg[binary]>=3.1",
|
|
@@ -28,6 +28,7 @@ dependencies = [
|
|
|
28
28
|
|
|
29
29
|
[project.optional-dependencies]
|
|
30
30
|
bde = ["requests>=2.31"]
|
|
31
|
+
ecb = ["requests>=2.31"]
|
|
31
32
|
|
|
32
33
|
[dependency-groups]
|
|
33
34
|
dev = [
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/line_plot.py
RENAMED
|
@@ -96,6 +96,107 @@ def auto_ncol(ax: Axes, labels: list[str]) -> int:
|
|
|
96
96
|
return min(ncol, len(labels))
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def annotate_last_values(
|
|
100
|
+
ax: Axes,
|
|
101
|
+
plot_data: pd.DataFrame,
|
|
102
|
+
*,
|
|
103
|
+
decimals: int,
|
|
104
|
+
units: str,
|
|
105
|
+
labels: dict[str, str] | None = None,
|
|
106
|
+
series_styles: dict[str, dict[str, Any]] | None = None,
|
|
107
|
+
annotate_color: str | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Label the last non-NaN value of each column on the right.
|
|
110
|
+
|
|
111
|
+
``plot_data.columns`` and ``series_styles`` keys must be
|
|
112
|
+
canonical series IDs. ``labels`` maps each ID to the
|
|
113
|
+
Matplotlib line label used when the axes were drawn; if
|
|
114
|
+
omitted, the ID itself is used as the line label.
|
|
115
|
+
|
|
116
|
+
Colour priority (highest first): ``annotate_color``
|
|
117
|
+
(global override), ``series_styles[col]['color']``,
|
|
118
|
+
the Matplotlib line colour.
|
|
119
|
+
|
|
120
|
+
Labels are packed vertically in display space so that
|
|
121
|
+
series ending at nearly the same value do not overlap.
|
|
122
|
+
The x-axis limit is extended by the widest label so
|
|
123
|
+
the text is not clipped by the axes frame.
|
|
124
|
+
"""
|
|
125
|
+
fig = ax.get_figure()
|
|
126
|
+
if fig is None:
|
|
127
|
+
return
|
|
128
|
+
styles = series_styles or {}
|
|
129
|
+
label_map = labels or {}
|
|
130
|
+
|
|
131
|
+
lines_by_label = {str(line.get_label()): line for line in ax.lines}
|
|
132
|
+
entries: list[tuple[Any, float, str, Any]] = []
|
|
133
|
+
for col in plot_data.columns:
|
|
134
|
+
col_series: pd.Series[float] = plot_data[col].dropna()
|
|
135
|
+
if col_series.empty:
|
|
136
|
+
continue
|
|
137
|
+
last_date = col_series.index[-1]
|
|
138
|
+
last_val = float(col_series.iloc[-1])
|
|
139
|
+
if annotate_color is not None:
|
|
140
|
+
color: Any = annotate_color
|
|
141
|
+
else:
|
|
142
|
+
override = styles.get(col, {}).get("color")
|
|
143
|
+
if override is not None:
|
|
144
|
+
color = override
|
|
145
|
+
else:
|
|
146
|
+
line = lines_by_label.get(label_map.get(col, col))
|
|
147
|
+
color = line.get_color() if line is not None else "black"
|
|
148
|
+
entries.append((last_date, last_val, col, color))
|
|
149
|
+
|
|
150
|
+
if not entries:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
fig.canvas.draw() # type: ignore[reportUnknownMemberType]
|
|
154
|
+
renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
|
|
155
|
+
trans = ax.transData
|
|
156
|
+
|
|
157
|
+
sample = ax.text( # type: ignore[reportUnknownMemberType]
|
|
158
|
+
0, 0, "0"
|
|
159
|
+
)
|
|
160
|
+
text_height = (
|
|
161
|
+
sample.get_window_extent(renderer).height # type: ignore[reportUnknownArgumentType]
|
|
162
|
+
* 1.15
|
|
163
|
+
)
|
|
164
|
+
sample.remove()
|
|
165
|
+
|
|
166
|
+
entries.sort(key=lambda e: e[1])
|
|
167
|
+
placements: list[float] = []
|
|
168
|
+
prev_y = -float("inf")
|
|
169
|
+
for _, val, _, _ in entries:
|
|
170
|
+
y_display: float = trans.transform((0, val))[1] # type: ignore[reportUnknownArgumentType]
|
|
171
|
+
if y_display - prev_y < text_height:
|
|
172
|
+
y_display = prev_y + text_height
|
|
173
|
+
placements.append(y_display)
|
|
174
|
+
prev_y = y_display
|
|
175
|
+
|
|
176
|
+
max_label_width = 0.0
|
|
177
|
+
for (date, val, _, color), packed_y in zip(entries, placements):
|
|
178
|
+
orig_y: float = trans.transform((0, val))[1] # type: ignore[reportUnknownArgumentType]
|
|
179
|
+
dy_pts = (packed_y - orig_y) * 72.0 / fig.dpi
|
|
180
|
+
text = format_annotation(val, decimals, units)
|
|
181
|
+
t = ax.annotate( # type: ignore[reportUnknownMemberType]
|
|
182
|
+
text,
|
|
183
|
+
xy=(date, val),
|
|
184
|
+
xytext=(5, dy_pts),
|
|
185
|
+
textcoords="offset points",
|
|
186
|
+
color=color,
|
|
187
|
+
va="center",
|
|
188
|
+
ha="left",
|
|
189
|
+
)
|
|
190
|
+
width = t.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
|
|
191
|
+
max_label_width = max(max_label_width, width)
|
|
192
|
+
|
|
193
|
+
inv = trans.inverted()
|
|
194
|
+
x0: float = inv.transform((0, 0))[0] # type: ignore[reportUnknownArgumentType]
|
|
195
|
+
x1: float = inv.transform((max_label_width + 10, 0))[0] # type: ignore[reportUnknownArgumentType]
|
|
196
|
+
xmin, xmax = ax.get_xlim()
|
|
197
|
+
ax.set_xlim(xmin, xmax + (x1 - x0))
|
|
198
|
+
|
|
199
|
+
|
|
99
200
|
def style_spines(
|
|
100
201
|
ax: Axes,
|
|
101
202
|
decimals: int,
|
|
@@ -290,6 +391,7 @@ class LinePlot:
|
|
|
290
391
|
end_date: datetime.datetime | None = None,
|
|
291
392
|
base_100: bool = False,
|
|
292
393
|
annotate: bool = False,
|
|
394
|
+
annotate_color: str | None = None,
|
|
293
395
|
baseline: bool = False,
|
|
294
396
|
format: Format | None = None,
|
|
295
397
|
legend: Legend | None = None,
|
|
@@ -320,7 +422,8 @@ class LinePlot:
|
|
|
320
422
|
raise ValueError("series is required")
|
|
321
423
|
|
|
322
424
|
self.base_100 = base_100
|
|
323
|
-
self.annotate = annotate
|
|
425
|
+
self.annotate = annotate
|
|
426
|
+
self.annotate_color = annotate_color
|
|
324
427
|
self.format = format
|
|
325
428
|
self.start_date = start_date
|
|
326
429
|
self.end_date = end_date
|
|
@@ -358,7 +461,6 @@ class LinePlot:
|
|
|
358
461
|
plot_data: pd.DataFrame = self.data.loc[
|
|
359
462
|
slice(start_date, end_date), self.series.keys()
|
|
360
463
|
]
|
|
361
|
-
plot_data = plot_data.rename(columns=self.series)
|
|
362
464
|
|
|
363
465
|
plot_data = plot_data * self.scale
|
|
364
466
|
|
|
@@ -372,17 +474,23 @@ class LinePlot:
|
|
|
372
474
|
**fig_kw
|
|
373
475
|
)
|
|
374
476
|
ax = fig.add_subplot()
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
else:
|
|
380
|
-
plot_data.plot(ax=ax)
|
|
381
|
-
|
|
382
|
-
if self.annotate: # not implemented yet
|
|
383
|
-
pass
|
|
477
|
+
styles = self.series_styles
|
|
478
|
+
for col in plot_data.columns:
|
|
479
|
+
style = styles.get(col, {}) if styles else {}
|
|
480
|
+
plot_data[col].plot(ax=ax, label=self.series[col], **style)
|
|
384
481
|
|
|
385
482
|
assert self.format is not None
|
|
483
|
+
if self.annotate:
|
|
484
|
+
annotate_last_values(
|
|
485
|
+
ax,
|
|
486
|
+
plot_data,
|
|
487
|
+
decimals=self.format.decimals,
|
|
488
|
+
units=self.format.units,
|
|
489
|
+
labels=self.series,
|
|
490
|
+
series_styles=self.series_styles,
|
|
491
|
+
annotate_color=self.annotate_color,
|
|
492
|
+
)
|
|
493
|
+
|
|
386
494
|
style_spines( # maybe make this function accept a Format object
|
|
387
495
|
ax,
|
|
388
496
|
decimals=self.format.decimals,
|
|
@@ -394,7 +502,7 @@ class LinePlot:
|
|
|
394
502
|
style_baseline(ax, reference, **AX_CONFIG["baseline"])
|
|
395
503
|
|
|
396
504
|
if self.legend is not None:
|
|
397
|
-
labels =
|
|
505
|
+
labels = [self.series[c] for c in plot_data.columns]
|
|
398
506
|
ncol = (
|
|
399
507
|
self.legend.ncol
|
|
400
508
|
if self.legend.ncol is not None
|
|
@@ -84,7 +84,8 @@ class StackedAreaPlot:
|
|
|
84
84
|
else self.data.index.max()
|
|
85
85
|
)
|
|
86
86
|
|
|
87
|
-
plot_data = self.data.loc[start:end, list(self.series.keys())]
|
|
87
|
+
plot_data = self.data.loc[start:end, list(self.series.keys())]
|
|
88
|
+
plot_data = plot_data.dropna(how="all").fillna(0)
|
|
88
89
|
plot_data = plot_data * self.scale
|
|
89
90
|
|
|
90
91
|
fig_kw = dict(FIG_CONFIG)
|
|
@@ -211,7 +212,8 @@ class StackedBarPlot:
|
|
|
211
212
|
else self.data.index.max()
|
|
212
213
|
)
|
|
213
214
|
all_cols = list(self.series.keys()) + list(self.overlay_series.keys())
|
|
214
|
-
plot_data = self.data.loc[start:end, all_cols]
|
|
215
|
+
plot_data = self.data.loc[start:end, all_cols]
|
|
216
|
+
plot_data = plot_data.dropna(how="all").fillna(0)
|
|
215
217
|
return plot_data * self.scale
|
|
216
218
|
|
|
217
219
|
def _format_xticks(
|
|
@@ -51,6 +51,12 @@ def apply_transformations(
|
|
|
51
51
|
) -> pd.DataFrame:
|
|
52
52
|
"""Apply rules sequentially to a DataFrame.
|
|
53
53
|
|
|
54
|
+
New columns are accumulated in a dict and merged into
|
|
55
|
+
*result* only when a later rule reads from them (or at
|
|
56
|
+
the end). This avoids per-rule fragmentation of the
|
|
57
|
+
DataFrame and pandas' ``PerformanceWarning`` when many
|
|
58
|
+
rules (100+) are applied.
|
|
59
|
+
|
|
54
60
|
Parameters
|
|
55
61
|
----------
|
|
56
62
|
df
|
|
@@ -64,8 +70,10 @@ def apply_transformations(
|
|
|
64
70
|
Copy of *df* with new columns added by the rules.
|
|
65
71
|
"""
|
|
66
72
|
result = df.copy()
|
|
73
|
+
pending: dict[str, pd.Series[float]] = {}
|
|
67
74
|
for rule in rules:
|
|
68
|
-
|
|
75
|
+
available = set(result.columns) | set(pending)
|
|
76
|
+
missing = [d for d in rule.dependencies if d not in available]
|
|
69
77
|
if missing:
|
|
70
78
|
logger.warning(
|
|
71
79
|
"Skipping %s: missing dependencies %s",
|
|
@@ -73,5 +81,21 @@ def apply_transformations(
|
|
|
73
81
|
missing,
|
|
74
82
|
)
|
|
75
83
|
continue
|
|
76
|
-
|
|
84
|
+
# rule.compute() reads from result; flush pending
|
|
85
|
+
# into result if this rule needs any pending column.
|
|
86
|
+
if any(d in pending for d in rule.dependencies):
|
|
87
|
+
result = pd.concat(
|
|
88
|
+
[
|
|
89
|
+
result,
|
|
90
|
+
pd.DataFrame(pending, index=result.index),
|
|
91
|
+
],
|
|
92
|
+
axis=1,
|
|
93
|
+
)
|
|
94
|
+
pending = {}
|
|
95
|
+
pending[rule.output_name] = rule.compute(result)
|
|
96
|
+
if pending:
|
|
97
|
+
result = pd.concat(
|
|
98
|
+
[result, pd.DataFrame(pending, index=result.index)],
|
|
99
|
+
axis=1,
|
|
100
|
+
)
|
|
77
101
|
return result
|
|
@@ -45,6 +45,21 @@ def sum_rule(output: str, sources: list[str]) -> TransformationRule:
|
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def mean_rule(output: str, sources: list[str]) -> TransformationRule:
|
|
49
|
+
"""Row-wise arithmetic mean of multiple columns.
|
|
50
|
+
|
|
51
|
+
Equivalent to ``=AVERAGE(...)`` in Excel: NaN components
|
|
52
|
+
are skipped (not treated as zero), so a row with any
|
|
53
|
+
non-NaN value yields the mean of whatever is present.
|
|
54
|
+
A row of all NaN yields NaN.
|
|
55
|
+
"""
|
|
56
|
+
return TransformationRule(
|
|
57
|
+
output_name=output,
|
|
58
|
+
dependencies=list(sources),
|
|
59
|
+
compute=lambda df, cols=list(sources): df[cols].mean(axis=1),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
48
63
|
def ratio_rule(
|
|
49
64
|
output: str, numerator: str, denominator: str
|
|
50
65
|
) -> TransformationRule:
|
|
@@ -179,6 +194,152 @@ def cumsum_rule(output: str, source: str) -> TransformationRule:
|
|
|
179
194
|
)
|
|
180
195
|
|
|
181
196
|
|
|
197
|
+
def resample_rule(
|
|
198
|
+
output: str,
|
|
199
|
+
source: str,
|
|
200
|
+
freq: str,
|
|
201
|
+
agg: str = "mean",
|
|
202
|
+
) -> TransformationRule:
|
|
203
|
+
"""Resample a series to *freq* with aggregator *agg*.
|
|
204
|
+
|
|
205
|
+
NaN-aware: drops NaN before ``resample`` so interior
|
|
206
|
+
gaps in the input do not poison the aggregator.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def _compute(
|
|
210
|
+
df: pd.DataFrame,
|
|
211
|
+
s: str = source,
|
|
212
|
+
f: str = freq,
|
|
213
|
+
a: str = agg,
|
|
214
|
+
) -> pd.Series[float]:
|
|
215
|
+
clean: pd.Series[float] = df[s].dropna()
|
|
216
|
+
return clean.resample(f).agg(a) # type: ignore[reportUnknownMemberType]
|
|
217
|
+
|
|
218
|
+
return TransformationRule(
|
|
219
|
+
output_name=output,
|
|
220
|
+
dependencies=[source],
|
|
221
|
+
compute=_compute,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def weighted_average_rule(
|
|
226
|
+
output: str,
|
|
227
|
+
values: list[str],
|
|
228
|
+
weights: list[str],
|
|
229
|
+
) -> TransformationRule:
|
|
230
|
+
"""Weighted average: ``sum(v_i * w_i) / sum(w_i)``.
|
|
231
|
+
|
|
232
|
+
NaN components contribute 0 to both numerator and
|
|
233
|
+
denominator (so they do not propagate NaN across the
|
|
234
|
+
whole row). A denominator of 0 yields NaN (avoids
|
|
235
|
+
``inf``).
|
|
236
|
+
"""
|
|
237
|
+
if len(values) != len(weights):
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"weighted_average requires len(values) == len(weights),"
|
|
240
|
+
f" got {len(values)} and {len(weights)}"
|
|
241
|
+
)
|
|
242
|
+
deps = list(values) + list(weights)
|
|
243
|
+
|
|
244
|
+
def _compute(
|
|
245
|
+
df: pd.DataFrame,
|
|
246
|
+
vs: list[str] = list(values),
|
|
247
|
+
ws: list[str] = list(weights),
|
|
248
|
+
) -> pd.Series[float]:
|
|
249
|
+
zero = pd.Series(0.0, index=df.index)
|
|
250
|
+
num: pd.Series[float] = sum(
|
|
251
|
+
(df[v].fillna(0) * df[w].fillna(0) for v, w in zip(vs, ws)),
|
|
252
|
+
start=zero,
|
|
253
|
+
)
|
|
254
|
+
den: pd.Series[float] = sum(
|
|
255
|
+
(df[w].fillna(0) for w in ws),
|
|
256
|
+
start=zero,
|
|
257
|
+
)
|
|
258
|
+
return num / den.replace(0, float("nan"))
|
|
259
|
+
|
|
260
|
+
return TransformationRule(
|
|
261
|
+
output_name=output,
|
|
262
|
+
dependencies=deps,
|
|
263
|
+
compute=_compute,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def index_rule(
|
|
268
|
+
output: str,
|
|
269
|
+
source: str,
|
|
270
|
+
reference_date: str | pd.Timestamp,
|
|
271
|
+
base: float = 100.0,
|
|
272
|
+
) -> TransformationRule:
|
|
273
|
+
"""Rebase to *base* at *reference_date*.
|
|
274
|
+
|
|
275
|
+
Returns an empty Series if the reference date is not
|
|
276
|
+
present in the source's index after dropping NaN, or
|
|
277
|
+
if the reference value is zero. Keeps the pipeline
|
|
278
|
+
from blowing up on partial data.
|
|
279
|
+
"""
|
|
280
|
+
ref = pd.Timestamp(reference_date)
|
|
281
|
+
|
|
282
|
+
def _compute(
|
|
283
|
+
df: pd.DataFrame,
|
|
284
|
+
s: str = source,
|
|
285
|
+
r: pd.Timestamp = ref,
|
|
286
|
+
b: float = base,
|
|
287
|
+
) -> pd.Series[float]:
|
|
288
|
+
clean: pd.Series[float] = df[s].dropna()
|
|
289
|
+
if r not in clean.index:
|
|
290
|
+
return pd.Series(dtype="float64")
|
|
291
|
+
ref_val = float(clean.loc[r])
|
|
292
|
+
if ref_val == 0:
|
|
293
|
+
return pd.Series(dtype="float64")
|
|
294
|
+
return clean / ref_val * b
|
|
295
|
+
|
|
296
|
+
return TransformationRule(
|
|
297
|
+
output_name=output,
|
|
298
|
+
dependencies=[source],
|
|
299
|
+
compute=_compute,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def forward_fill_rule(
|
|
304
|
+
output: str,
|
|
305
|
+
source: str,
|
|
306
|
+
freq: str = "MS",
|
|
307
|
+
limit: int | None = None,
|
|
308
|
+
extend: int = 0,
|
|
309
|
+
) -> TransformationRule:
|
|
310
|
+
"""Reindex to a regular *freq* grid and forward-fill.
|
|
311
|
+
|
|
312
|
+
Decouples ``limit`` from the host DataFrame's own
|
|
313
|
+
frequency — useful when a quarterly series has to be
|
|
314
|
+
propagated to the months of each quarter inside a
|
|
315
|
+
daily/monthly DataFrame. ``extend`` adds that many
|
|
316
|
+
extra *freq* steps after the last real observation.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
def _compute(
|
|
320
|
+
df: pd.DataFrame,
|
|
321
|
+
s: str = source,
|
|
322
|
+
f: str = freq,
|
|
323
|
+
lim: int | None = limit,
|
|
324
|
+
ext: int = extend,
|
|
325
|
+
) -> pd.Series[float]:
|
|
326
|
+
clean: pd.Series[float] = df[s].dropna()
|
|
327
|
+
if clean.empty:
|
|
328
|
+
return pd.Series(dtype="float64")
|
|
329
|
+
start = clean.index.min()
|
|
330
|
+
end = clean.index.max()
|
|
331
|
+
if ext > 0:
|
|
332
|
+
end = end + pd.tseries.frequencies.to_offset(f) * ext # type: ignore[reportOperatorIssue]
|
|
333
|
+
grid = pd.date_range(start=start, end=end, freq=f)
|
|
334
|
+
return clean.reindex(grid).ffill(limit=lim)
|
|
335
|
+
|
|
336
|
+
return TransformationRule(
|
|
337
|
+
output_name=output,
|
|
338
|
+
dependencies=[source],
|
|
339
|
+
compute=_compute,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
182
343
|
#: Registry of factory functions, keyed by YAML function name.
|
|
183
344
|
#: Projects can add custom factories at runtime.
|
|
184
345
|
FACTORIES: dict[
|
|
@@ -187,6 +348,7 @@ FACTORIES: dict[
|
|
|
187
348
|
] = {
|
|
188
349
|
"scale": scale_rule,
|
|
189
350
|
"sum": sum_rule,
|
|
351
|
+
"mean": mean_rule,
|
|
190
352
|
"ratio": ratio_rule,
|
|
191
353
|
"difference": difference_rule,
|
|
192
354
|
"inverse": inverse_rule,
|
|
@@ -194,4 +356,8 @@ FACTORIES: dict[
|
|
|
194
356
|
"rolling_sum": rolling_sum_rule,
|
|
195
357
|
"delta": delta_rule,
|
|
196
358
|
"cumsum": cumsum_rule,
|
|
359
|
+
"resample": resample_rule,
|
|
360
|
+
"weighted_average": weighted_average_rule,
|
|
361
|
+
"index": index_rule,
|
|
362
|
+
"forward_fill": forward_fill_rule,
|
|
197
363
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""ECB SDMX REST data provider.
|
|
2
|
+
|
|
3
|
+
Downloads time series from the ECB public statistical data
|
|
4
|
+
warehouse via its SDMX 2.1 REST API. No authentication
|
|
5
|
+
required.
|
|
6
|
+
|
|
7
|
+
Install with the ``ecb`` optional extra::
|
|
8
|
+
|
|
9
|
+
uv pip install "tesorotools-python[ecb]"
|
|
10
|
+
|
|
11
|
+
API reference
|
|
12
|
+
-------------
|
|
13
|
+
Endpoint:
|
|
14
|
+
https://data-api.ecb.europa.eu/service/data/{dataflow}/{key}
|
|
15
|
+
|
|
16
|
+
Where ``{dataflow}`` is the dataset identifier (e.g. ``"MIR"``,
|
|
17
|
+
``"BSI"``, ``"FM"``) and ``{key}`` is the dot-separated series
|
|
18
|
+
key (e.g. ``"M.ES.B.L22.A.R.A.2250.EUR.N"``).
|
|
19
|
+
|
|
20
|
+
Query parameters used here:
|
|
21
|
+
``format`` : ``"csvdata"`` (flat CSV with one row per obs)
|
|
22
|
+
``startPeriod`` : optional, ISO date or year-month
|
|
23
|
+
|
|
24
|
+
Code convention
|
|
25
|
+
---------------
|
|
26
|
+
This provider accepts codes in the ECB full-key form
|
|
27
|
+
``"{dataflow}.{key}"``, e.g.
|
|
28
|
+
``"MIR.M.ES.B.L22.A.R.A.2250.EUR.N"``. The first dot
|
|
29
|
+
separates the dataflow from the key. This matches how R
|
|
30
|
+
packages (``ecb::get_data``) address series and keeps
|
|
31
|
+
catalog entries unambiguous.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import csv
|
|
37
|
+
import io
|
|
38
|
+
import logging
|
|
39
|
+
from typing import cast
|
|
40
|
+
|
|
41
|
+
import pandas as pd
|
|
42
|
+
import requests
|
|
43
|
+
|
|
44
|
+
from tesorotools.providers.base import DataProvider
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
_BASE_URL = "https://data-api.ecb.europa.eu/service/data"
|
|
49
|
+
_PING_URL = "https://data-api.ecb.europa.eu/service/dataflow/ECB"
|
|
50
|
+
|
|
51
|
+
DEFAULT_TIMEOUT = 60
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _split_code(code: str) -> tuple[str, str]:
|
|
55
|
+
"""Split an ECB code into ``(dataflow, key)``.
|
|
56
|
+
|
|
57
|
+
``"MIR.M.ES.B.L22.A.R.A.2250.EUR.N"`` becomes
|
|
58
|
+
``("MIR", "M.ES.B.L22.A.R.A.2250.EUR.N")``.
|
|
59
|
+
"""
|
|
60
|
+
dataflow, _, key = code.partition(".")
|
|
61
|
+
if not key:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"ECB code must include a dataflow and a key: {code!r}"
|
|
64
|
+
)
|
|
65
|
+
return dataflow, key
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_period(period: str) -> pd.Timestamp:
|
|
69
|
+
"""Parse an ECB TIME_PERIOD string into a timestamp.
|
|
70
|
+
|
|
71
|
+
Handles the most common formats:
|
|
72
|
+
|
|
73
|
+
- ``"2025"`` annual -> Jan 1
|
|
74
|
+
- ``"2025-Q1"`` quarter -> Jan 1 (start of quarter)
|
|
75
|
+
- ``"2025-01"`` monthly -> Jan 1
|
|
76
|
+
- ``"2025-01-15"`` daily -> that day
|
|
77
|
+
- ``"2025-W01"`` weekly -> Monday of that ISO week
|
|
78
|
+
"""
|
|
79
|
+
if len(period) == 4 and period.isdigit():
|
|
80
|
+
return pd.Timestamp(f"{period}-01-01")
|
|
81
|
+
if "Q" in period:
|
|
82
|
+
year_str, q_str = period.split("-Q")
|
|
83
|
+
month = (int(q_str) - 1) * 3 + 1
|
|
84
|
+
return pd.Timestamp(f"{year_str}-{month:02d}-01")
|
|
85
|
+
if "W" in period:
|
|
86
|
+
year_str, w_str = period.split("-W")
|
|
87
|
+
return pd.Timestamp.fromisocalendar(int(year_str), int(w_str), 1)
|
|
88
|
+
if len(period) == 7: # YYYY-MM
|
|
89
|
+
return pd.Timestamp(f"{period}-01")
|
|
90
|
+
return pd.Timestamp(period)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class EcbProvider(DataProvider):
|
|
94
|
+
"""Provider that downloads series from the ECB SDMX API.
|
|
95
|
+
|
|
96
|
+
One HTTP request per series (ECB allows multiple keys per
|
|
97
|
+
request using ``+``-separated alternatives within a
|
|
98
|
+
position, but bundling arbitrary keys is not supported).
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
timeout
|
|
103
|
+
Max seconds per HTTP request.
|
|
104
|
+
session
|
|
105
|
+
Optional pre-built ``requests.Session``. Useful for
|
|
106
|
+
tests or for custom retry policies.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
113
|
+
session: requests.Session | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._timeout = timeout
|
|
116
|
+
self._session = session or requests.Session()
|
|
117
|
+
|
|
118
|
+
def is_available(self) -> bool:
|
|
119
|
+
"""Check whether the ECB API responds.
|
|
120
|
+
|
|
121
|
+
Hits a lightweight dataflow endpoint.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
r = self._session.get(_PING_URL, timeout=self._timeout)
|
|
125
|
+
return r.ok
|
|
126
|
+
except requests.RequestException:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def fetch(
|
|
130
|
+
self,
|
|
131
|
+
codes: list[str],
|
|
132
|
+
start: str | None = None,
|
|
133
|
+
end: str | None = None,
|
|
134
|
+
) -> pd.DataFrame:
|
|
135
|
+
"""Download series for ``codes`` and return a DataFrame.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
codes
|
|
140
|
+
List of ECB full-key codes (``"{dataflow}.{key}"``).
|
|
141
|
+
start
|
|
142
|
+
Start period (ISO format, e.g. ``"2022-01"``).
|
|
143
|
+
If ``None`` the full history is returned.
|
|
144
|
+
end
|
|
145
|
+
End period. If ``None`` up to latest.
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
pd.DataFrame
|
|
150
|
+
Wide DataFrame. Index = dates (tz-naive), columns =
|
|
151
|
+
the requested codes. Missing observations are NaN.
|
|
152
|
+
"""
|
|
153
|
+
if not codes:
|
|
154
|
+
return pd.DataFrame()
|
|
155
|
+
|
|
156
|
+
frames: list[pd.Series[float]] = []
|
|
157
|
+
for code in codes:
|
|
158
|
+
series = self._fetch_one(code, start=start, end=end)
|
|
159
|
+
frames.append(series)
|
|
160
|
+
|
|
161
|
+
df = pd.concat(frames, axis=1)
|
|
162
|
+
df.index.name = "date"
|
|
163
|
+
df.sort_index(inplace=True)
|
|
164
|
+
return df
|
|
165
|
+
|
|
166
|
+
def _fetch_one(
|
|
167
|
+
self,
|
|
168
|
+
code: str,
|
|
169
|
+
start: str | None,
|
|
170
|
+
end: str | None,
|
|
171
|
+
) -> "pd.Series[float]":
|
|
172
|
+
"""Download a single series and return it as a Series."""
|
|
173
|
+
dataflow, key = _split_code(code)
|
|
174
|
+
url = f"{_BASE_URL}/{dataflow}/{key}"
|
|
175
|
+
|
|
176
|
+
params: dict[str, str] = {"format": "csvdata"}
|
|
177
|
+
if start is not None:
|
|
178
|
+
params["startPeriod"] = start
|
|
179
|
+
if end is not None:
|
|
180
|
+
params["endPeriod"] = end
|
|
181
|
+
|
|
182
|
+
logger.debug("GET %s params=%s", url, params)
|
|
183
|
+
r = self._session.get(url, params=params, timeout=self._timeout)
|
|
184
|
+
r.raise_for_status()
|
|
185
|
+
|
|
186
|
+
values = _parse_csv(r.text)
|
|
187
|
+
ts = pd.Series(values, name=code, dtype="float64")
|
|
188
|
+
ts.sort_index(inplace=True)
|
|
189
|
+
return ts
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_csv(text: str) -> dict[pd.Timestamp, float]:
|
|
193
|
+
"""Parse an ECB csvdata response.
|
|
194
|
+
|
|
195
|
+
Returns a mapping ``timestamp -> observation``. Empty or
|
|
196
|
+
non-numeric values are skipped.
|
|
197
|
+
|
|
198
|
+
The ECB csvdata format has one row per observation with
|
|
199
|
+
(at least) ``TIME_PERIOD`` and ``OBS_VALUE`` columns.
|
|
200
|
+
Other metadata columns are ignored.
|
|
201
|
+
"""
|
|
202
|
+
reader = csv.DictReader(io.StringIO(text))
|
|
203
|
+
out: dict[pd.Timestamp, float] = {}
|
|
204
|
+
for row_any in reader:
|
|
205
|
+
row = cast(dict[str, str], row_any)
|
|
206
|
+
period = row.get("TIME_PERIOD") or ""
|
|
207
|
+
raw = row.get("OBS_VALUE") or ""
|
|
208
|
+
if not period or not raw:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
value = float(raw)
|
|
212
|
+
except ValueError:
|
|
213
|
+
continue
|
|
214
|
+
out[_parse_period(period)] = value
|
|
215
|
+
return out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/barh_plot.py
RENAMED
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/type_curve.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/README.md
RENAMED
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/tesoro.mplstyle
RENAMED
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/README.md
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/debug.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/lseg.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/database/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/dependencies/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/dependencies/node.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/dependencies/resolution.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/diagnose.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/providers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/content.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/images.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/section.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/subtitle.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/table.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/text.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/title.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|