tesorotools-python 0.0.28__tar.gz → 0.0.30__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 (65) hide show
  1. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/PKG-INFO +3 -1
  2. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/pyproject.toml +2 -1
  3. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/artists/line_plot.py +107 -4
  4. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/pipeline/rules.py +150 -0
  5. tesorotools_python-0.0.30/src/tesorotools/providers/ecb.py +215 -0
  6. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/.gitignore +0 -0
  7. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/__init__.py +0 -0
  8. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/artists/__init__.py +0 -0
  9. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/artists/barh.md +0 -0
  10. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/artists/barh_plot.py +0 -0
  11. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/artists/stacked.py +0 -0
  12. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/artists/table.py +0 -0
  13. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/artists/type_curve.py +0 -0
  14. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/README.md +0 -0
  15. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  16. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  17. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  18. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  19. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  20. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  21. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  22. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  23. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/fonts/README.md +0 -0
  24. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/plots.yaml +0 -0
  25. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  26. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/convert.py +0 -0
  27. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/data_sources/README.md +0 -0
  28. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/data_sources/__init__.py +0 -0
  29. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/data_sources/debug.py +0 -0
  30. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/data_sources/lseg.py +0 -0
  31. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/database/__init__.py +0 -0
  32. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/database/local.py +0 -0
  33. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/database/push.py +0 -0
  34. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/dependencies/__init__.py +0 -0
  35. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/dependencies/node.py +0 -0
  36. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/dependencies/resolution.py +0 -0
  37. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/main.py +0 -0
  38. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/offsets/__init__.py +0 -0
  39. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/offsets/offsets.py +0 -0
  40. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/offsets/outliers.py +0 -0
  41. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/pipeline/__init__.py +0 -0
  42. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/pipeline/diagnose.py +0 -0
  43. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/pipeline/engine.py +0 -0
  44. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/providers/__init__.py +0 -0
  45. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/providers/base.py +0 -0
  46. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/providers/bde.py +0 -0
  47. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/py.typed +0 -0
  48. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/__init__.py +0 -0
  49. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/content/__init__.py +0 -0
  50. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/content/content.py +0 -0
  51. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/content/images.py +0 -0
  52. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/content/section.py +0 -0
  53. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/content/subtitle.py +0 -0
  54. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/content/table.py +0 -0
  55. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/content/text.py +0 -0
  56. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/content/title.py +0 -0
  57. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/render/report.py +0 -0
  58. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/utils/__init__.py +0 -0
  59. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/utils/config.py +0 -0
  60. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/utils/format.py +0 -0
  61. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/utils/globals.py +0 -0
  62. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/utils/matplotlib.py +0 -0
  63. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/utils/series.py +0 -0
  64. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/src/tesorotools/utils/shortcuts.py +0 -0
  65. {tesorotools_python-0.0.28 → tesorotools_python-0.0.30}/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.28
