tesorotools-python 0.0.44__py3-none-any.whl → 0.0.45__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  """Public provider API.
2
2
 
3
- ``BdeProvider``, ``EcbProvider`` and ``LSEGProvider`` depend on
4
- the optional ``[bde]`` / ``[ecb]`` / ``[lseg]`` extras and are
5
- imported lazily through ``__getattr__``; importing
6
- ``tesorotools.providers`` itself does not require those extras.
3
+ ``BdeProvider``, ``EcbProvider``, ``ImfIrfclProvider`` and
4
+ ``LSEGProvider`` depend on the optional ``[bde]`` / ``[ecb]`` /
5
+ ``[imf]`` / ``[lseg]`` extras and are imported lazily through
6
+ ``__getattr__``; importing ``tesorotools.providers`` itself does not
7
+ require those extras.
7
8
  """
8
9
 
9
10
  from typing import TYPE_CHECKING, Any
@@ -17,12 +18,14 @@ from tesorotools.providers.base import (
17
18
  if TYPE_CHECKING:
18
19
  from tesorotools.providers.bde import BdeProvider
19
20
  from tesorotools.providers.ecb import EcbProvider
21
+ from tesorotools.providers.imf_irfcl import ImfIrfclProvider
20
22
  from tesorotools.providers.lseg import LSEGProvider
21
23
 
22
24
  __all__ = [
23
25
  "BdeProvider",
24
26
  "DataProvider",
25
27
  "EcbProvider",
28
+ "ImfIrfclProvider",
26
29
  "LSEGProvider",
27
30
  "RegistryProtocol",
28
31
  "bootstrap_providers",
@@ -38,6 +41,10 @@ def __getattr__(name: str) -> Any:
38
41
  from tesorotools.providers.ecb import EcbProvider
39
42
 
40
43
  return EcbProvider
44
+ if name == "ImfIrfclProvider":
45
+ from tesorotools.providers.imf_irfcl import ImfIrfclProvider
46
+
47
+ return ImfIrfclProvider
41
48
  if name == "LSEGProvider":
42
49
  from tesorotools.providers.lseg import LSEGProvider
43
50
 
@@ -0,0 +1,242 @@
1
+ """IMF IRFCL data provider via the official SDMX 3.0 REST API.
2
+
3
+ Downloads time series from the IMF's IRFCL dataflow (International
4
+ Reserves and Foreign Currency Liquidity), which publishes official
5
+ reserve assets per country, gold included. No authentication
6
+ required.
7
+
8
+ Install with the ``imf`` optional extra::
9
+
10
+ uv pip install "tesorotools-python[imf]"
11
+
12
+ Indicators (verified against the API in jun-2026):
13
+
14
+ * ``IRFCLDT1_IRFCL56V_FTO`` — gold, VOLUME in fine troy ounces. This
15
+ is the **physical stock** (despite the IMF label saying "millions of
16
+ ounces", the raw value comes in ounces: Spain ≈ 9,053,750 oz ≈
17
+ 281.6 t).
18
+ * ``IRFCLDT1_IRFCL56_USD`` — gold value in USD (approx. market value).
19
+ * ``IRFCLDT1_IRFCL65_USD`` — TOTAL official reserve assets in USD
20
+ (denominator of the % gold represents over reserves).
21
+
22
+ API base
23
+ --------
24
+ ``https://api.imf.org/external/sdmx/3.0/data/dataflow/IMF.STA/IRFCL/~/{key}``
25
+
26
+ Code convention (4 dimensions of DSD_IRFCL_PUB)
27
+ -----------------------------------------------
28
+ ::
29
+
30
+ {COUNTRY}.{INDICATOR}.{SECTOR}.{FREQUENCY}
31
+
32
+ * COUNTRY ISO-3 country code (``ESP``, ``USA``, ``DEU`` …).
33
+ * INDICATOR the IRFCL indicator (``IRFCLDT1_IRFCL56V_FTO`` …).
34
+ * SECTOR ``*`` (wildcard). Countries report gold under different
35
+ sectors (Spain ``S1X``, the US ``S1XS1311`` …); the
36
+ wildcard captures them all. When a key returns several
37
+ series (same gold total under different sectors) they are
38
+ collapsed by date in :meth:`_fetch_one` (last write wins;
39
+ values coincide).
40
+ * FREQUENCY ``M`` (monthly; reserves are published every month).
41
+
42
+ Keys are passed verbatim to the API; this provider does not parse them.
43
+
44
+ Date label
45
+ ----------
46
+ IRFCL labels the period and the observation is the end-of-period
47
+ balance. We map to the real end of period:
48
+
49
+ * ``"2026"`` → 2026-12-31
50
+ * ``"2026-Q1"`` → 2026-03-31
51
+ * ``"2026-M04"`` → 2026-04-30
52
+
53
+ Corporate TLS
54
+ -------------
55
+ ``truststore`` is enabled on import (if installed) so the provider
56
+ works behind MINECO's TLS proxy without ``verify=False``.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import logging
62
+ from typing import ClassVar
63
+
64
+ import pandas as pd
65
+ import requests
66
+
67
+ from tesorotools.providers.base import DataProvider
68
+
69
+ logger = logging.getLogger(__name__)
70
+
71
+
72
+ def _enable_truststore_once() -> bool:
73
+ """Enable truststore (the OS cert store) if available."""
74
+ try:
75
+ import truststore # type: ignore[import-not-found]
76
+ except ImportError:
77
+ return False
78
+ truststore.inject_into_ssl() # pyright: ignore[reportUnknownMemberType]
79
+ return True
80
+
81
+
82
+ _TRUSTSTORE_ACTIVE = _enable_truststore_once()
83
+
84
+ _API_BASE = (
85
+ "https://api.imf.org/external/sdmx/3.0/data/dataflow/IMF.STA/IRFCL/~"
86
+ )
87
+ _DEFAULT_TIMEOUT = 60
88
+ _DEFAULT_START = "2000"
89
+ _HEADERS = {"Accept": "application/vnd.sdmx.data+json"}
90
+
91
+
92
+ class ImfIrfclProvider(DataProvider):
93
+ """Provider that downloads IMF IRFCL series via SDMX 3.0.
94
+
95
+ One HTTP request per key. Series with no observations (a country
96
+ that does not report that indicator) are skipped with a warning
97
+ instead of aborting the whole download.
98
+
99
+ Parameters
100
+ ----------
101
+ timeout
102
+ Max seconds per HTTP request.
103
+ session
104
+ Optional pre-built ``requests.Session``. Useful for tests or
105
+ for custom retry policies.
106
+ """
107
+
108
+ PROVIDER_NAME: ClassVar[str] = "imf_irfcl"
109
+
110
+ def __init__(
111
+ self,
112
+ *,
113
+ timeout: int = _DEFAULT_TIMEOUT,
114
+ session: requests.Session | None = None,
115
+ ) -> None:
116
+ self._timeout = timeout
117
+ self._session = session or requests.Session()
118
+
119
+ def fetch(
120
+ self,
121
+ codes: list[str],
122
+ start: str | None = None,
123
+ end: str | None = None,
124
+ ) -> pd.DataFrame:
125
+ """Download a list of IRFCL keys and return a wide DataFrame.
126
+
127
+ Parameters
128
+ ----------
129
+ codes
130
+ List of IRFCL keys (``"{COUNTRY}.{INDICATOR}.{SECTOR}.{FREQ}"``).
131
+ start
132
+ Start period (e.g. ``"2024"``). If ``None`` defaults to
133
+ ``"2000"``.
134
+ end
135
+ End date as ISO string. If ``None`` up to latest.
136
+
137
+ Returns
138
+ -------
139
+ pd.DataFrame
140
+ Wide DataFrame. Index = dates (tz-naive), columns = the
141
+ requested codes. Missing observations are NaN.
142
+ """
143
+ if not codes:
144
+ return pd.DataFrame()
145
+
146
+ cols: dict[str, pd.Series[float]] = {}
147
+ for code in codes:
148
+ try:
149
+ series = self._fetch_one(code, start=start)
150
+ except requests.RequestException as e:
151
+ logger.warning("IRFCL: failed downloading %s (%s)", code, e)
152
+ continue
153
+ if series is not None and not series.empty:
154
+ cols[code] = series
155
+
156
+ if not cols:
157
+ return pd.DataFrame()
158
+
159
+ df = pd.DataFrame(cols).sort_index()
160
+ df.index.name = "date"
161
+ if end:
162
+ df = df.loc[df.index <= pd.Timestamp(end)]
163
+ return df
164
+
165
+ def is_available(self) -> bool:
166
+ """Check whether the IMF API responds.
167
+
168
+ Hits a small ESP gold-volume query.
169
+ """
170
+ try:
171
+ r = self._session.get(
172
+ f"{_API_BASE}/ESP.IRFCLDT1_IRFCL56V_FTO.*.M?startPeriod=2024",
173
+ timeout=10,
174
+ headers=_HEADERS,
175
+ )
176
+ return r.status_code == 200
177
+ except requests.RequestException:
178
+ return False
179
+
180
+ def _fetch_one(
181
+ self, code: str, start: str | None = None
182
+ ) -> "pd.Series[float] | None":
183
+ """Download a single IRFCL series and return it as a Series."""
184
+ url = f"{_API_BASE}/{code}?startPeriod={start or _DEFAULT_START}"
185
+ r = self._session.get(url, timeout=self._timeout, headers=_HEADERS)
186
+ if r.status_code != 200:
187
+ logger.warning("IRFCL: HTTP %d for %s", r.status_code, code)
188
+ return None
189
+ payload = r.json()
190
+ data_sets = payload.get("data", {}).get("dataSets", [])
191
+ structures = payload.get("data", {}).get("structures", [])
192
+ if not data_sets or not structures:
193
+ return None
194
+ time_dim = structures[0].get("dimensions", {}).get("observation", [])
195
+ if not time_dim:
196
+ return None
197
+ time_values = [t["value"] for t in time_dim[0].get("values", [])]
198
+ series_dict = data_sets[0].get("series", {})
199
+
200
+ data: dict[pd.Timestamp, float] = {}
201
+ for _, series in series_dict.items():
202
+ observations = series.get("observations", {})
203
+ for idx_str, payload_obs in observations.items():
204
+ if not payload_obs or payload_obs[0] is None:
205
+ continue
206
+ try:
207
+ value = float(payload_obs[0])
208
+ except (TypeError, ValueError):
209
+ continue
210
+ idx = int(idx_str)
211
+ if idx >= len(time_values):
212
+ continue
213
+ ts = _parse_period(time_values[idx])
214
+ if ts is not None:
215
+ data[ts] = value
216
+
217
+ if not data:
218
+ return None
219
+ return pd.Series(data, dtype="float64").sort_index()
220
+
221
+
222
+ def _parse_period(label: str) -> pd.Timestamp | None:
223
+ """Convert an IRFCL label to the **real end of period**.
224
+
225
+ * ``"YYYY"`` → December 31 of that year.
226
+ * ``"YYYY-QN"`` → last day of the quarter.
227
+ * ``"YYYY-MNN"`` → last day of the month.
228
+ """
229
+ try:
230
+ if "-Q" in label:
231
+ period = pd.Period(label.replace("-Q", "Q"), freq="Q")
232
+ return period.end_time.normalize()
233
+ if "-M" in label: # "YYYY-MNN"
234
+ year_str, month_str = label.split("-M")
235
+ period = pd.Period(
236
+ freq="M", year=int(year_str), month=int(month_str)
237
+ )
238
+ return period.end_time.normalize()
239
+ year = int(label)
240
+ return pd.Timestamp(year=year, month=12, day=31)
241
+ except (ValueError, TypeError):
242
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.44
3
+ Version: 0.0.45
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -16,5 +16,8 @@ Provides-Extra: bde
16
16
  Requires-Dist: requests>=2.31; extra == 'bde'
17
17
  Provides-Extra: ecb
18
18
  Requires-Dist: requests>=2.31; extra == 'ecb'
19
+ Provides-Extra: imf
20
+ Requires-Dist: requests>=2.31; extra == 'imf'
21
+ Requires-Dist: truststore>=0.10; extra == 'imf'
19
22
  Provides-Extra: lseg
20
23
  Requires-Dist: lseg-data>=2.1; extra == 'lseg'
@@ -39,10 +39,11 @@ tesorotools/pipeline/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hS
39
39
  tesorotools/pipeline/diagnose.py,sha256=Ji_T3rAn-z4NhSdkpyPsnwQ1CTl4u5-1gnJZC8d1Sxg,1470
40
40
  tesorotools/pipeline/engine.py,sha256=D4gKIv1bO6pw2gUMWJ7CVRv7e_kWWrWpZCzupeZCx0k,2929
41
41
  tesorotools/pipeline/rules.py,sha256=OXi6dk2Gx63aO-SGjYnblhAah7tQIynNq2DKtfMGIEk,10041
42
- tesorotools/providers/__init__.py,sha256=uSer9cXywNBA4K7gfkon7d5JoXEkrP5sBEIw5YoW35I,1235
42
+ tesorotools/providers/__init__.py,sha256=7iEs_BeNeVSMwzjsVd16b6GRmj3bgzMD30rNLEKwupo,1495
43
43
  tesorotools/providers/base.py,sha256=6PA1HKhbc-Ehy27Ekm4wn-jWthEdL39Q9qxLCoqH_Jo,7666
44
44
  tesorotools/providers/bde.py,sha256=wVfaKzgInTjThJ_twQ-ycA6FUMAKLx3BWL19P03LR0g,7226
45
45
  tesorotools/providers/ecb.py,sha256=8UIW-NFLlGVIM7E2Sd_9UEWryt5wwilY3Hv13dnjY68,6422
46
+ tesorotools/providers/imf_irfcl.py,sha256=jGUDFGJBpNRsWKRI90BzI5AzIOmrSj6XwBS6ZUThc8A,8041
46
47
  tesorotools/providers/lseg.py,sha256=paKdSxb1tWv26YCwMD0dEUfF4GLFJWzHukKII9WPges,30183
47
48
  tesorotools/render/__init__.py,sha256=cqC5hoOwt_ZEE7gNG8nBbvf59YI99Lro8e18WsMKVT0,589
48
49
  tesorotools/render/report.py,sha256=_7QnmDgUScRtUWSuaKYoTOVk2TTGUCkRWxAo1haxnek,1051
@@ -64,6 +65,6 @@ tesorotools/utils/matplotlib.py,sha256=lItlkJwJEDGALt0J1UZioTz2GOOFPPe7ffQ4Hzxl7
64
65
  tesorotools/utils/series.py,sha256=AEf5DhneYjzQ0nvZD5b1IU3hop0Xgb3Bw2xWs_G3Lhw,1207
65
66
  tesorotools/utils/shortcuts.py,sha256=9fpTLgfDFm6_1kh2W0Zk2atn3ZIZMvPfuOjlPpjVy4c,1718
66
67
  tesorotools/utils/template.py,sha256=owYVB_dDlkIcKGmT6muH_4UjKDD7JYn_I1pxjQ9VIB8,5211
67
- tesorotools_python-0.0.44.dist-info/METADATA,sha256=hsbxz4y6lPP0IplST5yGfmOCjIltXx1rFMh0XFTk96w,610
68
- tesorotools_python-0.0.44.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
69
- tesorotools_python-0.0.44.dist-info/RECORD,,
68
+ tesorotools_python-0.0.45.dist-info/METADATA,sha256=fnpAvg45bw2GqNyvh3-V05ssOs4pYYfvMF3dzsyj36k,724
69
+ tesorotools_python-0.0.45.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
70
+ tesorotools_python-0.0.45.dist-info/RECORD,,