@@ -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