tesorotools-python 0.0.52__tar.gz → 0.0.53__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 (82) hide show
  1. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/PKG-INFO +1 -1
  2. tesorotools_python-0.0.53/src/tesorotools/providers/bde.py +479 -0
  3. tesorotools_python-0.0.52/src/tesorotools/providers/bde.py +0 -269
  4. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/.gitignore +0 -0
  5. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/pyproject.toml +0 -0
  6. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/__init__.py +0 -0
  7. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/_build_context.py +0 -0
  8. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/_registry.py +0 -0
  9. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/__init__.py +0 -0
  10. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/_common.py +0 -0
  11. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/bar_line.py +0 -0
  12. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/barh_plot.py +0 -0
  13. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/box_plot.py +0 -0
  14. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/compact.py +0 -0
  15. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/intraday_plot.py +0 -0
  16. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/line_plot.py +0 -0
  17. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/matrix.py +0 -0
  18. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/plotly_backend.py +0 -0
  19. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/shock_plot.py +0 -0
  20. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/stacked.py +0 -0
  21. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/type_curve.py +0 -0
  22. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/vector_plot.py +0 -0
  23. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/artists/waterfall.py +0 -0
  24. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/README.md +0 -0
  25. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  26. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  27. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  28. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  29. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  30. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  31. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  32. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  33. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/fonts/README.md +0 -0
  34. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/plots.yaml +0 -0
  35. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/template.docx +0 -0
  36. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  37. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/data_sources/__init__.py +0 -0
  38. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/data_sources/debug.py +0 -0
  39. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/database/__init__.py +0 -0
  40. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/database/local.py +0 -0
  41. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/database/push.py +0 -0
  42. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/database/shared.py +0 -0
  43. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/dependencies/__init__.py +0 -0
  44. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/dependencies/node.py +0 -0
  45. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/dependencies/resolution.py +0 -0
  46. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/driver.py +0 -0
  47. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/manifest.py +0 -0
  48. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/offsets/__init__.py +0 -0
  49. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/offsets/offsets.py +0 -0
  50. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/offsets/outliers.py +0 -0
  51. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/orchestration.py +0 -0
  52. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/pipeline/__init__.py +0 -0
  53. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/pipeline/diagnose.py +0 -0
  54. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/pipeline/engine.py +0 -0
  55. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/pipeline/rules.py +0 -0
  56. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/providers/__init__.py +0 -0
  57. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/providers/base.py +0 -0
  58. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/providers/ecb.py +0 -0
  59. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/providers/imf_irfcl.py +0 -0
  60. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/providers/lseg.py +0 -0
  61. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/py.typed +0 -0
  62. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/__init__.py +0 -0
  63. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/content/__init__.py +0 -0
  64. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/content/content.py +0 -0
  65. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/content/images.py +0 -0
  66. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/content/section.py +0 -0
  67. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/content/subtitle.py +0 -0
  68. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/content/table.py +0 -0
  69. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/content/text.py +0 -0
  70. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/content/title.py +0 -0
  71. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/document.py +0 -0
  72. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/render/report.py +0 -0
  73. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/testing/__init__.py +0 -0
  74. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/testing/compare.py +0 -0
  75. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/utils/__init__.py +0 -0
  76. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/utils/config.py +0 -0
  77. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/utils/format.py +0 -0
  78. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/utils/globals.py +0 -0
  79. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/utils/matplotlib.py +0 -0
  80. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/utils/series.py +0 -0
  81. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/src/tesorotools/utils/shortcuts.py +0 -0
  82. {tesorotools_python-0.0.52 → tesorotools_python-0.0.53}/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.52
