Skip to content

Commit cc7fca1

Browse files
hanlulongclaude
andcommitted
Providers: IMF multi-country accumulation, CoinGecko honesty,
ExchangeRate keyed endpoint, retrieval-layer concurrency - IMF SDMX multi-country batches accumulate first-success-per-country (a 3-country comparison returned after country #1, silently dropping the rest); CSV series group by the DSD structure's real dimension ids so attribute columns (STATUS) can't fragment a series; DataMapper source links use the code's actual dataset anchor, not @weo. - CoinGecko: the free-tier 365-day history clamp is disclosed via a metadata note on both paths, and frequency is derived from the ACTUAL point spacing (8-90 day hourly windows were labeled "daily"). - ExchangeRate: the keyed v6 endpoint returns conversion_rates, not rates — setting an API key would have hard-broken every spot query. - Indicator retrieval: FTS query builder strips ALL non-word characters (an enumerated escape list let '"2025,"*' through — an FTS5 syntax error that killed the whole lexical arm for any punctuated query, seen in prod); sqlite connections are per-thread (the shared connection corrupted in-flight cursors under concurrent searches, ~40/day InterfaceError in prod, silently degrading retrieval). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0a2e246 commit cc7fca1

7 files changed

Lines changed: 570 additions & 63 deletions

File tree

backend/providers/coingecko.py

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,34 @@ class CoinGeckoProvider(BaseProvider):
4747
def provider_name(self) -> str:
4848
return "CoinGecko"
4949

50+
@staticmethod
51+
def _frequency_from_point_spacing(raw_points: Any, requested_days: float) -> str:
52+
"""Label frequency from the ACTUAL spacing of the returned points.
53+
54+
CoinGecko's market_chart auto-granularity (intraday / hourly / daily)
55+
depends on the window in ways that have shifted over time; deriving
56+
the label from the served data can never mislabel (a request-days
57+
ladder called 8-90 day hourly windows "daily").
58+
"""
59+
timestamps = [pt[0] for pt in (raw_points or []) if isinstance(pt, (list, tuple)) and pt]
60+
if len(timestamps) >= 3:
61+
deltas = sorted(
62+
t2 - t1 for t1, t2 in zip(timestamps, timestamps[1:]) if t2 > t1
63+
)
64+
if deltas:
65+
median_ms = deltas[len(deltas) // 2]
66+
if median_ms <= 30 * 60 * 1000:
67+
return "5-minute"
68+
if median_ms <= 6 * 60 * 60 * 1000:
69+
return "hourly"
70+
return "daily"
71+
# Too few points to measure — fall back to the request-window ladder.
72+
if requested_days <= 1:
73+
return "5-minute"
74+
if requested_days <= 90:
75+
return "hourly"
76+
return "daily"
77+
5078
def __init__(self, api_key: Optional[str] = None, timeout: float = 30.0):
5179
"""
5280
Initialize CoinGecko provider.
@@ -308,9 +336,11 @@ async def get_historical_data(
308336

309337
# CoinGecko free/demo tier has a 365-day limit for historical data
310338
# Pro tier doesn't have this limit
339+
was_clamped_to_free_tier = False
311340
if not self.is_pro and days > 365:
312341
logger.warning(f"⚠️ CoinGecko: Free tier limited to 365 days, requested {days} days. Capping at 365.")
313342
days = 365
343+
was_clamped_to_free_tier = True
314344
params = {
315345
"vs_currency": vs_currency,
316346
"days": str(days),
@@ -349,13 +379,11 @@ async def get_historical_data(
349379

350380
logger.info(f"📊 Created {len(data_points)} data points for {metric}")
351381

352-
# Determine frequency
353-
if days == 1:
354-
frequency = "5-minute"
355-
elif days <= 7:
356-
frequency = "hourly"
357-
else:
358-
frequency = "daily"
382+
# Determine frequency from the ACTUAL point spacing the API returned
383+
# (its auto-granularity: intraday for 1 day, hourly for 2-90 days,
384+
# daily beyond). The old request-days ladder mislabeled 8-90 day
385+
# windows as "daily" while the points were hourly.
386+
frequency = self._frequency_from_point_spacing(data.get(metric_key, []), days)
359387

360388
# Determine indicator name and unit based on metric
361389
metric_display_mapping = {
@@ -374,6 +402,14 @@ async def get_historical_data(
374402
lastUpdated=datetime.now().isoformat(),
375403
seriesId=coin_id,
376404
apiUrl=api_url,
405+
notes=(
406+
[
407+
"CoinGecko's free tier limits history to the last 365 days; "
408+
"the requested range was truncated accordingly."
409+
]
410+
if was_clamped_to_free_tier
411+
else None
412+
),
377413
)
378414

379415
return [NormalizedData(metadata=metadata, data=data_points)]
@@ -419,9 +455,11 @@ async def get_historical_data_range(
419455
max_days_ago = 365 * 24 * 60 * 60 # 365 days in seconds
420456
min_allowed_timestamp = now_timestamp - max_days_ago
421457

458+
was_clamped_to_free_tier = False
422459
if not self.is_pro and from_timestamp < min_allowed_timestamp:
423460
logger.warning(f"⚠️ CoinGecko: Free tier limited to 365 days. Adjusting from_date.")
424461
from_timestamp = min_allowed_timestamp
462+
was_clamped_to_free_tier = True
425463
logger.info(f" - Adjusted from_timestamp: {datetime.fromtimestamp(from_timestamp, tz=timezone.utc).isoformat()}")
426464

427465
params = {
@@ -456,14 +494,9 @@ async def get_historical_data_range(
456494
except (ValueError, TypeError):
457495
continue
458496

459-
# Determine frequency based on date range
497+
# Frequency from actual point spacing (see _frequency_from_point_spacing)
460498
days_diff = (to_timestamp - from_timestamp) / 86400
461-
if days_diff <= 1:
462-
frequency = "5-minute"
463-
elif days_diff <= 7:
464-
frequency = "hourly"
465-
else:
466-
frequency = "daily"
499+
frequency = self._frequency_from_point_spacing(data.get(metric_key, []), days_diff)
467500

468501
# Determine indicator name and unit based on metric
469502
metric_display_mapping = {
@@ -482,6 +515,14 @@ async def get_historical_data_range(
482515
lastUpdated=datetime.now().isoformat(),
483516
seriesId=coin_id,
484517
apiUrl=api_url,
518+
notes=(
519+
[
520+
"CoinGecko's free tier limits history to the last 365 days; "
521+
"the requested range was truncated accordingly."
522+
]
523+
if was_clamped_to_free_tier
524+
else None
525+
),
485526
)
486527

487528
return [NormalizedData(metadata=metadata, data=data_points)]

backend/providers/exchangerate.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ async def fetch_exchange_rate(
133133
response = await self._get_with_retry(client, full_url, timeout=15.0)
134134
logger.info(f"📊 Response status: {response.status_code}")
135135
data = response.json()
136-
logger.info(f"✅ Response received. Result: {data.get('result')}, Rates count: {len(data.get('rates', {}))}")
136+
logger.info(f"✅ Response received. Result: {data.get('result')}, Rates count: {len(data.get('rates') or data.get('conversion_rates') or {})}")
137137

138138
if data.get("result") != "success":
139139
error_msg = data.get('error-type', 'Unknown error')
@@ -156,7 +156,10 @@ async def fetch_exchange_rate(
156156
logger.error(f"Unexpected error fetching exchange rates: {str(e)}")
157157
raise DataNotAvailableError(f"Failed to fetch exchange rates: {str(e)}")
158158

159-
rates = data.get("rates", {})
159+
# Keyless open endpoint returns "rates"; the keyed v6 API returns
160+
# "conversion_rates" for the same call — read whichever is present so
161+
# setting an API key doesn't hard-break every spot-rate query.
162+
rates = data.get("rates") or data.get("conversion_rates") or {}
160163
time_last_update = data.get("time_last_update_utc", "")
161164

162165
logger.info(f"📈 Processing {len(rates)} exchange rates")

backend/providers/imf.py

Lines changed: 111 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -707,13 +707,32 @@ async def fetch_batch_indicator(
707707
# an EMPTY unit. Use the catalog unit when present; fall back to the
708708
# heuristic only when it is missing. Graceful on any lookup failure.
709709
catalog_unit = ""
710+
# DataMapper website anchor for the verification sourceUrl. The catalog
711+
# records the DataMapper dataset a code belongs to (WEO, FM, GDD,
712+
# AFRREO, ...) in the raw payload and in the `category` column; the
713+
# legacy hardcoded "@WEO" mislabeled every non-WEO code. Prefer the
714+
# payload's own dataset id, then the catalog category. If neither is
715+
# derivable, the URL below links the generic indicator page instead of
716+
# asserting a wrong dataset.
717+
dataset_anchor = ""
710718
try:
711719
from ..services.indicator_database import get_indicator_lookup
712720
_imf_meta = get_indicator_lookup().get("IMF", indicator_code)
713721
if _imf_meta:
714722
catalog_unit = str(_imf_meta.get("unit") or "").strip()
723+
raw_meta = _imf_meta.get("raw_metadata")
724+
if raw_meta:
725+
try:
726+
dataset_anchor = str((json.loads(raw_meta) or {}).get("dataset") or "").strip()
727+
except (ValueError, TypeError):
728+
dataset_anchor = ""
729+
if not dataset_anchor:
730+
category = str(_imf_meta.get("category") or "").strip()
731+
if category and category.upper() != "INDICATOR":
732+
dataset_anchor = category
715733
except Exception:
716734
catalog_unit = ""
735+
dataset_anchor = ""
717736

718737
# Process each requested country
719738
results = []
@@ -771,9 +790,16 @@ async def fetch_batch_indicator(
771790
# see the FRED provider note on the removed value-sniffing
772791
# percent normalizer (it corrupted legitimately small series).
773792

774-
# Human-readable URL for data verification on IMF DataMapper website
775-
# Format: https://www.imf.org/external/datamapper/{INDICATOR_CODE}@WEO/{COUNTRY}
776-
source_url = f"https://www.imf.org/external/datamapper/{indicator_code}@WEO/{country_code}"
793+
# Human-readable URL for data verification on IMF DataMapper website.
794+
# Format with a known dataset:
795+
# https://www.imf.org/external/datamapper/{CODE}@{DATASET}/{COUNTRY}
796+
# When the dataset is not derivable from the catalog, link the
797+
# dataset-agnostic indicator page (IMF's routing picks the dataset)
798+
# rather than asserting a wrong "@WEO" anchor.
799+
if dataset_anchor:
800+
source_url = f"https://www.imf.org/external/datamapper/{indicator_code}@{dataset_anchor}/{country_code}"
801+
else:
802+
source_url = f"https://www.imf.org/external/datamapper/{indicator_code}"
777803

778804
# Build country-specific API URL for reproducibility
779805
# Format: https://www.imf.org/external/datamapper/api/v1/{INDICATOR_CODE}/{COUNTRY}
@@ -1556,34 +1582,66 @@ def _parse_sdmx_structure_specific_xml(
15561582
def _parse_sdmx_csv(
15571583
self,
15581584
response_text: str,
1585+
dimension_ids: Optional[List[str]] = None,
15591586
) -> List[tuple[Dict[str, str], List[Dict[str, str]]]]:
1560-
"""Parse IMF SDMX CSV into the same series/observation shape as XML."""
1587+
"""Parse IMF SDMX CSV into the same series/observation shape as XML.
1588+
1589+
Series identity is defined by the DSD's structural dimensions only.
1590+
When the caller supplies the dataflow's ``dimension_ids`` (from
1591+
``_get_imf_dataflow_structure``), group strictly by those columns and
1592+
treat every other column as a per-observation attribute. Without
1593+
structure metadata, fall back to a conservative exclusion list of the
1594+
observation/attribute columns the live IMF.STA CSV is known to emit.
1595+
"""
15611596
rows = [
15621597
{str(key): str(value) for key, value in row.items() if key is not None and value is not None}
15631598
for row in csv.DictReader(io.StringIO(str(response_text or "")))
15641599
]
15651600
grouped: Dict[tuple[tuple[str, str], ...], List[Dict[str, str]]] = {}
15661601
series_dimensions_by_key: Dict[tuple[tuple[str, str], ...], Dict[str, str]] = {}
1567-
observation_columns = {
1602+
1603+
allowed_dimensions: Optional[set[str]] = None
1604+
if dimension_ids:
1605+
allowed_dimensions = {
1606+
str(dim).strip().upper() for dim in dimension_ids if str(dim or "").strip()
1607+
}
1608+
1609+
# Observation values and per-observation attributes are never
1610+
# series-defining. STATUS/SCALE/DECIMALS_DISPLAYED are the real column
1611+
# names emitted by the live IMF.STA SDMX 2.1 CSV; the SDMX-standard
1612+
# OBS_STATUS/UNIT_MULT/DECIMALS names are kept for other flows/versions.
1613+
attribute_columns = {
15681614
"TIME_PERIOD",
15691615
"OBS_VALUE",
15701616
"OBS_STATUS",
15711617
"OBS_CONF",
15721618
"UNIT_MULT",
15731619
"DECIMALS",
1620+
"STATUS",
1621+
"SCALE",
1622+
"DECIMALS_DISPLAYED",
15741623
}
15751624

15761625
for row in rows:
15771626
if not row.get("TIME_PERIOD") or "OBS_VALUE" not in row:
15781627
continue
1579-
dimensions = {
1580-
key: value
1581-
for key, value in row.items()
1582-
if key not in observation_columns
1583-
and not key.startswith("OBS_")
1584-
and key
1585-
and value != ""
1586-
}
1628+
if allowed_dimensions is not None:
1629+
dimensions = {
1630+
key: value
1631+
for key, value in row.items()
1632+
if key
1633+
and str(key).strip().upper() in allowed_dimensions
1634+
and value != ""
1635+
}
1636+
else:
1637+
dimensions = {
1638+
key: value
1639+
for key, value in row.items()
1640+
if key
1641+
and str(key).strip().upper() not in attribute_columns
1642+
and not str(key).startswith("OBS_")
1643+
and value != ""
1644+
}
15871645
key = tuple(sorted(dimensions.items()))
15881646
series_dimensions_by_key.setdefault(key, dimensions)
15891647
grouped.setdefault(key, []).append(row)
@@ -1609,9 +1667,24 @@ async def _fetch_sdmx_exact_indicator_family(
16091667
last_error: Optional[Exception] = None
16101668
indicator_name = indicator_label or indicator_code
16111669

1670+
# Accumulate the first successful candidate per requested country so a
1671+
# multi-country comparison returns every country that resolves, not just
1672+
# the first one. Candidates are country-major (see
1673+
# _build_sdmx_series_candidates), so the previous early ``return`` on the
1674+
# first success silently dropped every country after the first. This
1675+
# mirrors the DataMapper JSON path, which iterates all requested
1676+
# countries before returning. Partial coverage is intentional; the
1677+
# pipeline's coverage-warning machinery discloses the missing countries.
1678+
results_by_country: Dict[str, List[NormalizedData]] = {}
1679+
16121680
for candidate in candidates:
16131681
flow = candidate["flow"]
16141682
key = candidate["key"]
1683+
candidate_country = candidate.get("country") or key.split(".", 1)[0]
1684+
# A country already satisfied by an earlier candidate (for CPI, a
1685+
# lower frequency) does not need its remaining candidates attempted.
1686+
if candidate_country in results_by_country:
1687+
continue
16151688
url = self._sdmx_data_url(
16161689
flow=flow,
16171690
key=key,
@@ -1640,14 +1713,26 @@ async def _fetch_sdmx_exact_indicator_family(
16401713
response_text = getattr(response, "text", "")
16411714
content_type = str(getattr(response, "headers", {}).get("content-type", "")).lower()
16421715
if "csv" in content_type or str(response_text).lstrip().startswith("DATAFLOW,"):
1643-
series_payloads = self._parse_sdmx_csv(response_text)
1716+
# Series identity must be defined by the DSD's structural
1717+
# dimensions only. The live IMF.STA CSV also emits per-obs
1718+
# attribute columns (STATUS/SCALE/DECIMALS_DISPLAYED) that,
1719+
# if treated as dimensions, fragment a series whose STATUS
1720+
# varies mid-history. Pass the real dimension ids so the CSV
1721+
# parser groups strictly by them (cached structure lookup).
1722+
structure = await self._get_imf_dataflow_structure(flow)
1723+
dimension_ids = (
1724+
structure.get("dimension_ids")
1725+
if isinstance(structure, dict)
1726+
else None
1727+
)
1728+
series_payloads = self._parse_sdmx_csv(response_text, dimension_ids)
16441729
else:
16451730
series_payloads = self._parse_sdmx_structure_specific_xml(response_text)
16461731
except Exception as exc:
16471732
last_error = exc
16481733
continue
16491734

1650-
results: List[NormalizedData] = []
1735+
candidate_results: List[NormalizedData] = []
16511736
for series_attrs, observations in series_payloads:
16521737
data_points = [
16531738
{
@@ -1695,10 +1780,18 @@ async def _fetch_sdmx_exact_indicator_family(
16951780
startDate=data_points[0]["date"],
16961781
endDate=data_points[-1]["date"],
16971782
)
1698-
results.append(NormalizedData(metadata=metadata, data=data_points))
1783+
candidate_results.append(NormalizedData(metadata=metadata, data=data_points))
1784+
1785+
if candidate_results:
1786+
results_by_country[candidate_country] = candidate_results
16991787

1700-
if results:
1701-
return results
1788+
all_results = [
1789+
series
1790+
for country_results in results_by_country.values()
1791+
for series in country_results
1792+
]
1793+
if all_results:
1794+
return all_results
17021795

17031796
diagnostic = f" Attempted SDMX keys: {', '.join(attempted)}." if attempted else ""
17041797
error_suffix = f" Last error: {last_error}." if last_error else ""

0 commit comments

Comments
 (0)