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 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
+ [![PyPI version](https://img.shields.io/pypi/v/ecbfx.svg)](https://pypi.org/project/ecbfx/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/ecbfx.svg)](https://pypi.org/project/ecbfx/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ecbfx = ecbfx.cli:main
@@ -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.