3
+ Version: 0.0.53
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -0,0 +1,479 @@
1
+ """Bank of Spain (Banco de Espana, BdE) data provider.
2
+
3
+ Downloads historical time series from the BdE public REST API.
4
+ No authentication required.
5
+
6
+ Install with::
7
+
8
+ pip install tesorotools-python[bde]
9
+
10
+ API reference
11
+ -------------
12
+ Endpoints:
13
+ https://app.bde.es/bierest/resources/srdatosapp/listaSeries
14
+ https://app.bde.es/bierest/resources/srdatosapp/favoritas
15
+
16
+ Query parameters (names are fixed by the BdE API):
17
+ ``idioma`` : ``"es"`` | ``"en"``
18
+ ``series`` : comma-separated series codes
19
+ ``rango`` : temporal range; **the valid values depend on
20
+ the frequency of the series** (see below).
21
+
22
+ The ``rango`` parameter
23
+ -----------------------
24
+ The BdE API rejects a ``rango`` value that does not match the
25
+ series frequency (``codFrecuencia``). The official table is:
26
+
27
+ ========== ==============================
28
+ Frequency Valid ``rango`` values
29
+ ========== ==============================
30
+ Daily (D) ``3M``, ``12M``, ``36M``
31
+ Monthly (M) ``30M``, ``60M``, ``MAX``
32
+ Quarter (Q) ``30M``, ``60M``, ``MAX``
33
+ Annual (A) ``60M``, ``MAX``
34
+ ========== ==============================
35
+
36
+ Every frequency additionally accepts a specific calendar year
37
+ (e.g. ``rango=2024``), which is the only frequency-agnostic
38
+ value. Daily series have **no** ``MAX``: their largest window
39
+ is 36 months, so longer histories are paginated year by year.
40
+
41
+ Because ``rango`` applies to the whole request, series of
42
+ different frequencies cannot share a request. This provider
43
+ therefore detects each code's frequency first (via the cheap
44
+ ``favoritas`` endpoint) and downloads one frequency at a time.
45
+
46
+ Notes
47
+ -----
48
+ - ECB DCF series (credit stock data) are also available
49
+ through this API because the BdE redistributes them.
50
+ - The API accepts multiple series per request, but may
51
+ time out with too many. Downloads are batched.
52
+ - Timestamps include timezone info (UTC). They are
53
+ normalized to tz-naive midnight for pandas compatibility.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import logging
59
+ import re
60
+ from typing import ClassVar, NotRequired, TypedDict, cast
61
+
62
+ import pandas as pd
63
+ import requests
64
+
65
+ from tesorotools.providers.base import DataProvider
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ class _BdeSeriesEntry(TypedDict):
71
+ """Shape of a single series object in the ``listaSeries``
72
+ JSON response. ``informacion`` is optional metadata."""
73
+
74
+ serie: str
75
+ fechas: list[str]
76
+ valores: list[int | float | None]
77
+ decimales: int
78
+ codFrecuencia: str
79
+ informacion: NotRequired[list[dict[str, str]]]
80
+
81
+
82
+ _BASE_URL = "https://app.bde.es/bierest/resources/srdatosapp"
83
+ _LIST_URL = f"{_BASE_URL}/listaSeries"
84
+ _FAV_URL = f"{_BASE_URL}/favoritas"
85
+
86
+ DEFAULT_TIMEOUT = 30
87
+ BATCH_SIZE = 10
88
+
89
+ #: Frequency code the API treats specially: no ``MAX`` and a
90
+ #: distinct set of month-window ranges.
91
+ _DAILY = "D"
92
+ #: Largest month window the API offers for daily series.
93
+ _MAX_DAILY_MONTHS = 36
94
+ #: Extra months added when translating a start date into a
95
+ #: month-window range, so publication lag never truncates it.
96
+ _RANGE_BUFFER_MONTHS = 3
97
+
98
+
99
+ class BdeProvider(DataProvider):
100
+ """Provider that downloads series from the Bank of Spain.
101
+
102
+ The BdE API is public and requires no credentials.
103
+
104
+ Parameters
105
+ ----------
106
+ language
107
+ Language for metadata. ``"es"`` for Spanish,
108
+ ``"en"`` for English.
109
+ timeout
110
+ Maximum seconds to wait per HTTP request.
111
+ """
112
+
113
+ PROVIDER_NAME: ClassVar[str] = "bde"
114
+
115
+ def __init__(
116
+ self,
117
+ *,
118
+ language: str = "es",
119
+ timeout: int = DEFAULT_TIMEOUT,
120
+ ) -> None:
121
+ self._language = language
122
+ self._timeout = timeout
123
+
124
+ def fetch(
125
+ self,
126
+ codes: list[str],
127
+ start: str | None = None,
128
+ end: str | None = None,
129
+ ) -> pd.DataFrame:
130
+ """Download one or more series from the BdE.
131
+
132
+ Detects the frequency of every code, groups the codes
133
+ by frequency (series of different frequencies cannot
134
+ share a request), downloads each group with a valid
135
+ ``rango`` for that frequency, and merges everything
136
+ into one DataFrame.
137
+
138
+ Parameters
139
+ ----------
140
+ codes
141
+ BdE series codes.
142
+ start
143
+ Earliest date to include (ISO format).
144
+ If ``None``, fetches the full history.
145
+ end
146
+ Ignored by this provider. The BdE API always
147
+ returns data up to the latest observation.
148
+ """
149
+ if not codes:
150
+ return pd.DataFrame()
151
+
152
+ frequencies = self._detect_frequencies(codes)
153
+ missing = [c for c in codes if c not in frequencies]
154
+ if missing:
155
+ logger.warning(
156
+ "BdE: series not found: %s",
157
+ ", ".join(missing),
158
+ )
159
+
160
+ start_ts = pd.Timestamp(start) if start else None
161
+ now = pd.Timestamp.now()
162
+ frames: list[pd.DataFrame] = []
163
+
164
+ for freq, group in _group_by_frequency(codes, frequencies).items():
165
+ df = self._fetch_group(group, freq, start_ts, now)
166
+ if not df.empty:
167
+ frames.append(df)
168
+
169
+ if not frames:
170
+ return pd.DataFrame()
171
+
172
+ result = pd.concat(frames, axis=1)
173
+ result.sort_index(inplace=True)
174
+ result = result[[c for c in codes if c in result.columns]]
175
+
176
+ if start_ts is not None:
177
+ result = result.loc[result.index >= start_ts]
178
+
179
+ return result
180
+
181
+ def is_available(self) -> bool:
182
+ """Ping the BdE API to check connectivity."""
183
+ try:
184
+ resp = requests.get(
185
+ _FAV_URL,
186
+ params={
187
+ "idioma": self._language,
188
+ "series": "DN_1TI2TIE42",
189
+ },
190
+ timeout=5,
191
+ )
192
+ return resp.status_code == 200
193
+ except requests.RequestException:
194
+ return False
195
+
196
+ # -- frequency detection ---------------------------------
197
+
198
+ def _detect_frequencies(self, codes: list[str]) -> dict[str, str]:
199
+ """Return ``{code: codFrecuencia}`` for the codes the
200
+ API knows about.
201
+
202
+ Uses the lightweight ``favoritas`` endpoint (last datum
203
+ only), which returns the frequency of every series and
204
+ works for a mixed-frequency batch.
205
+ """
206
+ frequencies: dict[str, str] = {}
207
+ for batch in _batches(codes, BATCH_SIZE):
208
+ resp = requests.get(
209
+ _FAV_URL,
210
+ params={
211
+ "idioma": self._language,
212
+ "series": ",".join(batch),
213
+ },
214
+ timeout=self._timeout,
215
+ )
216
+ resp.raise_for_status()
217
+ payload = resp.json()
218
+ _check_api_error(payload)
219
+ for entry in cast(list[dict[str, object]], payload):
220
+ code = entry.get("serie")
221
+ freq = entry.get("codFrecuencia")
222
+ if isinstance(code, str) and isinstance(freq, str):
223
+ frequencies[code] = freq
224
+ return frequencies
225
+
226
+ # -- per-frequency download ------------------------------
227
+
228
+ def _fetch_group(
229
+ self,
230
+ codes: list[str],
231
+ freq: str,
232
+ start: pd.Timestamp | None,
233
+ now: pd.Timestamp,
234
+ ) -> pd.DataFrame:
235
+ """Download a set of same-frequency codes."""
236
+ if freq == _DAILY and start is None:
237
+ first_year = self._earliest_year(codes, now)
238
+ ranges = _year_ranges(first_year, now.year)
239
+ else:
240
+ ranges = _plan_ranges(freq, start, now)
241
+ return self._download_ranges(codes, ranges)
242
+
243
+ def _earliest_year(self, codes: list[str], now: pd.Timestamp) -> int:
244
+ """First observation year across a daily batch.
245
+
246
+ Reads the ``"Primer valor"`` metadata (format
247
+ ``[DD/MM/YYYY]``) returned by ``listaSeries``. Falls
248
+ back to the last ``_MAX_DAILY_MONTHS`` when no year can
249
+ be parsed, so a metadata change never breaks the fetch.
250
+ """
251
+ params = {
252
+ "idioma": self._language,
253
+ "series": ",".join(codes[:BATCH_SIZE]),
254
+ "rango": f"{_MAX_DAILY_MONTHS}M",
255
+ }
256
+ resp = requests.get(_LIST_URL, params=params, timeout=self._timeout)
257
+ resp.raise_for_status()
258
+ payload = resp.json()
259
+ _check_api_error(payload)
260
+
261
+ years = [
262
+ year
263
+ for entry in cast(list[_BdeSeriesEntry], payload)
264
+ if (year := _first_observation_year(entry)) is not None
265
+ ]
266
+ if not years:
267
+ logger.warning(
268
+ "BdE: could not determine daily history start; "
269
+ "limiting to last %d months",
270
+ _MAX_DAILY_MONTHS,
271
+ )
272
+ return now.year - _MAX_DAILY_MONTHS // 12
273
+ return min(years)
274
+
275
+ def _download_ranges(
276
+ self,
277
+ codes: list[str],
278
+ ranges: list[str],
279
+ ) -> pd.DataFrame:
280
+ """Download ``codes`` for every ``rango`` in ``ranges``
281
+ and stack the results by date."""
282
+ per_range: list[pd.DataFrame] = []
283
+ for time_range in ranges:
284
+ batch_frames: list[pd.DataFrame] = []
285
+ for batch in _batches(codes, BATCH_SIZE):
286
+ df = self._download_batch(batch, time_range)
287
+ if not df.empty:
288
+ batch_frames.append(df)
289
+ if batch_frames:
290
+ per_range.append(pd.concat(batch_frames, axis=1))
291
+
292
+ if not per_range:
293
+ return pd.DataFrame()
294
+
295
+ combined = pd.concat(per_range, axis=0)
296
+ combined = combined[~combined.index.duplicated(keep="last")]
297
+ combined.sort_index(inplace=True)
298
+ return combined
299
+
300
+ def _download_batch(
301
+ self,
302
+ codes: list[str],
303
+ time_range: str,
304
+ ) -> pd.DataFrame:
305
+ params = {
306
+ "idioma": self._language,
307
+ "series": ",".join(codes),
308
+ "rango": time_range,
309
+ }
310
+ logger.info(
311
+ "BdE: downloading %d series (rango=%s)...",
312
+ len(codes),
313
+ time_range,
314
+ )
315
+ resp = requests.get(
316
+ _LIST_URL,
317
+ params=params,
318
+ timeout=self._timeout,
319
+ )
320
+ resp.raise_for_status()
321
+
322
+ payload = resp.json()
323
+ _check_api_error(payload)
324
+ return _parse_series_list(cast(list[_BdeSeriesEntry], payload))
325
+
326
+
327
+ # ----------------------------------------------------------
328
+ # Module-level helpers
329
+ # ----------------------------------------------------------
330
+
331
+
332
+ class _BdeErrorResponse(TypedDict, total=False):
333
+ errNum: int # noqa: N815
334
+ errMsgUsr: str # noqa: N815
335
+ errMsgDebug: str # noqa: N815
336
+
337
+
338
+ def _check_api_error(payload: object) -> None:
339
+ """Raise RuntimeError if the API returned an error."""
340
+ if not isinstance(payload, dict):
341
+ return
342
+ if "errNum" not in payload:
343
+ return
344
+ err = cast(_BdeErrorResponse, payload)
345
+ raise RuntimeError(
346
+ f"BdE API error {err.get('errNum', '?')}: {err.get('errMsgUsr', '')}"
347
+ )
348
+
349
+
350
+ def _group_by_frequency(
351
+ codes: list[str],
352
+ frequencies: dict[str, str],
353
+ ) -> dict[str, list[str]]:
354
+ """Group *codes* by their frequency, preserving order.
355
+
356
+ Codes absent from *frequencies* (unknown to the API) are
357
+ dropped; ``fetch`` has already warned about them.
358
+ """
359
+ groups: dict[str, list[str]] = {}
360
+ for code in codes:
361
+ freq = frequencies.get(code)
362
+ if freq is None:
363
+ continue
364
+ groups.setdefault(freq, []).append(code)
365
+ return groups
366
+
367
+
368
+ def _plan_ranges(
369
+ freq: str,
370
+ start: pd.Timestamp | None,
371
+ now: pd.Timestamp,
372
+ ) -> list[str]:
373
+ """Pick the ``rango`` value(s) needed to cover *start* for a
374
+ series of frequency *freq*.
375
+
376
+ Returns a list because daily series older than
377
+ ``_MAX_DAILY_MONTHS`` must be paginated one calendar year
378
+ at a time. Every other case is a single keyword range.
379
+ """
380
+ if freq == _DAILY:
381
+ # Daily start is never None here: that case is resolved
382
+ # in _fetch_group, which knows the first observation.
383
+ months = _months_between(cast(pd.Timestamp, start), now)
384
+ if months <= 3:
385
+ return ["3M"]
386
+ if months <= 12:
387
+ return ["12M"]
388
+ if months <= _MAX_DAILY_MONTHS:
389
+ return [f"{_MAX_DAILY_MONTHS}M"]
390
+ return _year_ranges(cast(pd.Timestamp, start).year, now.year)
391
+
392
+ if start is None:
393
+ return ["MAX"]
394
+
395
+ months = _months_between(start, now)
396
+ # Annual series reject "30M"; their smallest window is 60M.
397
+ if freq != "A" and months <= 30:
398
+ return ["30M"]
399
+ if months <= 60:
400
+ return ["60M"]
401
+ return ["MAX"]
402
+
403
+
404
+ def _year_ranges(first_year: int, last_year: int) -> list[str]:
405
+ """Calendar-year ``rango`` values from *first_year* to
406
+ *last_year* (inclusive)."""
407
+ return [str(year) for year in range(first_year, last_year + 1)]
408
+
409
+
410
+ def _months_between(start: pd.Timestamp, now: pd.Timestamp) -> int:
411
+ """Whole months from *start* to *now*, plus a lag buffer."""
412
+ return (
413
+ (now.year - start.year) * 12
414
+ + (now.month - start.month)
415
+ + _RANGE_BUFFER_MONTHS
416
+ )
417
+
418
+
419
+ _FIRST_YEAR_RE = re.compile(r"\[\d{1,2}\D\d{1,2}\D(\d{4})\]")
420
+
421
+
422
+ def _first_observation_year(entry: _BdeSeriesEntry) -> int | None:
423
+ """Parse the first-observation year from a series' metadata.
424
+
425
+ The ``"Primer valor"`` entry looks like ``"[04/01/1999] ..."``
426
+ or ``"[18.07.1989] ..."`` -- the day/month separator varies
427
+ by series, so any non-digit separator is accepted. Only
428
+ daily series (this helper's only caller) use that format.
429
+ """
430
+ for info in entry.get("informacion", []):
431
+ if info.get("titulo") == "Primer valor":
432
+ match = _FIRST_YEAR_RE.search(info.get("descripcion", ""))
433
+ if match:
434
+ return int(match.group(1))
435
+ return None
436
+
437
+
438
+ def _parse_series_list(
439
+ series_list: list[_BdeSeriesEntry],
440
+ ) -> pd.DataFrame:
441
+ """Convert the JSON series list into a DataFrame."""
442
+ columns: dict[str, pd.Series[float]] = {}
443
+
444
+ for entry in series_list:
445
+ code = entry["serie"]
446
+ raw_dates = entry["fechas"]
447
+ raw_values = entry["valores"]
448
+
449
+ if not raw_dates:
450
+ continue
451
+
452
+ dates = pd.to_datetime(raw_dates, utc=True)
453
+ dates = dates.tz_localize(None).normalize()
454
+ values = [_to_float(v) for v in raw_values]
455
+
456
+ columns[code] = pd.Series(values, index=dates, name=code, dtype=float)
457
+
458
+ if not columns:
459
+ return pd.DataFrame()
460
+
461
+ df = pd.DataFrame(columns)
462
+ df.index.name = "date"
463
+ df.sort_index(inplace=True)
464
+ return df
465
+
466
+
467
+ def _to_float(raw: int | float | None) -> float | None:
468
+ """Convert a raw API value to float, or None."""
469
+ if raw is None:
470
+ return None
471
+ try:
472
+ return float(raw)
473
+ except (ValueError, TypeError):
474
+ return None
475
+
476
+
477
+ def _batches(items: list[str], size: int) -> list[list[str]]:
478
+ """Split a list into sublists of at most ``size``."""
479
+ return [items[i : i + size] for i in range(0, len(items), size)]
@@ -1,269 +0,0 @@
1
- """Bank of Spain (Banco de Espana, BdE) data provider.
2
-
3
- Downloads historical time series from the BdE public REST API.
4
- No authentication required.
5
-
6
- Install with::
7
-
8
- pip install tesorotools-python[bde]
9
-
10
- API reference
11
- -------------
12
- Endpoint:
13
- https://app.bde.es/bierest/resources/srdatosapp/listaSeries
14
-
15
- Query parameters (names are fixed by the BdE API):
16
- ``idioma`` : ``"es"`` | ``"en"``
17
- ``series`` : comma-separated series codes
18
- ``rango`` : ``"MAX"`` | ``"30M"`` | ``"60M"``
19
-
20
- Notes
21
- -----
22
- - ECB DCF series (credit stock data) are also available
23
- through this API because the BdE redistributes them.
24
- - The API accepts multiple series per request, but may
25
- time out with too many. Downloads are batched.
26
- - Timestamps include timezone info (UTC). They are
27
- normalized to tz-naive midnight for pandas compatibility.
28
- """
29
-
30
- from __future__ import annotations
31
-
32
- import logging
33
- from typing import ClassVar, TypedDict, cast
34
-
35
- import pandas as pd
36
- import requests
37
-
38
- from tesorotools.providers.base import DataProvider
39
-
40
- logger = logging.getLogger(__name__)
41
-
42
-
43
- class _BdeSeriesEntry(TypedDict):
44
- """Shape of a single series object in the BdE API
45
- JSON response."""
46
-
47
- serie: str
48
- fechas: list[str]
49
- valores: list[int | float | None]
50
- decimales: int
51
- codFrecuencia: str
52
-
53
-
54
- _LIST_URL = "https://app.bde.es/bierest/resources/srdatosapp/listaSeries"
55
- _PING_URL = "https://app.bde.es/bierest/resources/srdatosapp/favoritas"
56
-
57
- DEFAULT_TIMEOUT = 30
58
- BATCH_SIZE = 10
59
-
60
-
61
- class BdeProvider(DataProvider):
62
- """Provider that downloads series from the Bank of Spain.
63
-
64
- The BdE API is public and requires no credentials.
65
-
66
- Parameters
67
- ----------
68
- language
69
- Language for metadata. ``"es"`` for Spanish,
70
- ``"en"`` for English.
71
- timeout
72
- Maximum seconds to wait per HTTP request.
73
- """
74
-
75
- PROVIDER_NAME: ClassVar[str] = "bde"
76
-
77
- def __init__(
78
- self,
79
- *,
80
- language: str = "es",
81
- timeout: int = DEFAULT_TIMEOUT,
82
- ) -> None:
83
- self._language = language
84
- self._timeout = timeout
85
-
86
- def fetch(
87
- self,
88
- codes: list[str],
89
- start: str | None = None,
90
- end: str | None = None,
91
- ) -> pd.DataFrame:
92
- """Download one or more series from the BdE.
93
-
94
- Splits the series into batches of ``BATCH_SIZE``,
95
- downloads each batch in a single HTTP request, and
96
- merges the results into one DataFrame.
97
-
98
- Parameters
99
- ----------
100
- codes
101
- BdE series codes.
102
- start
103
- Earliest date to include (ISO format).
104
- If ``None``, fetches the full history.
105
- end
106
- Ignored by this provider. The BdE API always
107
- returns data up to the latest observation.
108
- """
109
- if not codes:
110
- return pd.DataFrame()
111
-
112
- time_range = _compute_time_range(start)
113
- frames: list[pd.DataFrame] = []
114
-
115
- for batch in _batches(codes, BATCH_SIZE):
116
- df = self._download_batch(batch, time_range)
117
- if not df.empty:
118
- frames.append(df)
119
-
120
- if not frames:
121
- return pd.DataFrame()
122
-
123
- result = pd.concat(frames, axis=1)
124
- result.sort_index(inplace=True)
125
-
126
- if start:
127
- cutoff = pd.Timestamp(start)
128
- result = result.loc[result.index >= cutoff]
129
-
130
- return result
131
-
132
- def is_available(self) -> bool:
133
- """Ping the BdE API to check connectivity."""
134
- try:
135
- resp = requests.get(
136
- _PING_URL,
137
- params={
138
- "idioma": self._language,
139
- "series": "DN_1TI2TIE42",
140
- },
141
- timeout=5,
142
- )
143
- return resp.status_code == 200
144
- except requests.RequestException:
145
- return False
146
-
147
- def _download_batch(
148
- self,
149
- codes: list[str],
150
- time_range: str,
151
- ) -> pd.DataFrame:
152
- params = {
153
- "idioma": self._language,
154
- "series": ",".join(codes),
155
- "rango": time_range,
156
- }
157
- logger.info("BdE: downloading %d series...", len(codes))
158
- resp = requests.get(
159
- _LIST_URL,
160
- params=params,
161
- timeout=self._timeout,
162
- )
163
- resp.raise_for_status()
164
-
165
- payload = resp.json()
166
- _check_api_error(payload)
167
- return _parse_series_list(cast(list[_BdeSeriesEntry], payload), codes)
168
-
169
-
170
- # ----------------------------------------------------------
171
- # Module-level helpers
172
- # ----------------------------------------------------------
173
-
174
-
175
- class _BdeErrorResponse(TypedDict, total=False):
176
- errNum: int # noqa: N815
177
- errMsgUsr: str # noqa: N815
178
- errMsgDebug: str # noqa: N815
179
-
180
-
181
- def _check_api_error(payload: object) -> None:
182
- """Raise RuntimeError if the API returned an error."""
183
- if not isinstance(payload, dict):
184
- return
185
- if "errNum" not in payload:
186
- return
187
- err = cast(_BdeErrorResponse, payload)
188
- raise RuntimeError(
189
- f"BdE API error {err.get('errNum', '?')}: {err.get('errMsgUsr', '')}"
190
- )
191
-
192
-
193
- def _compute_time_range(start: str | None) -> str:
194
- """Translate a start date into a BdE API range value.
195
-
196
- The BdE API only accepts ``"30M"``, ``"60M"``, or
197
- ``"MAX"`` for the ``rango`` parameter. This function
198
- picks the smallest value that covers the requested
199
- start date.
200
- """
201
- if start is None:
202
- return "MAX"
203
-
204
- start_ts = pd.Timestamp(start)
205
- now = pd.Timestamp.now()
206
- months_needed = (
207
- (now.year - start_ts.year) * 12
208
- + (now.month - start_ts.month)
209
- + 3 # buffer for publication lag
210
- )
211
-
212
- if months_needed <= 30:
213
- return "30M"
214
- if months_needed <= 60:
215
- return "60M"
216
- return "MAX"
217
-
218
-
219
- def _parse_series_list(
220
- series_list: list[_BdeSeriesEntry],
221
- requested: list[str],
222
- ) -> pd.DataFrame:
223
- """Convert the JSON series list into a DataFrame."""
224
- columns: dict[str, pd.Series[float]] = {}
225
-
226
- for entry in series_list:
227
- code = entry["serie"]
228
- raw_dates = entry["fechas"]
229
- raw_values = entry["valores"]
230
-
231
- if not raw_dates:
232
- logger.warning("BdE: %s returned no data", code)
233
- continue
234
-
235
- dates = pd.to_datetime(raw_dates, utc=True)
236
- dates = dates.tz_localize(None).normalize()
237
- values = [_to_float(v) for v in raw_values]
238
-
239
- columns[code] = pd.Series(values, index=dates, name=code, dtype=float)
240
-
241
- missing = set(requested) - set(columns)
242
- if missing:
243
- logger.warning(
244
- "BdE: series not found: %s",
245
- ", ".join(sorted(missing)),
246
- )
247
-
248
- if not columns:
249
- return pd.DataFrame()
250
-
251
- df = pd.DataFrame(columns)
252
- df.index.name = "date"
253
- df.sort_index(inplace=True)
254
- return df
255
-
256
-
257
- def _to_float(raw: int | float | None) -> float | None:
258
- """Convert a raw API value to float, or None."""
259
- if raw is None:
260
- return None
261
- try:
262
- return float(raw)
263
- except (ValueError, TypeError):
264
- return None
265
-
266
-
267
- def _batches(items: list[str], size: int) -> list[list[str]]:
268
- """Split a list into sublists of at most ``size``."""
269
- return [items[i : i + size] for i in range(0, len(items), size)]