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.
Files changed (65) hide show
  1. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/PKG-INFO +3 -1
  2. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/pyproject.toml +2 -1
  3. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/line_plot.py +120 -12
  4. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/stacked.py +4 -2
  5. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/engine.py +26 -2
  6. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/rules.py +166 -0
  7. tesorotools_python-0.0.31/src/tesorotools/providers/ecb.py +215 -0
  8. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/.gitignore +0 -0
  9. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/__init__.py +0 -0
  10. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/__init__.py +0 -0
  11. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/barh.md +0 -0
  12. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/barh_plot.py +0 -0
  13. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/table.py +0 -0
  14. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/artists/type_curve.py +0 -0
  15. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/README.md +0 -0
  16. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  17. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  18. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  19. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  20. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  21. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  22. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  23. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  24. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/fonts/README.md +0 -0
  25. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/plots.yaml +0 -0
  26. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  27. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/convert.py +0 -0
  28. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/README.md +0 -0
  29. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/__init__.py +0 -0
  30. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/debug.py +0 -0
  31. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/data_sources/lseg.py +0 -0
  32. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/database/__init__.py +0 -0
  33. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/database/local.py +0 -0
  34. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/database/push.py +0 -0
  35. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/dependencies/__init__.py +0 -0
  36. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/dependencies/node.py +0 -0
  37. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/dependencies/resolution.py +0 -0
  38. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/main.py +0 -0
  39. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/offsets/__init__.py +0 -0
  40. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/offsets/offsets.py +0 -0
  41. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/offsets/outliers.py +0 -0
  42. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/__init__.py +0 -0
  43. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/pipeline/diagnose.py +0 -0
  44. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/providers/__init__.py +0 -0
  45. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/providers/base.py +0 -0
  46. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/providers/bde.py +0 -0
  47. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/py.typed +0 -0
  48. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/__init__.py +0 -0
  49. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/__init__.py +0 -0
  50. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/content.py +0 -0
  51. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/images.py +0 -0
  52. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/section.py +0 -0
  53. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/subtitle.py +0 -0
  54. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/table.py +0 -0
  55. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/text.py +0 -0
  56. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/content/title.py +0 -0
  57. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/render/report.py +0 -0
  58. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/__init__.py +0 -0
  59. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/config.py +0 -0
  60. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/format.py +0 -0
  61. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/globals.py +0 -0
  62. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/matplotlib.py +0 -0
  63. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/series.py +0 -0
  64. {tesorotools_python-0.0.27 → tesorotools_python-0.0.31}/src/tesorotools/utils/shortcuts.py +0 -0
  65. {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.27
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.27"
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 = [
@@ -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 # unused for the moment
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
- if self.series_styles:
376
- for col in plot_data.columns:
377
- style = self.series_styles.get(col, {})
378
- plot_data[col].plot(ax=ax, label=col, **style)
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 = list(plot_data.columns)
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())].dropna()
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].dropna()
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
- missing = [d for d in rule.dependencies if d not in result.columns]
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
- result[rule.output_name] = rule.compute(result)
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