ecbfx 0.2.0__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.
- ecbfx/__init__.py +31 -0
- ecbfx/api.py +473 -0
- ecbfx/cli.py +490 -0
- ecbfx-0.2.0.dist-info/METADATA +226 -0
- ecbfx-0.2.0.dist-info/RECORD +8 -0
- ecbfx-0.2.0.dist-info/WHEEL +4 -0
- ecbfx-0.2.0.dist-info/entry_points.txt +2 -0
- ecbfx-0.2.0.dist-info/licenses/LICENSE +21 -0
ecbfx/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ecbfx — ECB foreign exchange rate lookup tool.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .api import (
|
|
6
|
+
ECBError,
|
|
7
|
+
validate_currency,
|
|
8
|
+
fetch_rates,
|
|
9
|
+
fetch_rates_for_pairs,
|
|
10
|
+
fetch_latest,
|
|
11
|
+
fetch_ecb_raw,
|
|
12
|
+
parse_ecb_csv,
|
|
13
|
+
resolve_rate,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from importlib.metadata import version as _v
|
|
18
|
+
__version__ = _v("ecbfx")
|
|
19
|
+
except Exception:
|
|
20
|
+
__version__ = "unknown"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ECBError",
|
|
24
|
+
"validate_currency",
|
|
25
|
+
"fetch_rates",
|
|
26
|
+
"fetch_rates_for_pairs",
|
|
27
|
+
"fetch_latest",
|
|
28
|
+
"fetch_ecb_raw",
|
|
29
|
+
"parse_ecb_csv",
|
|
30
|
+
"resolve_rate",
|
|
31
|
+
]
|
ecbfx/api.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ecbfx.api
|
|
3
|
+
==========
|
|
4
|
+
Low-level ECB SDMX data fetcher and parser.
|
|
5
|
+
Public functions have no side effects beyond outbound HTTP requests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import logging
|
|
12
|
+
import random
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from datetime import date, timedelta
|
|
17
|
+
from io import StringIO
|
|
18
|
+
from typing import Dict, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ECBError",
|
|
26
|
+
"validate_currency",
|
|
27
|
+
"fetch_ecb_raw",
|
|
28
|
+
"parse_ecb_csv",
|
|
29
|
+
"resolve_rate",
|
|
30
|
+
"fetch_rates",
|
|
31
|
+
"fetch_rates_for_pairs",
|
|
32
|
+
"fetch_latest",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
MAX_GAP_DAYS = 5
|
|
36
|
+
RETRIES = 3
|
|
37
|
+
BASE_URL = "https://data-api.ecb.europa.eu/service/data"
|
|
38
|
+
# Log a warning when fetch_rates_for_pairs spans more than this many days —
|
|
39
|
+
# the full range is still fetched in one call, but users should be aware
|
|
40
|
+
# of the payload size for very sparse historical data.
|
|
41
|
+
SPARSE_WARNING_THRESHOLD = 365
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ECBError(RuntimeError):
|
|
45
|
+
"""Raised for any ECB API or data error."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Validation ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
_CCY_RE = re.compile(r"[A-Z]{2,4}")
|
|
51
|
+
|
|
52
|
+
def validate_currency(currency: str) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Normalise and validate a currency code.
|
|
55
|
+
Accepts 2–4 ASCII letters (covers all ISO 4217 codes).
|
|
56
|
+
Raises ECBError for anything that would corrupt the ECB URL.
|
|
57
|
+
|
|
58
|
+
Uses re.fullmatch (not re.match) so a trailing newline such as
|
|
59
|
+
"USD\\n" is correctly rejected — re.match with a $ anchor silently
|
|
60
|
+
accepts strings ending with a newline due to Python's $ semantics.
|
|
61
|
+
"""
|
|
62
|
+
# Strip only horizontal whitespace (spaces). Do NOT strip newlines —
|
|
63
|
+
# "USD\n" must be rejected, and .strip() would remove \n before fullmatch.
|
|
64
|
+
ccy = currency.strip(" ").upper()
|
|
65
|
+
if not _CCY_RE.fullmatch(ccy):
|
|
66
|
+
raise ECBError(
|
|
67
|
+
f"Invalid currency code {currency!r}. "
|
|
68
|
+
"Expected 2–4 ASCII letters, e.g. USD, GBP, CHF."
|
|
69
|
+
)
|
|
70
|
+
return ccy
|
|
71
|
+
|
|
72
|
+
# Keep the private alias so any external code that used _validate_currency
|
|
73
|
+
# still works — will be removed in 0.3.0.
|
|
74
|
+
_validate_currency = validate_currency
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── HTTP ───────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def fetch_ecb_raw(
|
|
80
|
+
currency: str,
|
|
81
|
+
start: date,
|
|
82
|
+
end: date,
|
|
83
|
+
timeout: int = 30,
|
|
84
|
+
session: Optional[requests.Session] = None,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Fetch raw CSV text from the ECB SDMX API.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
currency : ISO 4217 currency code
|
|
92
|
+
start : start of date range
|
|
93
|
+
end : end of date range
|
|
94
|
+
timeout : HTTP request timeout in seconds (default 30)
|
|
95
|
+
session : optional requests.Session for connection reuse.
|
|
96
|
+
If None, a one-off request is made.
|
|
97
|
+
"""
|
|
98
|
+
currency = validate_currency(currency)
|
|
99
|
+
url = (
|
|
100
|
+
f"{BASE_URL}/EXR/D.{currency}.EUR.SP00.A"
|
|
101
|
+
f"?startPeriod={start.isoformat()}&endPeriod={end.isoformat()}&format=csvdata"
|
|
102
|
+
)
|
|
103
|
+
get = (session.get if session is not None else requests.get)
|
|
104
|
+
last_err: Exception | None = None
|
|
105
|
+
|
|
106
|
+
for attempt in range(1, RETRIES + 1):
|
|
107
|
+
try:
|
|
108
|
+
logger.debug("Fetching %s attempt %d/%d: %s", currency, attempt, RETRIES, url)
|
|
109
|
+
resp = get(url, timeout=timeout, headers={"Accept": "text/csv"})
|
|
110
|
+
resp.raise_for_status()
|
|
111
|
+
# Guard against the ECB returning an HTML error page instead of CSV.
|
|
112
|
+
# Primary check: Content-Type header. Secondary check: response body
|
|
113
|
+
# starts with HTML tags — catches misconfigured servers that send
|
|
114
|
+
# text/html content with a non-HTML Content-Type header.
|
|
115
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
116
|
+
body_start = resp.text.lstrip()[:15].lower()
|
|
117
|
+
if "html" in content_type.lower() or body_start.startswith(("<html", "<!doctype")):
|
|
118
|
+
raise ECBError(
|
|
119
|
+
f"ECB returned an HTML response instead of CSV for {currency}. "
|
|
120
|
+
"The API may be temporarily unavailable."
|
|
121
|
+
)
|
|
122
|
+
logger.debug("Fetched %s: %d bytes", currency, len(resp.text))
|
|
123
|
+
return resp.text
|
|
124
|
+
except requests.exceptions.HTTPError as exc:
|
|
125
|
+
# Do not retry client errors (4xx) — they won't recover on retry.
|
|
126
|
+
if exc.response is not None and exc.response.status_code < 500:
|
|
127
|
+
raise ECBError(
|
|
128
|
+
f"ECB returned {exc.response.status_code} for {currency}. "
|
|
129
|
+
"Check the currency code is valid (e.g. USD, GBP, CHF)."
|
|
130
|
+
) from exc
|
|
131
|
+
last_err = exc
|
|
132
|
+
except requests.RequestException as exc:
|
|
133
|
+
# Network errors, timeouts, 5xx — all worth retrying
|
|
134
|
+
last_err = exc
|
|
135
|
+
|
|
136
|
+
if attempt < RETRIES:
|
|
137
|
+
# Exponential backoff with jitter — avoids thundering herd on retry storms
|
|
138
|
+
wait = min(2 ** attempt + random.uniform(0, 1), 30)
|
|
139
|
+
logger.warning(
|
|
140
|
+
"ECB fetch for %s failed (attempt %d/%d): %s — retrying in %.1fs",
|
|
141
|
+
currency, attempt, RETRIES, last_err.__class__.__name__, wait,
|
|
142
|
+
)
|
|
143
|
+
time.sleep(wait)
|
|
144
|
+
|
|
145
|
+
raise ECBError(
|
|
146
|
+
f"ECB fetch failed for {currency} after {RETRIES} attempts: {last_err}\n"
|
|
147
|
+
"Check your internet connection or verify the currency code."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── Parsing ────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def parse_ecb_csv(text: str, currency: str) -> Dict[date, float]:
|
|
154
|
+
"""
|
|
155
|
+
Parse ECB SDMX CSV response.
|
|
156
|
+
Returns {date: indirect_rate} where indirect_rate = foreign units per 1 EUR
|
|
157
|
+
(ECB native format — no inversion applied here).
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
reader = csv.DictReader(StringIO(text))
|
|
161
|
+
rows = list(reader)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
raise ECBError(f"Failed to parse ECB response for {currency}: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
# Normalise header names — ECB occasionally adds whitespace;
|
|
166
|
+
# guard against None values that csv.DictReader can produce for missing fields.
|
|
167
|
+
rows = [{k.strip(): (v.strip() if v is not None else "") for k, v in row.items()} for row in rows]
|
|
168
|
+
|
|
169
|
+
if not rows or "TIME_PERIOD" not in rows[0] or "OBS_VALUE" not in rows[0]:
|
|
170
|
+
raise ECBError(
|
|
171
|
+
f"Unexpected ECB response structure for {currency}. "
|
|
172
|
+
f"Got columns: {list(rows[0].keys()) if rows else '(empty)'}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
result: Dict[date, float] = {}
|
|
176
|
+
for row in rows:
|
|
177
|
+
try:
|
|
178
|
+
d = date.fromisoformat(row["TIME_PERIOD"])
|
|
179
|
+
rate = float(row["OBS_VALUE"])
|
|
180
|
+
if rate > 0:
|
|
181
|
+
result[d] = rate # keep full float precision; rounding done at output
|
|
182
|
+
except (ValueError, KeyError) as exc:
|
|
183
|
+
logger.warning("Skipped malformed ECB row (currency=%s): %s", currency, exc)
|
|
184
|
+
continue # skip malformed rows
|
|
185
|
+
|
|
186
|
+
if not result:
|
|
187
|
+
raise ECBError(
|
|
188
|
+
f"No valid rate data returned for {currency}. "
|
|
189
|
+
"The currency may not be available from ECB, or the date range "
|
|
190
|
+
"may fall entirely on non-trading days."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── Gap filling ────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def resolve_rate(daily: Dict[date, float], target: date,
|
|
199
|
+
currency: str, gap_fill: bool = True) -> float:
|
|
200
|
+
"""
|
|
201
|
+
Return the rate for target date.
|
|
202
|
+
|
|
203
|
+
gap_fill=True (default): falls back to the nearest available date within
|
|
204
|
+
MAX_GAP_DAYS — handles weekends and public holidays.
|
|
205
|
+
When two dates are equidistant (e.g. a mid-week
|
|
206
|
+
holiday), the earlier date is preferred for
|
|
207
|
+
consistency with standard financial convention.
|
|
208
|
+
gap_fill=False (strict): raises ECBError if target date has no exact match.
|
|
209
|
+
"""
|
|
210
|
+
if not daily:
|
|
211
|
+
raise ECBError(
|
|
212
|
+
f"No rate data available for {currency}. "
|
|
213
|
+
"The date range may fall entirely outside ECB published data."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if target in daily:
|
|
217
|
+
return daily[target]
|
|
218
|
+
|
|
219
|
+
if not gap_fill:
|
|
220
|
+
nearby = ", ".join(
|
|
221
|
+
str(d) for d in sorted(daily) if abs((d - target).days) <= 3
|
|
222
|
+
)
|
|
223
|
+
raise ECBError(
|
|
224
|
+
f"No ECB rate for {target} ({currency}) and --no-gap-fill is set. "
|
|
225
|
+
f"This date may be a weekend or public holiday. "
|
|
226
|
+
f"Available dates nearby: {nearby or 'none'}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Last Observation Carried Forward (LOCF) — standard financial convention.
|
|
230
|
+
# For any missing date (weekend, holiday), use the most recent prior
|
|
231
|
+
# trading day. Never use a future rate — doing so would introduce
|
|
232
|
+
# look-ahead bias (e.g. a Monday holiday should use Friday, not Tuesday).
|
|
233
|
+
prior = [d for d in daily if d < target]
|
|
234
|
+
if prior:
|
|
235
|
+
closest = max(prior) # most recent trading day before target
|
|
236
|
+
else:
|
|
237
|
+
# No prior data — target is before the earliest available date.
|
|
238
|
+
# Falling forward would introduce look-ahead bias, so raise explicitly.
|
|
239
|
+
raise ECBError(
|
|
240
|
+
f"No ECB rate available for {currency} on or before {target}. "
|
|
241
|
+
f"Earliest data available: {min(daily.keys())}. "
|
|
242
|
+
"Consider widening the date range."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
gap = abs((closest - target).days)
|
|
246
|
+
|
|
247
|
+
if gap > MAX_GAP_DAYS:
|
|
248
|
+
raise ECBError(
|
|
249
|
+
f"No ECB rate within {MAX_GAP_DAYS} days of {target} for {currency}. "
|
|
250
|
+
f"Closest available: {closest} ({gap} days away)."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
logger.debug("Gap-filled %s %s → %s (%d day(s))", currency, target, closest, gap)
|
|
254
|
+
return daily[closest]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── High-level ─────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
def fetch_rates(
|
|
260
|
+
currencies: List[str],
|
|
261
|
+
start: date,
|
|
262
|
+
end: date,
|
|
263
|
+
direct: bool = False,
|
|
264
|
+
decimals: int = 4,
|
|
265
|
+
gap_fill: bool = True,
|
|
266
|
+
session: Optional[requests.Session] = None,
|
|
267
|
+
) -> List[Dict]:
|
|
268
|
+
"""
|
|
269
|
+
Fetch rates for all currencies over [start, end].
|
|
270
|
+
|
|
271
|
+
Parameters
|
|
272
|
+
----------
|
|
273
|
+
currencies : list of ISO 4217 codes, e.g. ["USD", "GBP"]
|
|
274
|
+
start, end : inclusive date range
|
|
275
|
+
direct : if True, return EUR per 1 foreign unit (inverted);
|
|
276
|
+
if False (default), return foreign units per 1 EUR (ECB native)
|
|
277
|
+
decimals : decimal places to round the output rate to (default 4)
|
|
278
|
+
gap_fill : if False, raise ECBError on weekends/holidays instead of
|
|
279
|
+
substituting the nearest available rate
|
|
280
|
+
|
|
281
|
+
Returns
|
|
282
|
+
-------
|
|
283
|
+
List of dicts: {date, currency, rate, convention}
|
|
284
|
+
convention is "EUR/CCY" (direct) or "CCY/EUR" (indirect).
|
|
285
|
+
"""
|
|
286
|
+
results = []
|
|
287
|
+
|
|
288
|
+
for currency in currencies:
|
|
289
|
+
currency = validate_currency(currency)
|
|
290
|
+
|
|
291
|
+
if currency == "EUR":
|
|
292
|
+
d = start
|
|
293
|
+
while d <= end:
|
|
294
|
+
results.append({
|
|
295
|
+
"date": str(d),
|
|
296
|
+
"currency": "EUR",
|
|
297
|
+
"rate": round(1.0, decimals),
|
|
298
|
+
"convention": "EUR/EUR",
|
|
299
|
+
})
|
|
300
|
+
d += timedelta(days=1)
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
# Fetch slightly before `start` so that LOCF always has a prior
|
|
304
|
+
# trading day available for the first date in the requested range.
|
|
305
|
+
# Without this, a range starting on a public holiday (e.g. Jan 1)
|
|
306
|
+
# would raise because there is no prior data within the window.
|
|
307
|
+
# The extra days are used only for gap-filling — output still covers
|
|
308
|
+
# exactly start → end.
|
|
309
|
+
ecb_start = start - timedelta(days=MAX_GAP_DAYS)
|
|
310
|
+
raw_text = fetch_ecb_raw(currency, ecb_start, end, session=session)
|
|
311
|
+
daily = parse_ecb_csv(raw_text, currency)
|
|
312
|
+
col_label = f"EUR/{currency}" if direct else f"{currency}/EUR"
|
|
313
|
+
|
|
314
|
+
# Iterate every calendar day in the requested range and resolve each
|
|
315
|
+
# date individually — this fills weekends and public holidays with the
|
|
316
|
+
# nearest available trading-day rate (via resolve_rate), unless
|
|
317
|
+
# gap_fill=False in which case an error is raised for missing dates.
|
|
318
|
+
d = start
|
|
319
|
+
while d <= end:
|
|
320
|
+
indirect_rate = resolve_rate(daily, d, currency, gap_fill=gap_fill)
|
|
321
|
+
rate = round(1 / indirect_rate, decimals) if direct \
|
|
322
|
+
else round(indirect_rate, decimals)
|
|
323
|
+
results.append({
|
|
324
|
+
"date": str(d),
|
|
325
|
+
"currency": currency,
|
|
326
|
+
"rate": rate,
|
|
327
|
+
"convention": col_label,
|
|
328
|
+
})
|
|
329
|
+
d += timedelta(days=1)
|
|
330
|
+
|
|
331
|
+
# Sort by date then currency for consistent, predictable output
|
|
332
|
+
return sorted(results, key=lambda r: (r["date"], r["currency"]))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def fetch_latest(
|
|
336
|
+
currencies: List[str],
|
|
337
|
+
direct: bool = False,
|
|
338
|
+
decimals: int = 4,
|
|
339
|
+
lookback_days: int = 14,
|
|
340
|
+
session: Optional[requests.Session] = None,
|
|
341
|
+
_today: Optional[date] = None,
|
|
342
|
+
) -> List[Dict]:
|
|
343
|
+
"""
|
|
344
|
+
Return the most recent available ECB rate for each currency.
|
|
345
|
+
|
|
346
|
+
Fetches the past `lookback_days` calendar days and returns only the row
|
|
347
|
+
with the latest date. Default of 14 days safely covers extended holiday
|
|
348
|
+
periods (e.g. Christmas/New Year break) that can run up to 10 calendar
|
|
349
|
+
days with no trading days.
|
|
350
|
+
|
|
351
|
+
_today is an optional override for the current date, used in tests to
|
|
352
|
+
avoid depending on the real system clock.
|
|
353
|
+
"""
|
|
354
|
+
today = _today if _today is not None else date.today()
|
|
355
|
+
start = today - timedelta(days=lookback_days)
|
|
356
|
+
rows = fetch_rates(currencies, start, today,
|
|
357
|
+
direct=direct, decimals=decimals, gap_fill=True,
|
|
358
|
+
session=session)
|
|
359
|
+
|
|
360
|
+
# For each currency, keep only the row with the maximum date
|
|
361
|
+
latest: Dict[str, Dict] = {}
|
|
362
|
+
for row in rows:
|
|
363
|
+
ccy = row["currency"]
|
|
364
|
+
if ccy not in latest or row["date"] > latest[ccy]["date"]:
|
|
365
|
+
latest[ccy] = row
|
|
366
|
+
|
|
367
|
+
# Return in the same order as the input currencies list.
|
|
368
|
+
# Raise if any requested currency wasn't found — silent omission would
|
|
369
|
+
# give callers incomplete data without any indication something went wrong.
|
|
370
|
+
results = []
|
|
371
|
+
for c in currencies:
|
|
372
|
+
key = c.upper()
|
|
373
|
+
if key not in latest:
|
|
374
|
+
raise ECBError(
|
|
375
|
+
f"No rate data returned for {c!r} within the last {lookback_days} days. "
|
|
376
|
+
"The currency may not be supported by ECB, or all days in the "
|
|
377
|
+
"lookback window may be non-trading days."
|
|
378
|
+
)
|
|
379
|
+
results.append(latest[key])
|
|
380
|
+
return results
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def fetch_rates_for_pairs(
|
|
384
|
+
pairs: List[Tuple[date, str]],
|
|
385
|
+
direct: bool = False,
|
|
386
|
+
decimals: int = 4,
|
|
387
|
+
gap_fill: bool = True,
|
|
388
|
+
session: Optional[requests.Session] = None,
|
|
389
|
+
) -> List[Dict]:
|
|
390
|
+
"""
|
|
391
|
+
Fetch rates for an arbitrary list of (date, currency) pairs.
|
|
392
|
+
|
|
393
|
+
Unlike fetch_rates() which requires a contiguous date range, this function
|
|
394
|
+
accepts sparse dates — ideal for transaction-level data where trades are
|
|
395
|
+
scattered across a calendar (e.g. a broker activity statement).
|
|
396
|
+
|
|
397
|
+
Internally batches by currency: one HTTP call per unique currency regardless
|
|
398
|
+
of how many pairs share that currency. Duplicate (date, currency) pairs are
|
|
399
|
+
deduplicated before fetching and restored in the output.
|
|
400
|
+
|
|
401
|
+
Parameters
|
|
402
|
+
----------
|
|
403
|
+
pairs : list of (date, currency) tuples,
|
|
404
|
+
e.g. [(date(2025,1,15), "USD"), (date(2025,1,20), "GBP")]
|
|
405
|
+
direct : if True, return EUR per 1 foreign unit (inverted);
|
|
406
|
+
if False (default), return foreign units per 1 EUR (ECB native)
|
|
407
|
+
decimals : decimal places to round the output rate to (default 4)
|
|
408
|
+
gap_fill : if False, raise ECBError on weekends/holidays instead of
|
|
409
|
+
substituting the nearest available rate
|
|
410
|
+
|
|
411
|
+
Returns
|
|
412
|
+
-------
|
|
413
|
+
List of dicts: {date, currency, rate, convention}
|
|
414
|
+
Ordered to match the input pairs list.
|
|
415
|
+
"""
|
|
416
|
+
if not pairs:
|
|
417
|
+
return []
|
|
418
|
+
|
|
419
|
+
# ── Group unique dates by currency ────────────────────────────────────────
|
|
420
|
+
by_currency: Dict[str, List[date]] = defaultdict(list)
|
|
421
|
+
for d, ccy in pairs:
|
|
422
|
+
by_currency[validate_currency(ccy)].append(d)
|
|
423
|
+
|
|
424
|
+
# Deduplicate dates per currency (preserve all for output reconstruction)
|
|
425
|
+
unique_by_currency: Dict[str, List[date]] = {
|
|
426
|
+
ccy: sorted(set(dates))
|
|
427
|
+
for ccy, dates in by_currency.items()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
# ── One HTTP call per currency, covering its full date span ───────────────
|
|
431
|
+
# rate_cache[(currency, date_str)] = rate
|
|
432
|
+
rate_cache: Dict[Tuple[str, str], float] = {}
|
|
433
|
+
|
|
434
|
+
for currency, dates in unique_by_currency.items():
|
|
435
|
+
col_label = f"EUR/{currency}" if direct else f"{currency}/EUR"
|
|
436
|
+
|
|
437
|
+
if currency == "EUR":
|
|
438
|
+
for d in dates:
|
|
439
|
+
rate_cache[("EUR", str(d))] = round(1.0, decimals)
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
span = (max(dates) - min(dates)).days
|
|
443
|
+
if span > SPARSE_WARNING_THRESHOLD:
|
|
444
|
+
logger.warning(
|
|
445
|
+
"%s: date span is %d days (%s to %s, %d unique dates). "
|
|
446
|
+
"Fetching full range in one request.",
|
|
447
|
+
currency, span, min(dates), max(dates), len(dates),
|
|
448
|
+
)
|
|
449
|
+
# Extend the fetch window backward by MAX_GAP_DAYS so LOCF has a
|
|
450
|
+
# prior trading day available even if the earliest requested date
|
|
451
|
+
# falls on a public holiday.
|
|
452
|
+
ecb_start = min(dates) - timedelta(days=MAX_GAP_DAYS)
|
|
453
|
+
raw_text = fetch_ecb_raw(currency, ecb_start, max(dates), session=session)
|
|
454
|
+
daily = parse_ecb_csv(raw_text, currency)
|
|
455
|
+
|
|
456
|
+
for d in dates:
|
|
457
|
+
indirect = resolve_rate(daily, d, currency, gap_fill=gap_fill)
|
|
458
|
+
rate = round(1 / indirect if direct else indirect, decimals)
|
|
459
|
+
rate_cache[(currency, str(d))] = rate
|
|
460
|
+
|
|
461
|
+
# ── Reconstruct output in input order ─────────────────────────────────────
|
|
462
|
+
results = []
|
|
463
|
+
for d, ccy in pairs:
|
|
464
|
+
ccy = validate_currency(ccy)
|
|
465
|
+
col_label = f"EUR/{ccy}" if direct else f"{ccy}/EUR"
|
|
466
|
+
results.append({
|
|
467
|
+
"date": str(d),
|
|
468
|
+
"currency": ccy,
|
|
469
|
+
"rate": rate_cache[(ccy, str(d))],
|
|
470
|
+
"convention": col_label,
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
return results
|
ecbfx/cli.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ecbfx.cli
|
|
3
|
+
==========
|
|
4
|
+
Command-line interface for ECB FX rate lookup.
|
|
5
|
+
|
|
6
|
+
Usage examples
|
|
7
|
+
--------------
|
|
8
|
+
ecbfx USD
|
|
9
|
+
ecbfx USD 2025-01-15
|
|
10
|
+
ecbfx USD GBP CHF 2025-01-15
|
|
11
|
+
ecbfx USD GBP --from 2025-01-01 --to 2025-03-31
|
|
12
|
+
ecbfx USD --from 2025-01-01 --to 2025-01-31 --direct
|
|
13
|
+
ecbfx USD --from 2025-01-01 --to 2025-01-31 --csv > rates.csv
|
|
14
|
+
ecbfx USD --latest
|
|
15
|
+
ecbfx USD --latest --quiet
|
|
16
|
+
ecbfx USD --decimal 6
|
|
17
|
+
ecbfx USD 2025-01-13 --no-gap-fill
|
|
18
|
+
ecbfx --pairs transactions.csv --direct --csv
|
|
19
|
+
ecbfx --pairs - --direct --csv < transactions.csv
|
|
20
|
+
cat transactions.csv | ecbfx --pairs - --quiet
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import contextlib
|
|
27
|
+
import csv
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import date
|
|
30
|
+
from typing import List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
def _get_version() -> str:
|
|
33
|
+
try:
|
|
34
|
+
from importlib.metadata import version
|
|
35
|
+
return version("ecbfx")
|
|
36
|
+
except Exception:
|
|
37
|
+
return "unknown"
|
|
38
|
+
|
|
39
|
+
import requests
|
|
40
|
+
from .api import ECBError, fetch_rates, fetch_rates_for_pairs, fetch_latest, validate_currency
|
|
41
|
+
|
|
42
|
+
# ── Formatting ─────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
# ANSI colours (suppressed when stdout is not a tty)
|
|
45
|
+
def _ansi(code: str, text: str) -> str:
|
|
46
|
+
if not sys.stdout.isatty():
|
|
47
|
+
return text
|
|
48
|
+
return f"\033[{code}m{text}\033[0m"
|
|
49
|
+
|
|
50
|
+
DIM = lambda t: _ansi("2", t)
|
|
51
|
+
BOLD = lambda t: _ansi("1", t)
|
|
52
|
+
CYAN = lambda t: _ansi("36", t)
|
|
53
|
+
GREEN = lambda t: _ansi("32", t)
|
|
54
|
+
RED = lambda t: _ansi("31", t)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _print_table(rows: List[dict]) -> None:
|
|
58
|
+
"""Pretty-print rows as an aligned table to stdout."""
|
|
59
|
+
if not rows:
|
|
60
|
+
print(DIM(" (no data)"))
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
w_date = max(len(r["date"]) for r in rows)
|
|
64
|
+
w_ccy = max(len(r["currency"]) for r in rows)
|
|
65
|
+
w_conv = max(len(r["convention"]) for r in rows)
|
|
66
|
+
w_rate = max(len(str(r["rate"])) for r in rows)
|
|
67
|
+
|
|
68
|
+
header = (
|
|
69
|
+
f" {BOLD('Date'): <{w_date + 10}}"
|
|
70
|
+
f"{BOLD('CCY'): <{w_ccy + 10}}"
|
|
71
|
+
f"{BOLD('Convention'): <{w_conv + 10}}"
|
|
72
|
+
f"{BOLD('Rate'):>{w_rate}}"
|
|
73
|
+
)
|
|
74
|
+
sep = " " + DIM("─" * (w_date + w_ccy + w_conv + w_rate + 6))
|
|
75
|
+
print(header)
|
|
76
|
+
print(sep)
|
|
77
|
+
|
|
78
|
+
for r in rows:
|
|
79
|
+
print(
|
|
80
|
+
f" {CYAN(r['date']): <{w_date + 10}}"
|
|
81
|
+
f"{r['currency']: <{w_ccy + 10}}"
|
|
82
|
+
f"{DIM(r['convention']): <{w_conv + 10}}"
|
|
83
|
+
f"{GREEN(str(r['rate'])):>{w_rate + 9}}"
|
|
84
|
+
)
|
|
85
|
+
print()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _print_csv(rows: List[dict]) -> None:
|
|
89
|
+
"""Write rows as CSV to stdout."""
|
|
90
|
+
if not rows:
|
|
91
|
+
return
|
|
92
|
+
writer = csv.DictWriter(
|
|
93
|
+
sys.stdout,
|
|
94
|
+
fieldnames=["date", "currency", "convention", "rate"],
|
|
95
|
+
lineterminator="\n")
|
|
96
|
+
writer.writeheader()
|
|
97
|
+
writer.writerows(rows)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _print_quiet(rows: List[dict]) -> None:
|
|
101
|
+
"""Print only the rate value(s), one per line. Ideal for scripting."""
|
|
102
|
+
for r in rows:
|
|
103
|
+
print(r["rate"])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _output(rows: List[dict], args: argparse.Namespace) -> None:
|
|
107
|
+
"""Route to the correct output formatter."""
|
|
108
|
+
if args.quiet:
|
|
109
|
+
_print_quiet(rows)
|
|
110
|
+
elif args.csv:
|
|
111
|
+
_print_csv(rows)
|
|
112
|
+
else:
|
|
113
|
+
_print_table(rows)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Pairs input ────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def _read_pairs(
|
|
119
|
+
source: str,
|
|
120
|
+
date_from: Optional[date] = None,
|
|
121
|
+
date_to: Optional[date] = None,
|
|
122
|
+
) -> List[Tuple[date, str]]:
|
|
123
|
+
"""
|
|
124
|
+
Read (date, currency) pairs from a file or stdin.
|
|
125
|
+
|
|
126
|
+
source : path to a CSV/TSV file, or "-" to read from stdin
|
|
127
|
+
date_from: optional lower bound filter (inclusive)
|
|
128
|
+
date_to : optional upper bound filter (inclusive)
|
|
129
|
+
|
|
130
|
+
Input format — one pair per line, date first, then currency code.
|
|
131
|
+
The separator can be a comma, tab, or single space.
|
|
132
|
+
A header row is detected and skipped automatically.
|
|
133
|
+
Lines starting with '#' are treated as comments and skipped.
|
|
134
|
+
|
|
135
|
+
Valid input examples
|
|
136
|
+
--------------------
|
|
137
|
+
2025-01-15,USD
|
|
138
|
+
2025-01-20,GBP
|
|
139
|
+
2025-02-03,USD
|
|
140
|
+
|
|
141
|
+
Or with a header:
|
|
142
|
+
date,currency
|
|
143
|
+
2025-01-15,USD
|
|
144
|
+
...
|
|
145
|
+
"""
|
|
146
|
+
pairs: List[Tuple[date, str]] = []
|
|
147
|
+
seen_header = False
|
|
148
|
+
|
|
149
|
+
ctx = contextlib.ExitStack()
|
|
150
|
+
if source == "-":
|
|
151
|
+
fh = sys.stdin
|
|
152
|
+
else:
|
|
153
|
+
fh = ctx.enter_context(open(source, encoding="utf-8-sig"))
|
|
154
|
+
|
|
155
|
+
with ctx:
|
|
156
|
+
for lineno, raw in enumerate(fh, 1):
|
|
157
|
+
line = raw.strip()
|
|
158
|
+
if not line or line.startswith("#"):
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
# Detect separator: comma, tab, or space
|
|
162
|
+
if "\t" in line:
|
|
163
|
+
parts = line.split("\t", 1)
|
|
164
|
+
elif "," in line:
|
|
165
|
+
parts = line.split(",", 1)
|
|
166
|
+
else:
|
|
167
|
+
parts = line.split(None, 1)
|
|
168
|
+
|
|
169
|
+
if len(parts) < 2:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"Line {lineno}: expected 'date,currency', got: {line!r}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
raw_date, raw_ccy = parts[0].strip(), parts[1].strip()
|
|
175
|
+
|
|
176
|
+
# Skip a header row only if the first field is purely alphabetic
|
|
177
|
+
# (e.g. "date", "Date", "DATE"). This correctly accepts:
|
|
178
|
+
# "date,currency" → skip as header ✓
|
|
179
|
+
# And correctly rejects as malformed (not silently skipped):
|
|
180
|
+
# "not-a-date,USD" → '-' makes isalpha() False → error ✓
|
|
181
|
+
# "2025-02-29,USD" → digits make isalpha() False → error ✓
|
|
182
|
+
if not seen_header:
|
|
183
|
+
seen_header = True
|
|
184
|
+
if raw_date.isalpha():
|
|
185
|
+
continue # purely alphabetic label → header row, skip
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
d = date.fromisoformat(raw_date)
|
|
189
|
+
except ValueError:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"Line {lineno}: {raw_date!r} is not a valid YYYY-MM-DD date."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
raw_ccy = validate_currency(raw_ccy)
|
|
196
|
+
except ECBError as exc:
|
|
197
|
+
raise ValueError(f"Line {lineno}: {exc}") from exc
|
|
198
|
+
|
|
199
|
+
# Apply optional date filters
|
|
200
|
+
if date_from and d < date_from:
|
|
201
|
+
continue
|
|
202
|
+
if date_to and d > date_to:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
pairs.append((d, raw_ccy))
|
|
206
|
+
|
|
207
|
+
if not pairs:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
"No valid pairs found in input"
|
|
210
|
+
+ (f" after applying date filters ({date_from} → {date_to})"
|
|
211
|
+
if date_from or date_to else "") + "."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return pairs
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── Argument parsing ───────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
220
|
+
parser = argparse.ArgumentParser(
|
|
221
|
+
prog="ecbfx",
|
|
222
|
+
description=(
|
|
223
|
+
"Fetch EUR foreign exchange rates from the ECB SDMX API.\n\n"
|
|
224
|
+
"CONVENTION\n"
|
|
225
|
+
" Indirect (default) : foreign units per 1 EUR e.g. 1 EUR = 1.0830 USD\n"
|
|
226
|
+
" Direct (--direct) : EUR per 1 foreign unit e.g. 1 USD = 0.9234 EUR\n"
|
|
227
|
+
),
|
|
228
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
229
|
+
epilog=(
|
|
230
|
+
"examples:\n"
|
|
231
|
+
" ecbfx USD\n"
|
|
232
|
+
" ecbfx USD 2025-01-15\n"
|
|
233
|
+
" ecbfx USD GBP CHF 2025-01-15\n"
|
|
234
|
+
" ecbfx USD GBP --from 2025-01-01 --to 2025-03-31\n"
|
|
235
|
+
" ecbfx USD --from 2025-01-01 --to 2025-01-31 --direct\n"
|
|
236
|
+
" ecbfx USD --from 2025-01-01 --to 2025-01-31 --csv > rates.csv\n"
|
|
237
|
+
" ecbfx USD --latest\n"
|
|
238
|
+
" ecbfx USD --latest --quiet\n"
|
|
239
|
+
" ecbfx USD --decimal 6\n"
|
|
240
|
+
" ecbfx USD 2025-01-13 --no-gap-fill\n"
|
|
241
|
+
" ecbfx --pairs transactions.csv --direct --csv\n"
|
|
242
|
+
" ecbfx --pairs - --direct --csv < transactions.csv\n"
|
|
243
|
+
" cat transactions.csv | ecbfx --pairs - --quiet\n"
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# ── Mode selection (mutually exclusive) ──────────────────────────────────
|
|
248
|
+
# --pairs and positional CCY codes are mutually exclusive.
|
|
249
|
+
# --latest is a separate flag that works alongside CCY positional args.
|
|
250
|
+
mode = parser.add_mutually_exclusive_group()
|
|
251
|
+
mode.add_argument(
|
|
252
|
+
"currencies",
|
|
253
|
+
nargs="*",
|
|
254
|
+
metavar="CCY",
|
|
255
|
+
help="ISO 4217 currency code(s), e.g. USD GBP CHF",
|
|
256
|
+
)
|
|
257
|
+
mode.add_argument(
|
|
258
|
+
"--pairs", dest="pairs_source", default=None, metavar="FILE|-",
|
|
259
|
+
help=(
|
|
260
|
+
"Read (date, currency) pairs from FILE or stdin (-). "
|
|
261
|
+
"One pair per line: '2025-01-15,USD'. "
|
|
262
|
+
"Comma, tab, or space separators are all accepted. "
|
|
263
|
+
"An optional header row (first line starting with a non-date) "
|
|
264
|
+
"and comment lines starting with '#' are skipped automatically."
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
parser.add_argument(
|
|
269
|
+
"--latest", action="store_true", default=False,
|
|
270
|
+
help="Return the most recent available rate, regardless of today's date.",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
parser.add_argument(
|
|
274
|
+
"date",
|
|
275
|
+
nargs="?",
|
|
276
|
+
metavar="DATE",
|
|
277
|
+
default=None,
|
|
278
|
+
help="Single date YYYY-MM-DD (shorthand for --from DATE --to DATE)",
|
|
279
|
+
)
|
|
280
|
+
parser.add_argument(
|
|
281
|
+
"--from", dest="date_from", default=None, metavar="YYYY-MM-DD",
|
|
282
|
+
help="Start of date range (inclusive). Also filters --pairs input.",
|
|
283
|
+
)
|
|
284
|
+
parser.add_argument(
|
|
285
|
+
"--to", dest="date_to", default=None, metavar="YYYY-MM-DD",
|
|
286
|
+
help=(
|
|
287
|
+
"End of date range (inclusive). Also filters --pairs input. "
|
|
288
|
+
"Requires --from when used in range mode."
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
parser.add_argument(
|
|
292
|
+
"--direct", action="store_true", default=False,
|
|
293
|
+
help="Return EUR per 1 foreign unit (inverted). Default: foreign units per 1 EUR.",
|
|
294
|
+
)
|
|
295
|
+
parser.add_argument(
|
|
296
|
+
"--no-gap-fill", dest="no_gap_fill", action="store_true", default=False,
|
|
297
|
+
help=(
|
|
298
|
+
"Strict mode: raise an error if a requested date has no ECB rate "
|
|
299
|
+
"(e.g. weekend or public holiday) instead of substituting the "
|
|
300
|
+
"nearest available rate."
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
parser.add_argument(
|
|
304
|
+
"--decimal", dest="decimals", type=int, default=4, metavar="N",
|
|
305
|
+
help="Number of decimal places in the output rate (default: 4).",
|
|
306
|
+
)
|
|
307
|
+
parser.add_argument(
|
|
308
|
+
"--quiet", "-q", action="store_true", default=False,
|
|
309
|
+
help=(
|
|
310
|
+
"Print only the rate value(s), one per line. "
|
|
311
|
+
"Useful for shell scripting: $(ecbfx USD --quiet)"
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
parser.add_argument(
|
|
315
|
+
"--csv", action="store_true", default=False,
|
|
316
|
+
help="Output as CSV instead of a formatted table.",
|
|
317
|
+
)
|
|
318
|
+
parser.add_argument(
|
|
319
|
+
"--version", action="version", version=f"%(prog)s {_get_version()}",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return parser
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _resolve_dates(args: argparse.Namespace):
|
|
326
|
+
"""
|
|
327
|
+
Resolve the final (start, end) date pair for range mode.
|
|
328
|
+
|
|
329
|
+
Precedence:
|
|
330
|
+
1. --latest flag → handled separately in main()
|
|
331
|
+
2. --pairs flag → handled separately in main()
|
|
332
|
+
3. Positional DATE argument → single date
|
|
333
|
+
4. --from / --to flags → explicit range
|
|
334
|
+
5. No date arguments → today (single date)
|
|
335
|
+
"""
|
|
336
|
+
today = date.today()
|
|
337
|
+
|
|
338
|
+
# Detect if the last "currency" token is actually a date
|
|
339
|
+
# (handles: ecbfx USD GBP 2025-01-15)
|
|
340
|
+
currencies = list(args.currencies)
|
|
341
|
+
positional_date = None
|
|
342
|
+
|
|
343
|
+
if args.date:
|
|
344
|
+
positional_date = args.date
|
|
345
|
+
elif currencies:
|
|
346
|
+
last = currencies[-1]
|
|
347
|
+
try:
|
|
348
|
+
date.fromisoformat(last)
|
|
349
|
+
positional_date = last
|
|
350
|
+
currencies = currencies[:-1]
|
|
351
|
+
except ValueError:
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
if not currencies:
|
|
355
|
+
raise ValueError("At least one currency code is required.")
|
|
356
|
+
|
|
357
|
+
if positional_date:
|
|
358
|
+
if args.date_from or args.date_to:
|
|
359
|
+
raise ValueError(
|
|
360
|
+
"Positional DATE and --from/--to cannot be used together."
|
|
361
|
+
)
|
|
362
|
+
d = date.fromisoformat(positional_date)
|
|
363
|
+
return currencies, d, d
|
|
364
|
+
|
|
365
|
+
start = date.fromisoformat(args.date_from) if args.date_from else today
|
|
366
|
+
end = date.fromisoformat(args.date_to) if args.date_to else (
|
|
367
|
+
start if args.date_from else today
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if args.date_to and not args.date_from:
|
|
371
|
+
raise ValueError(
|
|
372
|
+
"--to requires --from. "
|
|
373
|
+
"Please provide a start date, e.g. --from 2025-01-01 --to 2025-03-31"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if end < start:
|
|
377
|
+
raise ValueError("--to date must not be earlier than --from date.")
|
|
378
|
+
|
|
379
|
+
return currencies, start, end
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ── Entry point ────────────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
def main(argv: List[str] | None = None) -> int:
|
|
385
|
+
parser = _build_parser()
|
|
386
|
+
try:
|
|
387
|
+
args = parser.parse_args(argv)
|
|
388
|
+
except SystemExit as exc:
|
|
389
|
+
# argparse calls sys.exit() on usage errors; convert to return code
|
|
390
|
+
# so callers (and tests) always get an integer back.
|
|
391
|
+
return int(exc.code) if exc.code is not None else 0
|
|
392
|
+
|
|
393
|
+
# ── Validate decimals ──────────────────────────────────────────────────────
|
|
394
|
+
if args.decimals < 0 or args.decimals > 10:
|
|
395
|
+
print(RED("Error: --decimal must be between 0 and 10."), file=sys.stderr)
|
|
396
|
+
return 2
|
|
397
|
+
|
|
398
|
+
# Shared session reuses TCP connections across all fetch calls
|
|
399
|
+
session = requests.Session()
|
|
400
|
+
|
|
401
|
+
# ── --pairs mode ───────────────────────────────────────────────────────────
|
|
402
|
+
if args.pairs_source is not None:
|
|
403
|
+
if args.latest:
|
|
404
|
+
print(RED("Error: --pairs cannot be combined with --latest."),
|
|
405
|
+
file=sys.stderr)
|
|
406
|
+
return 2
|
|
407
|
+
date_from = date.fromisoformat(args.date_from) if args.date_from else None
|
|
408
|
+
date_to = date.fromisoformat(args.date_to) if args.date_to else None
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
pairs = _read_pairs(args.pairs_source, date_from, date_to)
|
|
412
|
+
except (ValueError, OSError) as exc:
|
|
413
|
+
print(RED(f"Error reading pairs: {exc}"), file=sys.stderr)
|
|
414
|
+
return 2
|
|
415
|
+
|
|
416
|
+
if not args.quiet and not args.csv and sys.stdout.isatty():
|
|
417
|
+
conv = "direct (EUR/CCY)" if args.direct else "indirect (CCY/EUR)"
|
|
418
|
+
strict = " · strict (no gap fill)" if args.no_gap_fill else ""
|
|
419
|
+
print(DIM(f"\n ECB rates {len(pairs)} pairs · {conv}{strict}\n"))
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
rows = fetch_rates_for_pairs(
|
|
423
|
+
pairs,
|
|
424
|
+
direct=args.direct,
|
|
425
|
+
decimals=args.decimals,
|
|
426
|
+
gap_fill=not args.no_gap_fill,
|
|
427
|
+
session=session,
|
|
428
|
+
)
|
|
429
|
+
except ECBError as exc:
|
|
430
|
+
print(RED(f"Error: {exc}"), file=sys.stderr)
|
|
431
|
+
return 1
|
|
432
|
+
|
|
433
|
+
_output(rows, args)
|
|
434
|
+
return 0
|
|
435
|
+
|
|
436
|
+
# ── --latest mode ──────────────────────────────────────────────────────────
|
|
437
|
+
if args.latest:
|
|
438
|
+
currencies = list(args.currencies) if args.currencies else []
|
|
439
|
+
if not currencies:
|
|
440
|
+
print(RED("Error: at least one currency code is required with --latest."),
|
|
441
|
+
file=sys.stderr)
|
|
442
|
+
return 2
|
|
443
|
+
if args.date_from or args.date_to or args.date:
|
|
444
|
+
print(RED("Error: --latest cannot be combined with date arguments."),
|
|
445
|
+
file=sys.stderr)
|
|
446
|
+
return 2
|
|
447
|
+
if args.no_gap_fill:
|
|
448
|
+
print(RED("Error: --latest and --no-gap-fill cannot be combined."),
|
|
449
|
+
file=sys.stderr)
|
|
450
|
+
return 2
|
|
451
|
+
try:
|
|
452
|
+
rows = fetch_latest(currencies, direct=args.direct,
|
|
453
|
+
decimals=args.decimals, session=session)
|
|
454
|
+
except ECBError as exc:
|
|
455
|
+
print(RED(f"Error: {exc}"), file=sys.stderr)
|
|
456
|
+
return 1
|
|
457
|
+
_output(rows, args)
|
|
458
|
+
return 0
|
|
459
|
+
|
|
460
|
+
# ── Normal date-range mode ─────────────────────────────────────────────────
|
|
461
|
+
try:
|
|
462
|
+
currencies, start, end = _resolve_dates(args)
|
|
463
|
+
except ValueError as exc:
|
|
464
|
+
print(RED(f"Error: {exc}"), file=sys.stderr)
|
|
465
|
+
return 2
|
|
466
|
+
|
|
467
|
+
if not args.quiet and not args.csv and sys.stdout.isatty():
|
|
468
|
+
label = str(start) if start == end else f"{start} → {end}"
|
|
469
|
+
conv = "direct (EUR/CCY)" if args.direct else "indirect (CCY/EUR)"
|
|
470
|
+
strict = " · strict (no gap fill)" if args.no_gap_fill else ""
|
|
471
|
+
print(DIM(f"\n ECB rates {label} · {conv}{strict}\n"))
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
rows = fetch_rates(
|
|
475
|
+
currencies, start, end,
|
|
476
|
+
direct=args.direct,
|
|
477
|
+
decimals=args.decimals,
|
|
478
|
+
gap_fill=not args.no_gap_fill,
|
|
479
|
+
session=session,
|
|
480
|
+
)
|
|
481
|
+
except ECBError as exc:
|
|
482
|
+
print(RED(f"Error: {exc}"), file=sys.stderr)
|
|
483
|
+
return 1
|
|
484
|
+
|
|
485
|
+
_output(rows, args)
|
|
486
|
+
return 0
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
if __name__ == "__main__":
|
|
490
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ecbfx
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: ECB foreign exchange rate lookup — CLI and Python API with gap-fill, strict mode, and scripting support
|
|
5
|
+
Project-URL: Homepage, https://github.com/edvinassvedas-dev/ecbfx
|
|
6
|
+
Project-URL: Repository, https://github.com/edvinassvedas-dev/ecbfx
|
|
7
|
+
Project-URL: Issues, https://github.com/edvinassvedas-dev/ecbfx/issues
|
|
8
|
+
Author: Edvinas Švedas
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,currency,ecb,exchange-rate,finance,forex,fx
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: requests>=2.28
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
30
|
+
Requires-Dist: responses; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# ecbfx
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/ecbfx/)
|
|
36
|
+
[](https://pypi.org/project/ecbfx/)
|
|
37
|
+
[](https://github.com/edvinassvedas-dev/ecbfx/blob/main/LICENSE)
|
|
38
|
+
|
|
39
|
+
A minimal command-line tool and Python library for fetching EUR foreign exchange
|
|
40
|
+
rates directly from the [ECB SDMX API](https://data-api.ecb.europa.eu).
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install ecbfx
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**For development** (includes test dependencies):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone https://github.com/edvinassvedas-dev/ecbfx.git
|
|
54
|
+
cd ecbfx
|
|
55
|
+
pip install -e ".[dev]"
|
|
56
|
+
pytest
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## CLI usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Today's rate for USD (indirect: USD per 1 EUR — ECB native)
|
|
63
|
+
# Note: ECB publishes rates ~16:00 CET on trading days.
|
|
64
|
+
# If today's rate isn't available yet, use --latest instead.
|
|
65
|
+
ecbfx USD
|
|
66
|
+
|
|
67
|
+
# Specific date
|
|
68
|
+
ecbfx USD 2025-01-15
|
|
69
|
+
|
|
70
|
+
# Multiple currencies, specific date
|
|
71
|
+
ecbfx USD GBP CHF 2025-01-15
|
|
72
|
+
|
|
73
|
+
# Date range
|
|
74
|
+
ecbfx USD GBP --from 2025-01-01 --to 2025-03-31
|
|
75
|
+
|
|
76
|
+
# Direct convention: EUR per 1 foreign unit (inverted)
|
|
77
|
+
ecbfx USD 2025-01-15 --direct
|
|
78
|
+
|
|
79
|
+
# Most recent available rate (ignores today being a weekend/holiday)
|
|
80
|
+
ecbfx USD --latest
|
|
81
|
+
|
|
82
|
+
# Single value only — ideal for shell scripting
|
|
83
|
+
ecbfx USD --latest --quiet
|
|
84
|
+
RATE=$(ecbfx USD --quiet)
|
|
85
|
+
|
|
86
|
+
# Control decimal precision (default: 4)
|
|
87
|
+
ecbfx USD 2025-01-15 --decimal 6
|
|
88
|
+
|
|
89
|
+
# Strict mode — error instead of gap-filling on weekends/holidays
|
|
90
|
+
ecbfx USD 2025-01-15 --no-gap-fill
|
|
91
|
+
|
|
92
|
+
# CSV output (pipe-friendly)
|
|
93
|
+
ecbfx USD --from 2025-01-01 --to 2025-01-31 --csv
|
|
94
|
+
ecbfx USD --from 2025-01-01 --to 2025-01-31 --csv > rates.csv
|
|
95
|
+
|
|
96
|
+
# Read pairs from a file — one HTTP call per currency
|
|
97
|
+
ecbfx --pairs transactions.csv --direct --csv
|
|
98
|
+
|
|
99
|
+
# Read pairs from stdin
|
|
100
|
+
cat transactions.csv | ecbfx --pairs - --direct --csv
|
|
101
|
+
|
|
102
|
+
# Combine with date filter
|
|
103
|
+
ecbfx --pairs transactions.csv --from 2025-01-01 --to 2025-03-31 --csv
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Convention
|
|
107
|
+
|
|
108
|
+
| Flag | Formula | Example |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| *(default)* | foreign units per 1 EUR | `1 EUR = 1.0830 USD` |
|
|
111
|
+
| `--direct` | EUR per 1 foreign unit | `1 USD = 0.9234 EUR` |
|
|
112
|
+
|
|
113
|
+
ECB publishes indirect natively. `--direct` inverts the rate.
|
|
114
|
+
The `convention` column in CSV output (`USD/EUR` or `EUR/USD`) makes the
|
|
115
|
+
direction explicit for downstream pipelines.
|
|
116
|
+
|
|
117
|
+
### Flags reference
|
|
118
|
+
|
|
119
|
+
| Flag | Default | Description |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `--direct` | off | EUR per 1 foreign unit instead of ECB native |
|
|
122
|
+
| `--latest` | off | Most recent available rate, regardless of date |
|
|
123
|
+
| `--quiet` / `-q` | off | Print rate value(s) only — ideal for scripting |
|
|
124
|
+
| `--decimal N` | 4 | Decimal places in output rate |
|
|
125
|
+
| `--no-gap-fill` | off | Raise an error on weekends/holidays instead of substituting the nearest rate |
|
|
126
|
+
| `--csv` | off | CSV output instead of formatted table |
|
|
127
|
+
| `--pairs FILE\|-` | — | Read `date,currency` pairs from a file or stdin |
|
|
128
|
+
|
|
129
|
+
### Weekend and holiday gap-filling
|
|
130
|
+
|
|
131
|
+
ECB only publishes rates on trading days. By default, `ecbfx` automatically
|
|
132
|
+
uses the most recent prior trading day's rate (Last Observation Carried Forward)
|
|
133
|
+
when a requested date falls on a weekend or public holiday — including the first
|
|
134
|
+
date in a range that starts on a holiday such as January 1st.
|
|
135
|
+
|
|
136
|
+
Use `--no-gap-fill` to disable this and receive an explicit error instead —
|
|
137
|
+
useful in audit workflows where a substituted rate is not acceptable.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
### Exit codes
|
|
143
|
+
|
|
144
|
+
| Code | Meaning |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `0` | Success |
|
|
147
|
+
| `1` | Runtime error (ECB API failure, network issue, no data returned) |
|
|
148
|
+
| `2` | Usage error (invalid arguments, bad date format, missing required flag) |
|
|
149
|
+
|
|
150
|
+
Useful for scripting:
|
|
151
|
+
```bash
|
|
152
|
+
ecbfx USD --quiet || echo "fetch failed, exit $?"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Python API
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from datetime import date
|
|
159
|
+
from ecbfx import fetch_rates, fetch_rates_for_pairs, fetch_latest
|
|
160
|
+
|
|
161
|
+
# Indirect (default) — foreign units per 1 EUR, contiguous range
|
|
162
|
+
rows = fetch_rates(["USD", "GBP"], date(2025, 1, 1), date(2025, 1, 31))
|
|
163
|
+
|
|
164
|
+
# Direct — EUR per 1 foreign unit
|
|
165
|
+
rows = fetch_rates(["USD"], date(2025, 1, 15), date(2025, 1, 15), direct=True)
|
|
166
|
+
|
|
167
|
+
# Most recent available rate
|
|
168
|
+
rows = fetch_latest(["USD", "CHF"])
|
|
169
|
+
|
|
170
|
+
# Sparse transaction dates — one HTTP call per currency regardless of pair count
|
|
171
|
+
pairs = [
|
|
172
|
+
(date(2025, 1, 15), "USD"),
|
|
173
|
+
(date(2025, 1, 20), "GBP"),
|
|
174
|
+
(date(2025, 2, 3), "USD"),
|
|
175
|
+
(date(2025, 2, 3), "CHF"),
|
|
176
|
+
]
|
|
177
|
+
rows = fetch_rates_for_pairs(pairs, direct=True)
|
|
178
|
+
|
|
179
|
+
# Custom decimal precision
|
|
180
|
+
rows = fetch_rates(["USD"], date(2025, 1, 15), date(2025, 1, 15), decimals=6)
|
|
181
|
+
|
|
182
|
+
# Strict mode — raises ECBError on weekends/holidays
|
|
183
|
+
rows = fetch_rates(["USD"], date(2025, 1, 13), date(2025, 1, 13), gap_fill=False)
|
|
184
|
+
rows = fetch_rates_for_pairs([(date(2025, 1, 13), "USD")], gap_fill=False)
|
|
185
|
+
|
|
186
|
+
for r in rows:
|
|
187
|
+
print(r["date"], r["currency"], r["convention"], r["rate"])
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Each row is a dict: `{date, currency, rate, convention}`.
|
|
191
|
+
|
|
192
|
+
### Input validation
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from ecbfx import validate_currency, ECBError
|
|
196
|
+
|
|
197
|
+
# Normalises and validates a currency code — raises ECBError if invalid
|
|
198
|
+
print(validate_currency("usd")) # → "USD"
|
|
199
|
+
print(validate_currency(" GBP ")) # → "GBP"
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
validate_currency("US$")
|
|
203
|
+
except ECBError as e:
|
|
204
|
+
print(e) # Invalid currency code 'US$'. Expected 2–4 ASCII letters.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Use `ECBError` in `try/except` blocks when calling any `ecbfx` function
|
|
208
|
+
to handle API failures, network errors, or invalid inputs cleanly.
|
|
209
|
+
|
|
210
|
+
### fetch_rates vs fetch_rates_for_pairs
|
|
211
|
+
|
|
212
|
+
| | `fetch_rates` | `fetch_rates_for_pairs` |
|
|
213
|
+
|---|---|---|
|
|
214
|
+
| Input | currency list + date range | list of `(date, currency)` tuples |
|
|
215
|
+
| Returns | every calendar day in range | exactly the requested dates |
|
|
216
|
+
| Best for | daily pipelines, backfill | transaction enrichment, broker CSVs |
|
|
217
|
+
| HTTP calls | one per currency | one per currency (full span, regardless of gaps) |
|
|
218
|
+
|
|
219
|
+
> **Note:** `fetch_rates_for_pairs` always fetches the full date span from the earliest to the latest date per currency in a single HTTP call. For very sparse data (e.g. two transactions 10 years apart), this fetches the entire intervening range. A warning is logged when the span exceeds one year.
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ecbfx/__init__.py,sha256=kUtX49xLQ5M6dzKU1w_rKSwoHoPH9tLPY1lV1LCWvuI,551
|
|
2
|
+
ecbfx/api.py,sha256=x9lyfMxNZWG9tQbpVNMpqjex7sy24ihvLhrByVclcAA,19132
|
|
3
|
+
ecbfx/cli.py,sha256=5u9VH-BtUFoPglKzS5mFIfEk1CQTwKl3tQdSZ_vL5mc,17653
|
|
4
|
+
ecbfx-0.2.0.dist-info/METADATA,sha256=Q9n-ptFHPUWdz5cDHFLw0SLMmx53lI1bobarzYQWfsc,7491
|
|
5
|
+
ecbfx-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
ecbfx-0.2.0.dist-info/entry_points.txt,sha256=PqALcYDZ0uQ3m1YF756SCjUTOIHsDlb75759yYhyYkg,41
|
|
7
|
+
ecbfx-0.2.0.dist-info/licenses/LICENSE,sha256=ykjvlRCsC-Sg-BU9M68Gvp2PQQxo2fh4RC2EaUCHcKM,1074
|
|
8
|
+
ecbfx-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 edvinassvedas-dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|