3
+ Version: 0.0.30
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.28"
4
+ version = "0.0.30"
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 = [
@@ -96,6 +96,100 @@ 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
+ series_styles: dict[str, dict[str, Any]] | None = None,
106
+ annotate_color: str | None = None,
107
+ ) -> None:
108
+ """Label the last non-NaN value of each column on the right.
109
+
110
+ Colour priority (highest first): ``annotate_color``
111
+ (global override), ``series_styles[col]['color']``,
112
+ the Matplotlib line colour.
113
+
114
+ Labels are packed vertically in display space so that
115
+ series ending at nearly the same value do not overlap.
116
+ The x-axis limit is extended by the widest label so
117
+ the text is not clipped by the axes frame.
118
+ """
119
+ fig = ax.get_figure()
120
+ if fig is None:
121
+ return
122
+ styles = series_styles or {}
123
+
124
+ lines_by_label = {str(line.get_label()): line for line in ax.lines}
125
+ entries: list[tuple[Any, float, str, Any]] = []
126
+ for col in plot_data.columns:
127
+ col_series: pd.Series[float] = plot_data[col].dropna()
128
+ if col_series.empty:
129
+ continue
130
+ last_date = col_series.index[-1]
131
+ last_val = float(col_series.iloc[-1])
132
+ if annotate_color is not None:
133
+ color: Any = annotate_color
134
+ else:
135
+ override = styles.get(col, {}).get("color")
136
+ if override is not None:
137
+ color = override
138
+ else:
139
+ line = lines_by_label.get(col)
140
+ color = line.get_color() if line is not None else "black"
141
+ entries.append((last_date, last_val, col, color))
142
+
143
+ if not entries:
144
+ return
145
+
146
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
147
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
148
+ trans = ax.transData
149
+
150
+ sample = ax.text( # type: ignore[reportUnknownMemberType]
151
+ 0, 0, "0"
152
+ )
153
+ text_height = (
154
+ sample.get_window_extent(renderer).height # type: ignore[reportUnknownArgumentType]
155
+ * 1.15
156
+ )
157
+ sample.remove()
158
+
159
+ entries.sort(key=lambda e: e[1])
160
+ placements: list[float] = []
161
+ prev_y = -float("inf")
162
+ for _, val, _, _ in entries:
163
+ y_display: float = trans.transform((0, val))[1] # type: ignore[reportUnknownArgumentType]
164
+ if y_display - prev_y < text_height:
165
+ y_display = prev_y + text_height
166
+ placements.append(y_display)
167
+ prev_y = y_display
168
+
169
+ max_label_width = 0.0
170
+ for (date, val, _, color), packed_y in zip(entries, placements):
171
+ orig_y: float = trans.transform((0, val))[1] # type: ignore[reportUnknownArgumentType]
172
+ dy_pts = (packed_y - orig_y) * 72.0 / fig.dpi
173
+ text = format_annotation(val, decimals, units)
174
+ t = ax.annotate( # type: ignore[reportUnknownMemberType]
175
+ text,
176
+ xy=(date, val),
177
+ xytext=(5, dy_pts),
178
+ textcoords="offset points",
179
+ color=color,
180
+ va="center",
181
+ ha="left",
182
+ )
183
+ width = t.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
184
+ max_label_width = max(max_label_width, width)
185
+
186
+ inv = trans.inverted()
187
+ x0: float = inv.transform((0, 0))[0] # type: ignore[reportUnknownArgumentType]
188
+ x1: float = inv.transform((max_label_width + 10, 0))[0] # type: ignore[reportUnknownArgumentType]
189
+ xmin, xmax = ax.get_xlim()
190
+ ax.set_xlim(xmin, xmax + (x1 - x0))
191
+
192
+
99
193
  def style_spines(
100
194
  ax: Axes,
101
195
  decimals: int,
@@ -290,6 +384,7 @@ class LinePlot:
290
384
  end_date: datetime.datetime | None = None,
291
385
  base_100: bool = False,
292
386
  annotate: bool = False,
387
+ annotate_color: str | None = None,
293
388
  baseline: bool = False,
294
389
  format: Format | None = None,
295
390
  legend: Legend | None = None,
@@ -320,7 +415,8 @@ class LinePlot:
320
415
  raise ValueError("series is required")
321
416
 
322
417
  self.base_100 = base_100
323
- self.annotate = annotate # unused for the moment
418
+ self.annotate = annotate
419
+ self.annotate_color = annotate_color
324
420
  self.format = format
325
421
  self.start_date = start_date
326
422
  self.end_date = end_date
@@ -379,10 +475,17 @@ class LinePlot:
379
475
  else:
380
476
  plot_data.plot(ax=ax)
381
477
 
382
- if self.annotate: # not implemented yet
383
- pass
384
-
385
478
  assert self.format is not None
479
+ if self.annotate:
480
+ annotate_last_values(
481
+ ax,
482
+ plot_data,
483
+ decimals=self.format.decimals,
484
+ units=self.format.units,
485
+ series_styles=self.series_styles,
486
+ annotate_color=self.annotate_color,
487
+ )
488
+
386
489
  style_spines( # maybe make this function accept a Format object
387
490
  ax,
388
491
  decimals=self.format.decimals,
@@ -179,6 +179,152 @@ def cumsum_rule(output: str, source: str) -> TransformationRule:
179
179
  )
180
180
 
181
181
 
182
+ def resample_rule(
183
+ output: str,
184
+ source: str,
185
+ freq: str,
186
+ agg: str = "mean",
187
+ ) -> TransformationRule:
188
+ """Resample a series to *freq* with aggregator *agg*.
189
+
190
+ NaN-aware: drops NaN before ``resample`` so interior
191
+ gaps in the input do not poison the aggregator.
192
+ """
193
+
194
+ def _compute(
195
+ df: pd.DataFrame,
196
+ s: str = source,
197
+ f: str = freq,
198
+ a: str = agg,
199
+ ) -> pd.Series[float]:
200
+ clean: pd.Series[float] = df[s].dropna()
201
+ return clean.resample(f).agg(a) # type: ignore[reportUnknownMemberType]
202
+
203
+ return TransformationRule(
204
+ output_name=output,
205
+ dependencies=[source],
206
+ compute=_compute,
207
+ )
208
+
209
+
210
+ def weighted_average_rule(
211
+ output: str,
212
+ values: list[str],
213
+ weights: list[str],
214
+ ) -> TransformationRule:
215
+ """Weighted average: ``sum(v_i * w_i) / sum(w_i)``.
216
+
217
+ NaN components contribute 0 to both numerator and
218
+ denominator (so they do not propagate NaN across the
219
+ whole row). A denominator of 0 yields NaN (avoids
220
+ ``inf``).
221
+ """
222
+ if len(values) != len(weights):
223
+ raise ValueError(
224
+ f"weighted_average requires len(values) == len(weights),"
225
+ f" got {len(values)} and {len(weights)}"
226
+ )
227
+ deps = list(values) + list(weights)
228
+
229
+ def _compute(
230
+ df: pd.DataFrame,
231
+ vs: list[str] = list(values),
232
+ ws: list[str] = list(weights),
233
+ ) -> pd.Series[float]:
234
+ zero = pd.Series(0.0, index=df.index)
235
+ num: pd.Series[float] = sum(
236
+ (df[v].fillna(0) * df[w].fillna(0) for v, w in zip(vs, ws)),
237
+ start=zero,
238
+ )
239
+ den: pd.Series[float] = sum(
240
+ (df[w].fillna(0) for w in ws),
241
+ start=zero,
242
+ )
243
+ return num / den.replace(0, float("nan"))
244
+
245
+ return TransformationRule(
246
+ output_name=output,
247
+ dependencies=deps,
248
+ compute=_compute,
249
+ )
250
+
251
+
252
+ def index_rule(
253
+ output: str,
254
+ source: str,
255
+ reference_date: str | pd.Timestamp,
256
+ base: float = 100.0,
257
+ ) -> TransformationRule:
258
+ """Rebase to *base* at *reference_date*.
259
+
260
+ Returns an empty Series if the reference date is not
261
+ present in the source's index after dropping NaN, or
262
+ if the reference value is zero. Keeps the pipeline
263
+ from blowing up on partial data.
264
+ """
265
+ ref = pd.Timestamp(reference_date)
266
+
267
+ def _compute(
268
+ df: pd.DataFrame,
269
+ s: str = source,
270
+ r: pd.Timestamp = ref,
271
+ b: float = base,
272
+ ) -> pd.Series[float]:
273
+ clean: pd.Series[float] = df[s].dropna()
274
+ if r not in clean.index:
275
+ return pd.Series(dtype="float64")
276
+ ref_val = float(clean.loc[r])
277
+ if ref_val == 0:
278
+ return pd.Series(dtype="float64")
279
+ return clean / ref_val * b
280
+
281
+ return TransformationRule(
282
+ output_name=output,
283
+ dependencies=[source],
284
+ compute=_compute,
285
+ )
286
+
287
+
288
+ def forward_fill_rule(
289
+ output: str,
290
+ source: str,
291
+ freq: str = "MS",
292
+ limit: int | None = None,
293
+ extend: int = 0,
294
+ ) -> TransformationRule:
295
+ """Reindex to a regular *freq* grid and forward-fill.
296
+
297
+ Decouples ``limit`` from the host DataFrame's own
298
+ frequency — useful when a quarterly series has to be
299
+ propagated to the months of each quarter inside a
300
+ daily/monthly DataFrame. ``extend`` adds that many
301
+ extra *freq* steps after the last real observation.
302
+ """
303
+
304
+ def _compute(
305
+ df: pd.DataFrame,
306
+ s: str = source,
307
+ f: str = freq,
308
+ lim: int | None = limit,
309
+ ext: int = extend,
310
+ ) -> pd.Series[float]:
311
+ clean: pd.Series[float] = df[s].dropna()
312
+ if clean.empty:
313
+ return pd.Series(dtype="float64")
314
+ start = clean.index.min()
315
+ end = clean.index.max()
316
+ if ext > 0:
317
+ end = end + pd.tseries.frequencies.to_offset(f) * ext # type: ignore[reportOperatorIssue]
318
+ grid = pd.date_range(start=start, end=end, freq=f)
319
+ return clean.reindex(grid).ffill(limit=lim)
320
+
321
+ return TransformationRule(
322
+ output_name=output,
323
+ dependencies=[source],
324
+ compute=_compute,
325
+ )
326
+
327
+
182
328
  #: Registry of factory functions, keyed by YAML function name.
183
329
  #: Projects can add custom factories at runtime.
184
330
  FACTORIES: dict[
@@ -194,4 +340,8 @@ FACTORIES: dict[
194
340
  "rolling_sum": rolling_sum_rule,
195
341
  "delta": delta_rule,
196
342
  "cumsum": cumsum_rule,
343
+ "resample": resample_rule,
344
+ "weighted_average": weighted_average_rule,
345
+ "index": index_rule,
346
+ "forward_fill": forward_fill_rule,
197
347
  }
@@ -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